imap-processing 0.18.0__py3-none-any.whl → 0.19.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of imap-processing might be problematic. Click here for more details.

Files changed (122) hide show
  1. imap_processing/_version.py +2 -2
  2. imap_processing/ancillary/ancillary_dataset_combiner.py +161 -1
  3. imap_processing/cdf/config/imap_codice_global_cdf_attrs.yaml +6 -0
  4. imap_processing/cdf/config/imap_codice_l1a_variable_attrs.yaml +221 -1057
  5. imap_processing/cdf/config/imap_codice_l1b_variable_attrs.yaml +307 -283
  6. imap_processing/cdf/config/imap_codice_l2_variable_attrs.yaml +1044 -203
  7. imap_processing/cdf/config/imap_constant_attrs.yaml +4 -2
  8. imap_processing/cdf/config/imap_enamaps_l2-common_variable_attrs.yaml +11 -0
  9. imap_processing/cdf/config/imap_glows_l1b_variable_attrs.yaml +15 -1
  10. imap_processing/cdf/config/imap_hi_global_cdf_attrs.yaml +5 -0
  11. imap_processing/cdf/config/imap_hit_global_cdf_attrs.yaml +10 -4
  12. imap_processing/cdf/config/imap_idex_l2a_variable_attrs.yaml +33 -4
  13. imap_processing/cdf/config/imap_idex_l2b_variable_attrs.yaml +8 -91
  14. imap_processing/cdf/config/imap_idex_l2c_variable_attrs.yaml +106 -16
  15. imap_processing/cdf/config/imap_lo_global_cdf_attrs.yaml +5 -4
  16. imap_processing/cdf/config/imap_lo_l1a_variable_attrs.yaml +4 -15
  17. imap_processing/cdf/config/imap_lo_l1c_variable_attrs.yaml +189 -98
  18. imap_processing/cdf/config/imap_mag_global_cdf_attrs.yaml +85 -2
  19. imap_processing/cdf/config/imap_mag_l1c_variable_attrs.yaml +24 -1
  20. imap_processing/cdf/config/imap_ultra_global_cdf_attrs.yaml +20 -8
  21. imap_processing/cdf/config/imap_ultra_l1b_variable_attrs.yaml +45 -35
  22. imap_processing/cdf/config/imap_ultra_l1c_variable_attrs.yaml +110 -7
  23. imap_processing/cli.py +138 -93
  24. imap_processing/codice/codice_l0.py +2 -1
  25. imap_processing/codice/codice_l1a.py +167 -69
  26. imap_processing/codice/codice_l1b.py +42 -32
  27. imap_processing/codice/codice_l2.py +215 -9
  28. imap_processing/codice/constants.py +790 -603
  29. imap_processing/codice/data/lo_stepping_values.csv +1 -1
  30. imap_processing/decom.py +1 -4
  31. imap_processing/ena_maps/ena_maps.py +71 -43
  32. imap_processing/ena_maps/utils/corrections.py +291 -0
  33. imap_processing/ena_maps/utils/map_utils.py +20 -4
  34. imap_processing/ena_maps/utils/naming.py +8 -2
  35. imap_processing/glows/ancillary/imap_glows_exclusions-by-instr-team_20250923_v002.dat +10 -0
  36. imap_processing/glows/ancillary/imap_glows_map-of-excluded-regions_20250923_v002.dat +393 -0
  37. imap_processing/glows/ancillary/imap_glows_map-of-uv-sources_20250923_v002.dat +593 -0
  38. imap_processing/glows/ancillary/imap_glows_pipeline-settings_20250923_v002.json +54 -0
  39. imap_processing/glows/ancillary/imap_glows_suspected-transients_20250923_v002.dat +10 -0
  40. imap_processing/glows/l1b/glows_l1b.py +123 -18
  41. imap_processing/glows/l1b/glows_l1b_data.py +358 -47
  42. imap_processing/glows/l2/glows_l2.py +11 -0
  43. imap_processing/hi/hi_l1a.py +124 -3
  44. imap_processing/hi/hi_l1b.py +154 -71
  45. imap_processing/hi/hi_l1c.py +4 -109
  46. imap_processing/hi/hi_l2.py +104 -60
  47. imap_processing/hi/utils.py +262 -8
  48. imap_processing/hit/l0/constants.py +3 -0
  49. imap_processing/hit/l0/decom_hit.py +3 -6
  50. imap_processing/hit/l1a/hit_l1a.py +311 -21
  51. imap_processing/hit/l1b/hit_l1b.py +54 -126
  52. imap_processing/hit/l2/hit_l2.py +6 -6
  53. imap_processing/ialirt/calculate_ingest.py +219 -0
  54. imap_processing/ialirt/constants.py +12 -2
  55. imap_processing/ialirt/generate_coverage.py +15 -2
  56. imap_processing/ialirt/l0/ialirt_spice.py +6 -2
  57. imap_processing/ialirt/l0/parse_mag.py +293 -42
  58. imap_processing/ialirt/l0/process_hit.py +5 -3
  59. imap_processing/ialirt/l0/process_swapi.py +41 -25
  60. imap_processing/ialirt/process_ephemeris.py +70 -14
  61. imap_processing/ialirt/utils/create_xarray.py +1 -1
  62. imap_processing/idex/idex_l0.py +2 -2
  63. imap_processing/idex/idex_l1a.py +2 -3
  64. imap_processing/idex/idex_l1b.py +2 -3
  65. imap_processing/idex/idex_l2a.py +130 -4
  66. imap_processing/idex/idex_l2b.py +158 -143
  67. imap_processing/idex/idex_utils.py +1 -3
  68. imap_processing/lo/ancillary_data/imap_lo_hydrogen-geometric-factor_v001.csv +75 -0
  69. imap_processing/lo/ancillary_data/imap_lo_oxygen-geometric-factor_v001.csv +75 -0
  70. imap_processing/lo/l0/lo_science.py +25 -24
  71. imap_processing/lo/l1b/lo_l1b.py +93 -19
  72. imap_processing/lo/l1c/lo_l1c.py +273 -93
  73. imap_processing/lo/l2/lo_l2.py +949 -135
  74. imap_processing/lo/lo_ancillary.py +55 -0
  75. imap_processing/mag/l1a/mag_l1a.py +1 -0
  76. imap_processing/mag/l1a/mag_l1a_data.py +26 -0
  77. imap_processing/mag/l1b/mag_l1b.py +3 -2
  78. imap_processing/mag/l1c/interpolation_methods.py +14 -15
  79. imap_processing/mag/l1c/mag_l1c.py +23 -6
  80. imap_processing/mag/l1d/mag_l1d.py +57 -14
  81. imap_processing/mag/l1d/mag_l1d_data.py +202 -32
  82. imap_processing/mag/l2/mag_l2.py +2 -0
  83. imap_processing/mag/l2/mag_l2_data.py +14 -5
  84. imap_processing/quality_flags.py +23 -1
  85. imap_processing/spice/geometry.py +89 -39
  86. imap_processing/spice/pointing_frame.py +4 -8
  87. imap_processing/spice/repoint.py +78 -2
  88. imap_processing/spice/spin.py +28 -8
  89. imap_processing/spice/time.py +12 -22
  90. imap_processing/swapi/l1/swapi_l1.py +10 -4
  91. imap_processing/swapi/l2/swapi_l2.py +15 -17
  92. imap_processing/swe/l1b/swe_l1b.py +1 -2
  93. imap_processing/ultra/constants.py +30 -24
  94. imap_processing/ultra/l0/ultra_utils.py +9 -11
  95. imap_processing/ultra/l1a/ultra_l1a.py +1 -2
  96. imap_processing/ultra/l1b/badtimes.py +35 -11
  97. imap_processing/ultra/l1b/de.py +95 -31
  98. imap_processing/ultra/l1b/extendedspin.py +31 -16
  99. imap_processing/ultra/l1b/goodtimes.py +112 -0
  100. imap_processing/ultra/l1b/lookup_utils.py +281 -28
  101. imap_processing/ultra/l1b/quality_flag_filters.py +10 -1
  102. imap_processing/ultra/l1b/ultra_l1b.py +7 -7
  103. imap_processing/ultra/l1b/ultra_l1b_culling.py +169 -7
  104. imap_processing/ultra/l1b/ultra_l1b_extended.py +311 -69
  105. imap_processing/ultra/l1c/helio_pset.py +139 -37
  106. imap_processing/ultra/l1c/l1c_lookup_utils.py +289 -0
  107. imap_processing/ultra/l1c/spacecraft_pset.py +140 -29
  108. imap_processing/ultra/l1c/ultra_l1c.py +33 -24
  109. imap_processing/ultra/l1c/ultra_l1c_culling.py +92 -0
  110. imap_processing/ultra/l1c/ultra_l1c_pset_bins.py +400 -292
  111. imap_processing/ultra/l2/ultra_l2.py +54 -11
  112. imap_processing/ultra/utils/ultra_l1_utils.py +37 -7
  113. imap_processing/utils.py +3 -4
  114. {imap_processing-0.18.0.dist-info → imap_processing-0.19.2.dist-info}/METADATA +2 -2
  115. {imap_processing-0.18.0.dist-info → imap_processing-0.19.2.dist-info}/RECORD +118 -109
  116. imap_processing/idex/idex_l2c.py +0 -84
  117. imap_processing/spice/kernels.py +0 -187
  118. imap_processing/ultra/l1b/cullingmask.py +0 -87
  119. imap_processing/ultra/l1c/histogram.py +0 -36
  120. {imap_processing-0.18.0.dist-info → imap_processing-0.19.2.dist-info}/LICENSE +0 -0
  121. {imap_processing-0.18.0.dist-info → imap_processing-0.19.2.dist-info}/WHEEL +0 -0
  122. {imap_processing-0.18.0.dist-info → imap_processing-0.19.2.dist-info}/entry_points.txt +0 -0
