Skip to content

Commit e904a50

Browse files
authored
Build the core L2 code for IMAP-Ultra using the ENA Map code
1 parent bd72c9f commit e904a50

File tree

6 files changed

+1013
-37
lines changed

6 files changed

+1013
-37
lines changed

imap_processing/ena_maps/ena_maps.py

Lines changed: 216 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
from __future__ import annotations
44

5+
import json
56
import logging
67
import pathlib
78
from abc import ABC, abstractmethod
89
from enum import Enum
10+
from pathlib import Path
911

1012
import astropy_healpix.healpy as hp
1113
import numpy as np
@@ -234,7 +236,7 @@ def unwrapped_dims_dict(self) -> dict[str, tuple[str, ...]]:
234236
unwrapped_dims_dict : dict[str, tuple[str, ...]]
235237
Dictionary of variable names and their dimensions, with only 1 spatial dim.
236238
The generic pixel dimension is always included.
237-
E.g.: {"counts": ("epoch", "energy_bin_center", "pixel")} .
239+
E.g.: {"counts": ("epoch", "energy", "pixel")} .
238240
"""
239241
variable_dims = {}
240242
for var_name in self.data.data_vars:
@@ -438,6 +440,9 @@ def __init__(
438440
self.num_points = self.data[CoordNames.HEALPIX_INDEX.value].size
439441
self.nside = hp.npix_to_nside(self.num_points)
440442

443+
# Tracks Per-Pixel Solid Angle in steradians.
444+
self.solid_angle = hp.nside2pixarea(self.nside, degrees=False)
445+
441446
# Determine if the HEALPix tessellation is nested, default is False
442447
self.nested = bool(
443448
self.data[CoordNames.HEALPIX_INDEX.value].attrs.get("nested", False)
@@ -478,6 +483,48 @@ def __init__(
478483
(self.azimuth_pixel_center, self.elevation_pixel_center)
479484
)
480485

486+
@classmethod
487+
def from_path_or_dataset(
488+
cls,
489+
input_data: xr.Dataset | str | pathlib.Path,
490+
) -> UltraPointingSet:
491+
"""
492+
Read a path or Dataset into an UltraPointingSet.
493+
494+
Parameters
495+
----------
496+
input_data : xr.Dataset | str | pathlib.Path
497+
Path to the CDF file or xarray Dataset containing the L1C dataset.
498+
If a dataset is provided, it will be copied to avoid modifying the original.
499+
500+
Returns
501+
-------
502+
UltraPointingSet
503+
An UltraPointingSet object containing the L1C dataset.
504+
505+
Raises
506+
------
507+
ValueError
508+
If input_data is neither an xarray Dataset nor a path to a CDF file.
509+
"""
510+
# Allow for passing in EITHER xarray Datasets (preferable for testing)
511+
if isinstance(input_data, xr.Dataset):
512+
# Copy to avoid modifying the original dataset in place
513+
input_data = input_data.copy(deep=True)
514+
ultra_pointing_set = UltraPointingSet(l1c_dataset=input_data)
515+
# OR paths to CDF files (preferable for projecting many PointingSets)
516+
elif isinstance(input_data, str | pathlib.Path):
517+
if isinstance(input_data, str):
518+
input_data = pathlib.Path(input_data)
519+
ultra_pointing_set = UltraPointingSet(l1c_dataset=load_cdf(input_data))
520+
else:
521+
raise ValueError(
522+
f"Input data must be either an xarray Dataset or a path to a CDF file "
523+
"containing the L1C dataset.\n"
524+
f"Found {type(input_data)} instead."
525+
)
526+
return ultra_pointing_set
527+
481528
def __repr__(self) -> str:
482529
"""
483530
Return a string representation of the UltraPointingSet.
@@ -519,6 +566,10 @@ def __init__(self) -> None:
519566
self.binning_grid_shape: tuple[int, ...]
520567
self.data_1d: xr.Dataset
521568