@@ -2,13 +2,18 @@
2
2
 
3
3
  import logging
4
4
  from pathlib import Path
5
- from typing import Literal
6
5
 
7
6
  import numpy as np
7
+ import pandas as pd
8
8
  import xarray as xr
9
9
 
10
- from imap_processing.ena_maps.ena_maps import HiPointingSet, RectangularSkyMap
11
- from imap_processing.spice.geometry import SpiceFrame
10
+ from imap_processing.ena_maps.ena_maps import (
11
+ AbstractSkyMap,
12
+ HiPointingSet,
13
+ RectangularSkyMap,
14
+ )
15
+ from imap_processing.ena_maps.utils.naming import MapDescriptor
16
+ from imap_processing.hi.utils import CalibrationProductConfig
12
17
 
13
18
  logger = logging.getLogger(__name__)
14
19
 
@@ -42,24 +47,36 @@ def hi_l2(
42
47
  l2_dataset : list[xarray.Dataset]
43
48
  Level 2 IMAP-Hi dataset ready to be written to a CDF file.
44
49
  """
45
- # TODO: parse descriptor to determine map configuration
46
- sensor = "45" if "45" in descriptor else "90"
47
- direction: Literal["full"] = "full"
48
50
  cg_corrected = False
49
- map_spacing = 4
51
+ map_descriptor = MapDescriptor.from_string(descriptor)
50
52
 
51
- rect_map = generate_hi_map(
53
+ sky_map = generate_hi_map(
52
54
  psets,
53
55
  geometric_factors_path,
54
56
  esa_energies_path,
55
- direction=direction,
57
+ spin_phase=map_descriptor.spin_phase,
58
+ output_map=map_descriptor.to_empty_map(),
56
59
  cg_corrected=cg_corrected,
57
- map_spacing=map_spacing,
58
60
  )
59
61
 
60
62
  # Get the map dataset with variables/coordinates in the correct shape
61
63
  # TODO get the correct descriptor and frame
62
- l2_ds = rect_map.build_cdf_dataset("hi", "l2", "sf", descriptor, sensor=sensor)
64
+
65
+ if not isinstance(sky_map, RectangularSkyMap):
66
+ raise NotImplementedError("HEALPix map output not supported for Hi")
67
+ if not isinstance(map_descriptor.sensor, str):
68
+ raise ValueError(
69
+ "Invalid map_descriptor. Sensor attribute must be of type str "
70
+ "and be either '45' or '90'"
71
+ )
72
+
73
+ l2_ds = sky_map.build_cdf_dataset(
74
+ "hi",
75
+ "l2",
76
+ map_descriptor.frame_descriptor,
77
+ descriptor,
78
+ sensor=map_descriptor.sensor,
79
+ )
63
80
 
64
81
  return [l2_ds]
65
82
 
@@ -68,12 +85,12 @@ def generate_hi_map(
68
85
  psets: list[str | Path],
69
86
  geometric_factors_path: str | Path,
70
87
  esa_energies_path: str | Path,
88
+ output_map: AbstractSkyMap,
71
89
  cg_corrected: bool = False,
72
- direction: Literal["ram", "anti-ram", "full"] = "full",
73
- map_spacing: int = 4,
74
- ) -> RectangularSkyMap:
90
+ spin_phase: str = "full",
91
+ ) -> AbstractSkyMap:
75
92
  """
76
- Project Hi PSET data into a rectangular sky map.
93
+ Project Hi PSET data into a sky map.
77
94
 
78
95
  Parameters
79
96
  ----------
@@ -83,34 +100,28 @@ def generate_hi_map(
83
100
  Where to get the geometric factors from.
84
101
  esa_energies_path : str or pathlib.Path
85
102
  Where to get the energies from.
103
+ output_map : AbstractSkyMap
104
+ The map object to collect data into. Determines pixel spacing,
105
+ coordinate system, etc.
86
106
  cg_corrected : bool, Optional
87
107
  Whether to apply Compton-Getting correction to the energies. Defaults to
88
108
  False.
89
- direction : str, Optional
109
+ spin_phase : str, Optional
90
110
  Apply filtering to PSET data include ram or anti-ram or full spin data.
91
111
  Defaults to "full".
92
- map_spacing : int, Optional
93
- Pixel spacing, in degrees, of the output map in degrees. Defaults to 4.
94
112
 
95
113
  Returns
96
114
  -------
97
- sky_map : RectangularSkyMap
115
+ sky_map : AbstractSkyMap
98
116
  The sky map with all the PSET data projected into the map.
99
117
  """
100
- rect_map = RectangularSkyMap(
101
- spacing_deg=map_spacing, spice_frame=SpiceFrame.ECLIPJ2000
102
- )
103
-
104
118
  # TODO: Implement Compton-Getting correction
105
119
  if cg_corrected:
106
120
  raise NotImplementedError
107
- # TODO: Implement directional filtering
108
- if direction != "full":
109
- raise NotImplementedError
110
121
 
111
122
  for pset_path in psets:
112
123
  logger.info(f"Processing {pset_path}")
113
- pset = HiPointingSet(pset_path)
124
+ pset = HiPointingSet(pset_path, spin_phase=spin_phase)
114
125
 
115
126
  # Background rate and uncertainty are exposure time weighted means in
116
127
  # the map.
@@ -118,7 +129,7 @@ def generate_hi_map(
118
129
  pset.data[var] *= pset.data["exposure_factor"]
119
130
 
120
131
  # Project (bin) the PSET variables into the map pixels
121
- rect_map.project_pset_values_to_map(
132
+ output_map.project_pset_values_to_map(
122
133
  pset,
123
134
  ["counts", "exposure_factor", "bg_rates", "bg_rates_unc", "obs_date"],
124
135
  )
@@ -127,35 +138,45 @@ def generate_hi_map(
127
138
  # Allow divide by zero to fill set pixels with zero exposure time to NaN
128
139
  with np.errstate(divide="ignore"):
129
140
  for var in VARS_TO_EXPOSURE_TIME_AVERAGE:
130
- rect_map.data_1d[var] /= rect_map.data_1d["exposure_factor"]
141
+ output_map.data_1d[var] /= output_map.data_1d["exposure_factor"]
131
142
 
132
- rect_map.data_1d.update(calculate_ena_signal_rates(rect_map.data_1d))
133
- rect_map.data_1d.update(
143
+ output_map.data_1d.update(calculate_ena_signal_rates(output_map.data_1d))
144
+ output_map.data_1d.update(
134
145
  calculate_ena_intensity(
135
- rect_map.data_1d, geometric_factors_path, esa_energies_path
146
+ output_map.data_1d, geometric_factors_path, esa_energies_path
136
147
  )
137
148
  )
138
149
 
139
- rect_map.data_1d["obs_date"].data = rect_map.data_1d["obs_date"].data.astype(
150
+ output_map.data_1d["obs_date"].data = output_map.data_1d["obs_date"].data.astype(
140
151
  np.int64
141
152
  )
142
153
  # TODO: Figure out how to compute obs_date_range (stddev of obs_date)
143
- rect_map.data_1d["obs_date_range"] = xr.zeros_like(rect_map.data_1d["obs_date"])
154
+ output_map.data_1d["obs_date_range"] = xr.zeros_like(output_map.data_1d["obs_date"])
144
155
 
145
156
  # Rename and convert coordinate from esa_energy_step energy
146
- # TODO: the correct conversion from esa_energy_step to esa_energy
147
- esa_energy_step_conversion = (np.arange(10, dtype=float) + 1) * 1000
148
- rect_map.data_1d = rect_map.data_1d.rename({"esa_energy_step": "energy"})
149
- rect_map.data_1d = rect_map.data_1d.assign_coords(
150
- energy=esa_energy_step_conversion[rect_map.data_1d["energy"].values]
157
+ esa_df = esa_energy_df(
158
+ esa_energies_path, output_map.data_1d["esa_energy_step"].data
159
+ )
160
+ output_map.data_1d = output_map.data_1d.rename({"esa_energy_step": "energy"})
161
+ output_map.data_1d = output_map.data_1d.assign_coords(
162
+ energy=esa_df["nominal_central_energy"].values
163
+ )
164
+ # Set the energy_step_delta values to the energy bandpass half-width-half-max
165
+ energy_delta = esa_df["bandpass_fwhm"].values / 2
166
+ output_map.data_1d["energy_delta_minus"] = xr.DataArray(
167
+ energy_delta,
168
+ name="energy_delta_minus",
169
+ dims=["energy"],
170
+ )
171
+ output_map.data_1d["energy_delta_plus"] = xr.DataArray(
172
+ energy_delta,
173
+ name="energy_delta_plus",
174
+ dims=["energy"],
151
175
  )
152
- # Set the energy_step_delta values
153
- # TODO: get the correct energy delta values (they are set to NaN) in
154
- # rect_map.build_cdf_dataset()
155
176
 
156
- rect_map.data_1d = rect_map.data_1d.drop("esa_energy_step_label")
177
+ output_map.data_1d = output_map.data_1d.drop("esa_energy_step_label")
157
178
 
158
- return rect_map
179
+ return output_map
159
180
 
160
181
 
161
182
  def calculate_ena_signal_rates(map_ds: xr.Dataset) -> dict[str, xr.DataArray]:
@@ -214,33 +235,31 @@ def calculate_ena_intensity(
214
235
  geometric_factors_path : str or pathlib.Path
215
236
  Where to get the geometric factors from.
216
237
  esa_energies_path : str or pathlib.Path
217
- Where to get the energies from.
238
+ Where to get the esa energies, energy deltas, and geometric factors.
218
239
 
219
240
  Returns
220
241
  -------
221
242
  intensity_vars : dict[str, xarray.DataArray]
222
243
  ENA Intensity with statistical and systematic uncertainties.
223
244
  """
224
- # TODO: Implement geometric factor lookup
225
- if geometric_factors_path:
226
- raise NotImplementedError
227
- geometric_factor = xr.DataArray(
228
- np.ones((map_ds["esa_energy_step"].size, map_ds["calibration_prod"].size)),
229
- coords=[map_ds["esa_energy_step"], map_ds["calibration_prod"]],
245
+ # read calibration product configuration file
246
+ cal_prod_df = CalibrationProductConfig.from_csv(geometric_factors_path)
247
+ # reindex_like removes esa_energy_steps and calibration products not in the
248
+ # map_ds esa_energy_step and calibration_product coordinates
249
+ geometric_factor = cal_prod_df.to_xarray().reindex_like(map_ds)["geometric_factor"]
250
+ geometric_factor = geometric_factor.transpose(
251
+ *[coord for coord in map_ds.coords if coord in geometric_factor.coords]
230
252
  )
231
- # TODO: Implement esa energies lookup
232
- if esa_energies_path:
233
- raise NotImplementedError
234
- esa_energy = xr.ones_like(map_ds["esa_energy_step"])
253
+ energy_df = esa_energy_df(esa_energies_path, map_ds["esa_energy_step"].data)
254
+ esa_energy = energy_df.to_xarray()["nominal_central_energy"]
235
255
 
236
256
  # Convert ENA Signal Rate to Flux
257
+ flux_conversion_divisor = geometric_factor * esa_energy
237
258
  intensity_vars = {
238
- "ena_intensity": map_ds["ena_signal_rates"] / (geometric_factor * esa_energy),
259
+ "ena_intensity": map_ds["ena_signal_rates"] / flux_conversion_divisor,
239
260
  "ena_intensity_stat_unc": map_ds["ena_signal_rate_stat_unc"]
240
- / geometric_factor
241
- / esa_energy,
242
- "ena_intensity_sys_err": map_ds["bg_rates_unc"]
243
- / (geometric_factor * esa_energy),
261
+ / flux_conversion_divisor,
262
+ "ena_intensity_sys_err": map_ds["bg_rates_unc"] / flux_conversion_divisor,
244
263
  }
245
264
 
246
265
  # TODO: Correctly implement combining of calibration products. For now, just sum
@@ -259,3 +278,28 @@ def calculate_ena_intensity(
259
278
  )
260
279
 
261
280
  return intensity_vars
281
+
282
+
283
+ def esa_energy_df(
284
+ esa_energies_path: str | Path, esa_energy_steps: np.ndarray
285
+ ) -> pd.DataFrame:
286
+ """
287
+ Lookup the nominal central energy values for given esa energy steps.
288
+
289
+ Parameters
290
+ ----------
291
+ esa_energies_path : str or pathlib.Path
292
+ Location of the calibration csv file containing the lookup data.
293
+ esa_energy_steps : numpy.ndarray
294
+ The ESA energy steps to get energies for.
295
+
296
+ Returns
297
+ -------
298
+ esa_energies_df: pandas.DataFrame
299
+ Full data frame from the csv file filtered to only include the
300
+ esa_energy_steps input.
301
+ """
302
+ esa_energies_lut = pd.read_csv(
303
+ esa_energies_path, comment="#", index_col="esa_energy_step"
304
+ )
305
+ return esa_energies_lut.loc[esa_energy_steps]
@@ -1,12 +1,15 @@
1
1
  """IMAP-Hi utils functions."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import re
4
- from collections.abc import Sequence
6
+ from collections.abc import Iterable, Sequence
5
7
  from dataclasses import dataclass
6
8
  from enum import IntEnum
7
- from typing import Optional, Union
9
+ from pathlib import Path
8
10
 
9
11
  import numpy as np
12
+ import pandas as pd
10
13
  import xarray as xr
11
14
 
12
15
  from imap_processing.cdf.imap_cdf_manager import ImapCdfAttributes
@@ -15,11 +18,13 @@ from imap_processing.cdf.imap_cdf_manager import ImapCdfAttributes
15
18
  class HIAPID(IntEnum):
16
19
  """Create ENUM for apid."""
17
20
 
21
+ H45_MEMDMP = 740
18
22
  H45_APP_NHK = 754
19
23
  H45_SCI_CNT = 769
20
24
  H45_SCI_DE = 770
21
25
  H45_DIAG_FEE = 772
22
26
 
27
+ H90_MEMDMP = 804
23
28
  H90_APP_NHK = 818
24
29
  H90_SCI_CNT = 833
25
30
  H90_SCI_DE = 834
@@ -100,9 +105,9 @@ def parse_sensor_number(full_string: str) -> int:
100
105
  def full_dataarray(
101
106
  name: str,
102
107
  attrs: dict,
103
- coords: Optional[dict[str, xr.DataArray]] = None,
104
- shape: Optional[Union[int, Sequence[int]]] = None,
105
- fill_value: Optional[float] = None,
108
+ coords: dict[str, xr.DataArray] | None = None,
109
+ shape: int | Sequence[int] | None = None,
110
+ fill_value: float | None = None,
106
111
  ) -> xr.DataArray:
107
112
  """
108
113
  Generate an empty xarray.DataArray with appropriate attributes.
@@ -158,9 +163,9 @@ def full_dataarray(
158
163
 
159
164
  def create_dataset_variables(
160
165
  variable_names: list[str],
161
- variable_shape: Optional[Union[int, Sequence[int]]] = None,
162
- coords: Optional[dict[str, xr.DataArray]] = None,
163
- fill_value: Optional[float] = None,
166
+ variable_shape: int | Sequence[int] | None = None,
167
+ coords: dict[str, xr.DataArray] | None = None,
168
+ fill_value: float | None = None,
164
169
  att_manager_lookup_str: str = "{0}",
165
170
  ) -> dict[str, xr.DataArray]:
166
171
  """
@@ -247,3 +252,252 @@ class CoincidenceBitmap(IntEnum):
247
252
  matches = re.findall(pattern, detector_hit_str)
248
253
  # Sum the integer value assigned to the detector name for each match
249
254
  return sum(CoincidenceBitmap[m] for m in matches)
255
+
256
+
257
+ class EsaEnergyStepLookupTable:
258
+ """Class for holding a esa_step to esa_energy lookup table."""
259
+
260
+ def __init__(self) -> None:
261
+ self.df = pd.DataFrame(
262
+ columns=["start_met", "end_met", "esa_step", "esa_energy_step"]
263
+ )
264
+ self._indexed = False
265
+
266
+ # Get the FILLVAL from the CDF attribute manager that will be returned
267
+ # for queries without matches
268
+ attr_mgr = ImapCdfAttributes()
269
+ attr_mgr.add_instrument_global_attrs("hi")
270
+ attr_mgr.add_instrument_variable_attrs(instrument="hi", level=None)
271
+ var_attrs = attr_mgr.get_variable_attributes(
272
+ "hi_de_esa_energy_step", check_schema=False
273
+ )
274
+ self._fillval = var_attrs["FILLVAL"]
275
+ self._esa_energy_step_dtype = var_attrs["dtype"]
276
+
277
+ def add_entry(
278
+ self, start_met: float, end_met: float, esa_step: int, esa_energy_step: int
279
+ ) -> None:
280
+ """
281
+ Add a single entry to the lookup table.
282
+
283
+ Parameters
284
+ ----------
285
+ start_met : float
286
+ Start mission elapsed time of the time range.
287
+ end_met : float
288
+ End mission elapsed time of the time range.
289
+ esa_step : int
290
+ ESA step value.
291
+ esa_energy_step : int
292
+ ESA energy step value to be stored.
293
+ """
294
+ new_row = pd.DataFrame(
295
+ {
296
+ "start_met": [start_met],
297
+ "end_met": [end_met],
298
+ "esa_step": [esa_step],
299
+ "esa_energy_step": [esa_energy_step],
300
+ }
301
+ )
302
+ self.df = pd.concat([self.df, new_row], ignore_index=True)
303
+ self._indexed = False
304
+
305
+ def _ensure_indexed(self) -> None:
306
+ """
307
+ Create index for faster queries if not already done.
308
+
309
+ Notes
310
+ -----
311
+ This method sorts the internal DataFrame by start_met and esa_step
312
+ for improved query performance.
313
+ """
314
+ if not self._indexed:
315
+ # Sort by start_met and esa_step for better query performance
316
+ self.df = self.df.sort_values(["start_met", "esa_step"]).reset_index(
317
+ drop=True
318
+ )
319
+ self._indexed = True
320
+
321
+ def query(
322
+ self,
323
+ query_met: float | Iterable[float],
324
+ esa_step: int | Iterable[float],
325
+ ) -> float | np.ndarray:
326
+ """
327
+ Query MET(s) and esa_step(s) to retrieve esa_energy_step(s).
328
+
329
+ Parameters
330
+ ----------
331
+ query_met : float or array_like
332
+ Mission elapsed time value(s) to query.
333
+ Can be a single float or array-like of floats.
334
+ esa_step : int or array_like
335
+ ESA step value(s) to match. Can be a single int or array-like of ints.
336
+ Must be same type (scalar or array-like) as query_met.
337
+
338
+ Returns
339
+ -------
340
+ float or numpy.ndarray
341
+ - If inputs are scalars: returns float (esa_energy_step)
342
+ - If inputs are array-like: returns numpy array of esa_energy_steps
343
+ with same length as inputs.
344
+ Contains FILLVAL for queries with no matches.
345
+
346
+ Raises
347
+ ------
348
+ ValueError
349
+ If one input is scalar and the other is array-like, or if both are
350
+ array-like but have different lengths.
351
+
352
+ Notes
353
+ -----
354
+ If multiple entries match a query, returns the first match found.
355
+ """
356
+ self._ensure_indexed()
357
+
358
+ # Check if inputs are scalars
359
+ is_scalar_met = np.isscalar(query_met)
360
+ is_scalar_step = np.isscalar(esa_step)
361
+
362
+ # Check for mismatched input types
363
+ if is_scalar_met != is_scalar_step:
364
+ raise ValueError(
365
+ "query_met and esa_step must both be scalars or both be array-like"
366
+ )
367
+
368
+ # Convert to arrays for uniform processing
369
+ query_mets = np.atleast_1d(query_met)
370
+ esa_steps = np.atleast_1d(esa_step)
371
+
372
+ # Ensure both arrays have the same shape
373
+ if query_mets.shape != esa_steps.shape:
374
+ raise ValueError(
375
+ "query_met and esa_step must have the same "
376
+ "length when both are array-like"
377
+ )
378
+
379
+ results = np.full_like(query_mets, self._fillval)
380
+
381
+ # Lookup esa_energy_steps for queries
382
+ for i, (qm, es) in enumerate(zip(query_mets, esa_steps, strict=False)):
383
+ mask = (
384
+ (self.df["start_met"] <= qm)
385
+ & (self.df["end_met"] >= qm)
386
+ & (self.df["esa_step"] == es)
387
+ )
388
+
389
+ matches = self.df[mask]
390
+ if not matches.empty:
391
+ results[i] = matches["esa_energy_step"].iloc[0]
392
+
393
+ # Return scalar for scalar inputs, array for array inputs
394
+ if is_scalar_met and is_scalar_step:
395
+ return results.astype(self._esa_energy_step_dtype)[0]
396
+ else:
397
+ return results.astype(self._esa_energy_step_dtype)
398
+
399
+
400
+ @pd.api.extensions.register_dataframe_accessor("cal_prod_config")
401
+ class CalibrationProductConfig:
402
+ """
403
+ Register custom accessor for calibration product configuration DataFrames.
404
+
405
+ Parameters
406
+ ----------
407
+ pandas_obj : pandas.DataFrame
408
+ Object to run validation and use accessor functions on.
409
+ """
410
+
411
+ index_columns = (
412
+ "calibration_prod",
413
+ "esa_energy_step",
414
+ )
415
+ tof_detector_pairs = ("ab", "ac1", "bc1", "c1c2")
416
+ required_columns = (
417
+ "coincidence_type_list",
418
+ *[
419
+ f"tof_{det_pair}_{limit}"
420
+ for det_pair in tof_detector_pairs
421
+ for limit in ["low", "high"]
422
+ ],
423
+ )
424
+
425
+ def __init__(self, pandas_obj: pd.DataFrame) -> None:
426
+ self._validate(pandas_obj)
427
+ self._obj = pandas_obj
428
+ self._add_coincidence_values_column()
429
+
430
+ def _validate(self, df: pd.DataFrame) -> None:
431
+ """
432
+ Validate the current configuration.
433
+
434
+ Parameters
435
+ ----------
436
+ df : pandas.DataFrame
437
+ Object to validate.
438
+
439
+ Raises
440
+ ------
441
+ AttributeError : If the dataframe does not pass validation.
442
+ """
443
+ for index_name in self.index_columns:
444
+ if index_name in df.index:
445
+ raise AttributeError(
446
+ f"Required index {index_name} not present in dataframe."
447
+ )
448
+ # Verify that the Dataframe has all the required columns
449
+ for col in self.required_columns:
450
+ if col not in df.columns:
451
+ raise AttributeError(f"Required column {col} not present in dataframe.")
452
+ # TODO: Verify that the same ESA energy steps exist in all unique calibration
453
+ # product numbers
454
+
455
+ def _add_coincidence_values_column(self) -> None:
456
+ """Generate and add the coincidence_type_values column to the dataframe."""
457
+ # Add a column that consists of the coincidence type strings converted
458
+ # to integer values
459
+ self._obj["coincidence_type_values"] = self._obj.apply(
460
+ lambda row: tuple(
461
+ CoincidenceBitmap.detector_hit_str_to_int(entry)
462
+ for entry in row["coincidence_type_list"]
463
+ ),
464
+ axis=1,
465
+ )
466
+
467
+ @classmethod
468
+ def from_csv(cls, path: str | Path) -> pd.DataFrame:
469
+ """
470
+ Read configuration CSV file into a pandas.DataFrame.
471
+
472
+ Parameters
473
+ ----------
474
+ path : str or pathlib.Path
475
+ Location of the Calibration Product configuration CSV file.
476
+
477
+ Returns
478
+ -------
479
+ dataframe : pandas.DataFrame
480
+ Validated calibration product configuration data frame.
481
+ """
482
+ df = pd.read_csv(
483
+ path,
484
+ index_col=cls.index_columns,
485
+ converters={"coincidence_type_list": lambda s: tuple(s.split("|"))},
486
+ comment="#",
487
+ )
488
+ # Force the _init_ method to run by using the namespace
489
+ _ = df.cal_prod_config.number_of_products
490
+ return df
491
+
492
+ @property
493
+ def number_of_products(self) -> int:
494
+ """
495
+ Get the number of calibration products in the current configuration.
496
+
497
+ Returns
498
+ -------
499
+ number_of_products : int
500
+ The maximum number of calibration products defined in the list of
501
+ calibration product definitions.
502
+ """
503
+ return len(self._obj.index.unique(level="calibration_prod"))
@@ -114,6 +114,9 @@ FLAG_PATTERN = np.array([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
114
114
  # Define size of science frame (num of packets)
115
115
  FRAME_SIZE = len(FLAG_PATTERN)
116
116
 
117
+ # Mod 10 pattern
118
+ MOD_10_PATTERN = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
119
+
117
120
  # Define the number of bits in the mantissa and exponent for
118
121
  # decompressing data
119
122
  MANTISSA_BITS = 12
@@ -260,12 +260,9 @@ def assemble_science_frames(sci_dataset: xr.Dataset) -> xr.Dataset:
260
260
  height event data per valid science frame added as new
261
261
  data variables.
262
262
  """
263
- # TODO: Figure out how to handle partial science frames at the
264
- # beginning and end of CCSDS files. These science frames are split
265
- # across CCSDS files and still need to be processed with packets
266
- # from the previous file. Only discard incomplete science frames
267
- # in the middle of the CCSDS file. The code currently skips all
268
- # incomplete science frames.
263
+ # TODO: The code currently skips all incomplete science frames.
264
+ # Only discard incomplete science frames in the middle of the CCSDS file or
265
+ # use fill values?
269
266
 
270
267
  # Convert sequence flags and counters to NumPy arrays for vectorized operations
271
268
  seq_flgs = sci_dataset.seq_flgs.values