569+
# Initialize values to be used by the instrument code to push/pull
570+
self.values_to_push_project: list[str] = []
571+
self.values_to_pull_project: list[str] = []
572+
522573
def to_dataset(self) -> xr.Dataset:
523574
"""
524575
Get the SkyMap data as a formatted xarray Dataset.
@@ -685,8 +736,164 @@ def project_pset_values_to_map(
685736
"Only PUSH and PULL index matching methods are supported."
686737
)
687738

739+
# TODO: we may need to allow for unweighted/weighted means here by
740+
# dividing pointing_projected_values by some binned weights.
741+
# For unweighted means, we could use the number of pointing set pixels
742+
# that correspond to each map pixel as the weights.
688743
self.data_1d[value_key] += pointing_projected_values
689744

745+
@classmethod
746+
def from_json(cls, json_path: str | Path) -> RectangularSkyMap | HealpixSkyMap:
747+
"""
748+
Create a SkyMap object from a JSON configuration file.
749+
750+
Parameters
751+
----------
752+
json_path : str | Path
753+
Path to the JSON configuration file.
754+
755+
Returns
756+
-------
757+
RectangularSkyMap | HealpixSkyMap
758+
An instance of a SkyMap object with the specified properties.
759+
"""
760+
with open(json_path) as f:
761+
properties = json.load(f)
762+
return cls.from_dict(properties)
763+
764+
@classmethod
765+
def from_dict(cls, properties: dict) -> RectangularSkyMap | HealpixSkyMap:
766+
"""
767+
Create a SkyMap object from a dictionary of properties.
768+
769+
Parameters
770+
----------
771+
properties : dict
772+
Dictionary containing the map properties. The required keys are:
773+
- "spice_reference_frame" : str
774+
The reference Spice frame of the map as a string. The available
775+
options are defined in the spice geometry module:
776+
`imap_processing.geometry.spice.SpiceFrame`. Example: "ECLIPJ2000".
777+
- "sky_tiling_type" : str
778+
The type of sky tiling, either "HEALPIX" or "RECTANGULAR".
779+
- if "HEALPIX":
780+
- "nside" : int
781+
The nside parameter for the Healpix tessellation.
782+
- "nested" : bool
783+
Whether the Healpix tessellation is nested or not.
784+
- if "RECTANGULAR":
785+
- "spacing_deg" : float
786+
The spacing of the rectangular grid in degrees.
787+
- "values_to_push_project" : list[str], optional
788+
The names of the variables to project to the map with the PUSH method.
789+
NOTE: The projection is done by the instrument code, so this value can
790+
only be used to inform that code. No values are projected automatically.
791+
- "values_to_pull_project" : list[str], optional
792+
The names of the variables to project to the map with the PULL method.
793+
See the above note for more details.
794+
795+
See example dictionary in notes section.
796+
797+
Returns
798+
-------
799+
RectangularSkyMap | HealpixSkyMap
800+
An instance of a SkyMap object with the specified properties.
801+
802+
Raises
803+
------
804+
ValueError
805+
If the sky tiling type is not recognized.
806+
807+
Notes
808+
-----
809+
Example dictionary:
810+
811+
```python
812+
properties = {
813+
"spice_reference_frame": "ECLIPJ2000",
814+
"sky_tiling_type": "HEALPIX",
815+
"nside": 32,
816+
"nested": False,
817+
"values_to_push_project": ['counts', 'flux'],
818+
"values_to_pull_project": []
819+
}
820+
```
821+
"""
822+
sky_tiling_type = SkyTilingType[properties["sky_tiling_type"].upper()]
823+
spice_reference_frame = geometry.SpiceFrame[properties["spice_reference_frame"]]
824+
825+
skymap: RectangularSkyMap | HealpixSkyMap # Mypy gets confused by if/elif types
826+
if sky_tiling_type is SkyTilingType.HEALPIX:
827+
skymap = HealpixSkyMap(
828+
nside=properties["nside"],
829+
nested=properties["nested"],
830+
spice_frame=spice_reference_frame,
831+
)
832+
elif sky_tiling_type is SkyTilingType.RECTANGULAR:
833+
skymap = RectangularSkyMap(
834+
spacing_deg=properties["spacing_deg"],
835+
spice_frame=spice_reference_frame,
836+
)
837+
else:
838+
raise ValueError(
839+
f"Unknown sky tiling type: {sky_tiling_type}. "
840+
f"Must be one of: {SkyTilingType.__members__.keys()}"
841+
)
842+
843+
# Store requested variables to push/pull, which will be done by the instrument
844+
# code which creates and uses the SkyMap object.
845+
skymap.values_to_push_project = properties.get("values_to_push_project", [])
846+
skymap.values_to_pull_project = properties.get("values_to_pull_project", [])
847+
return skymap
848+
849+
def to_dict(self) -> dict:
850+
"""
851+
Convert the SkyMap object to a dictionary of properties.
852+
853+
Returns
854+
-------
855+
dict
856+
Dictionary containing the map properties.
857+
"""
858+
if isinstance(self, HealpixSkyMap):
859+
map_properties_dict = {
860+
"sky_tiling_type": "HEALPIX",
861+
"spice_reference_frame": self.spice_reference_frame.name,
862+
"nside": self.nside,
863+
"nested": self.nested,
864+
}
865+
elif isinstance(self, RectangularSkyMap):
866+
map_properties_dict = {
867+
"sky_tiling_type": "RECTANGULAR",
868+
"spice_reference_frame": self.spice_reference_frame.name,
869+
"spacing_deg": self.spacing_deg,
870+
}
871+
else:
872+
raise ValueError(
873+
f"Unknown SkyMap type: {self.__class__.__name__}. "
874+
f"Must be one of: {AbstractSkyMap.__subclasses__()}"
875+
)
876+
877+
map_properties_dict["values_to_push_project"] = (
878+
self.values_to_push_project if self.values_to_push_project else []
879+
)
880+
map_properties_dict["values_to_pull_project"] = (
881+
self.values_to_pull_project if self.values_to_pull_project else []
882+
)
883+
return map_properties_dict
884+
885+
def to_json(self, json_path: str | Path) -> None:
886+
"""
887+
Save the SkyMap object to a JSON configuration file.
888+
889+
Parameters
890+
----------
891+
json_path : str | Path
892+
Path to the JSON file where the properties will be saved.
893+
"""
894+
with open(json_path, "w") as f:
895+
json.dump(self.to_dict(), f, indent=4)
896+
690897

691898
class RectangularSkyMap(AbstractSkyMap):
692899
"""
@@ -749,6 +956,10 @@ def __init__(
749956
# The reference Spice frame of the map, in which angles are defined
750957
self.spice_reference_frame = spice_frame
751958

959+
# Initialize values to be used by the instrument code to push/pull
960+
self.values_to_push_project: list[str] = []
961+
self.values_to_pull_project: list[str] = []
962+
752963
# Angular spacing of the map grid (degrees) defines the number, size of pixels.
753964
self.spacing_deg = spacing_deg
754965
self.sky_grid = spatial_utils.AzElSkyGrid(
@@ -829,6 +1040,10 @@ def __init__(
8291040
self.tiling_type = SkyTilingType.HEALPIX
8301041
self.spice_reference_frame = spice_frame
8311042

1043+
# Initialize values to be used by the instrument code to push/pull
1044+
self.values_to_push_project: list[str] = []
1045+
self.values_to_pull_project: list[str] = []
1046+
8321047
# Tile the sky with a Healpix tessellation. Defined by nside, nested parameters.
8331048
self.nside = nside
8341049
self.nested = nested

imap_processing/ena_maps/utils/coordinates.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class CoordNames(Enum):
99
GENERIC_PIXEL = "pixel"
1010

1111
TIME = "epoch"
12-
ENERGY = "energy"
12+
ENERGY_ULTRA = "energy_bin_geometric_mean"
1313
HEALPIX_INDEX = "healpix_index"
1414

1515
# The names of the az/el angular coordinates may differ between L1C and L2 data

0 commit comments

Comments
 (0)