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
@@ -1,4 +1,4 @@
1
- table_num,row_num,num_reps,store_data,e1,e2,e3,e4,e5,e6,e7,e8,acq_time
1
+ table_num,half_spin_num,num_reps,store_data,e1,e2,e3,e4,e5,e6,e7,e8,acq_time
2
2
  0,0,1,12,0,-,-,-,-,-,-,-,578.708333
3
3
  0,1,1,12,1,-,-,-,-,-,-,-,578.708333
4
4
  0,2,1,12,2,-,-,-,-,-,-,-,578.708333
imap_processing/decom.py CHANGED
@@ -6,14 +6,11 @@ to decommutate CCSDS packet data using a given XTCE packet definition.
6
6
  """
7
7
 
8
8
  from pathlib import Path
9
- from typing import Union
10
9
 
11
10
  from space_packet_parser import definitions
12
11
 
13
12
 
14
- def decom_packets(
15
- packet_file: Union[str, Path], xtce_packet_definition: Union[str, Path]
16
- ) -> list:
13
+ def decom_packets(packet_file: str | Path, xtce_packet_definition: str | Path) -> list:
17
14
  """
18
15
  Unpack CCSDS data packet.
19
16
 
@@ -412,6 +412,7 @@ class RectangularPointingSet(PointingSet):
412
412
  for dim, constructed_bins in zip(
413
413
  [CoordNames.AZIMUTH_L1C.value, CoordNames.ELEVATION_L1C.value],
414
414
  [self.sky_grid.az_bin_midpoints, self.sky_grid.el_bin_midpoints],
415
+ strict=False,
415
416
  ):
416
417
  if not np.allclose(
417
418
  sorted(constructed_bins),
@@ -522,6 +523,7 @@ class UltraPointingSet(HealpixPointingSet):
522
523
  for dim, constructed_bins in zip(
523
524
  [CoordNames.AZIMUTH_L1C.value, CoordNames.ELEVATION_L1C.value],
524
525
  [azimuth_pixel_center, elevation_pixel_center],
526
+ strict=False,
525
527
  ):
526
528
  if not np.allclose(
527
529
  self.data[dim],
@@ -590,13 +592,31 @@ class HiPointingSet(PointingSet):
590
592
 
591
593
  Parameters
592
594
  ----------
593
- dataset : xarray.Dataset
594
- Hi L1C pointing set data loaded in an xarray.DataArray.
595
+ dataset : xarray.Dataset | str | Path
596
+ Hi L1C pointing set data loaded in a xarray.DataArray.
597
+ spin_phase : str
598
+ Include ENAs from "full", "ram" or "anti-ram" phases of the spin.
595
599
  """
596
600
 
597
- def __init__(self, dataset: xr.Dataset):
601
+ def __init__(self, dataset: xr.Dataset | str | Path, spin_phase: str):
598
602
  super().__init__(dataset, spice_reference_frame=geometry.SpiceFrame.ECLIPJ2000)
599
603
 
604
+ # Filter out ENAs from non-selected portions of the spin.
605
+ if spin_phase not in ["full", "ram", "anti-ram"]:
606
+ raise ValueError(f"Unrecognized spin_phase value: {spin_phase}.")
607
+ # ram only includes spin-phase interval [0, 0.5)
608
+ # which is the first half of the spin_angle_bins
609
+ elif spin_phase == "ram":
610
+ self.data = self.data.isel(
611
+ spin_angle_bin=slice(0, self.data["spin_angle_bin"].data.size // 2)
612
+ )
613
+ # anti-ram includes spin-phase interval [0.5, 1)
614
+ # which is the second half of the spin_angle_bins
615
+ elif spin_phase == "anti-ram":
616
+ self.data = self.data.isel(
617
+ spin_angle_bin=slice(self.data["spin_angle_bin"].data.size // 2, None)
618
+ )
619
+
600
620
  # Rename some PSET vars to match L2 variables
601
621
  self.data = self.data.rename(
602
622
  {
@@ -631,29 +651,16 @@ class LoPointingSet(PointingSet):
631
651
  """
632
652
 
633
653
  def __init__(self, dataset: xr.Dataset):
634
- super().__init__(dataset, spice_reference_frame=geometry.SpiceFrame.IMAP_DPS)
635
- # TODO: Use spatial_utils.az_el_grid instead of
636
- # manually creating the lon/lat values
637
- inferred_spacing_deg = 360 / dataset.longitude.size
638
- longitude_bin_centers = np.arange(
639
- 0 + inferred_spacing_deg / 2, 360, inferred_spacing_deg
640
- )
641
- latitude_bin_centers = np.arange(
642
- -2 + inferred_spacing_deg / 2, 2, inferred_spacing_deg
643
- )
654
+ super().__init__(dataset, spice_reference_frame=geometry.SpiceFrame.IMAP_HAE)
644
655
 
645
- # Could be wrong about the order here
646
- longitude_grid, latitude_grid = np.meshgrid(
647
- longitude_bin_centers,
648
- latitude_bin_centers,
649
- indexing="ij",
656
+ # The HAE centers are stored in the pset as (1, 3600, 40) arrays
657
+ self.az_el_points = np.column_stack(
658
+ (
659
+ np.squeeze(self.data["hae_longitude"]).values.ravel(),
660
+ np.squeeze(self.data["hae_latitude"]).values.ravel(),
661
+ )
650
662
  )
651
-
652
- longitude = longitude_grid.ravel()
653
- latitude = latitude_grid.ravel()
654
-
655
- self.az_el_points = np.column_stack((longitude, latitude))
656
- self.spatial_coords = ("longitude", "latitude")
663
+ self.spatial_coords = ("spin_angle", "off_angle")
657
664
 
658
665
 
659
666
  # Define the Map classes
@@ -761,6 +768,7 @@ class AbstractSkyMap(ABC):
761
768
  pointing_set: PointingSet,
762
769
  value_keys: list[str] | None = None,
763
770
  index_match_method: IndexMatchMethod = IndexMatchMethod.PUSH,
771
+ pset_valid_mask: NDArray | None = None,
764
772
  ) -> None:
765
773
  """
766
774
  Project a pointing set's values to the map grid.
@@ -782,6 +790,10 @@ class AbstractSkyMap(ABC):
782
790
  index_match_method : IndexMatchMethod, optional
783
791
  The method of index matching to use for all values.
784
792
  Default is IndexMatchMethod.PUSH.
793
+ pset_valid_mask : NDArray, optional
794
+ A boolean mask of shape (number of pointing set pixels,) indicating
795
+ which pixels in the pointing set should be considered valid for projection.
796
+ If None, all pixels are considered valid. Default is None.
785
797
 
786
798
  Raises
787
799
  ------
@@ -794,6 +806,9 @@ class AbstractSkyMap(ABC):
794
806
  if value_key not in pointing_set.data.data_vars:
795
807
  raise ValueError(f"Value key {value_key} not found in pointing set.")
796
808
 
809
+ if pset_valid_mask is None:
810
+ pset_valid_mask = np.ones(pointing_set.num_points, dtype=bool)
811
+
797
812
  if index_match_method is IndexMatchMethod.PUSH:
798
813
  # Determine the indices of the sky map grid that correspond to
799
814
  # each pixel in the pointing set.
@@ -853,22 +868,32 @@ class AbstractSkyMap(ABC):
853
868
  value_array=raveled_pset_data,
854
869
  projection_grid_shape=self.binning_grid_shape,
855
870
  projection_indices=matched_indices_push,
871
+ input_valid_mask=pset_valid_mask,
856
872
  )
873
+ # TODO: we may need to allow for unweighted/weighted means here by
874
+ # dividing pointing_projected_values by some binned weights.
875
+ # For unweighted means, we could use the number of pointing set pixels
876
+ # that correspond to each map pixel as the weights.
877
+ self.data_1d[value_key] += pointing_projected_values
857
878
  elif index_match_method is IndexMatchMethod.PULL:
879
+ valid_map_mask = pset_valid_mask[matched_indices_pull]
858
880
  # We know that there will only be one value per sky map pixel,
859
881
  # so we can use the matched indices directly
860
- pointing_projected_values = raveled_pset_data[..., matched_indices_pull]
882
+ pointing_projected_values = raveled_pset_data[
883
+ ..., matched_indices_pull[valid_map_mask]
884
+ ]
885
+ # TODO: we may need to allow for unweighted/weighted means here by
886
+ # dividing pointing_projected_values by some binned weights.
887
+ # For unweighted means, we could use the number of pointing set pixels
888
+ # that correspond to each map pixel as the weights.
889
+ self.data_1d[value_key].values[..., valid_map_mask] += (
890
+ pointing_projected_values
891
+ )
861
892
  else:
862
893
  raise NotImplementedError(
863
894
  "Only PUSH and PULL index matching methods are supported."
864
895
  )
865
896
 
866
- # TODO: we may need to allow for unweighted/weighted means here by
867
- # dividing pointing_projected_values by some binned weights.
868
- # For unweighted means, we could use the number of pointing set pixels
869
- # that correspond to each map pixel as the weights.
870
- self.data_1d[value_key] += pointing_projected_values
871
-
872
897
  # TODO: The max epoch needs to include the pset duration. Right now it
873
898
  # is just capturing the start epoch. See issue #1747
874
899
  self.min_epoch = min(self.min_epoch, pointing_set.epoch)
@@ -1162,6 +1187,10 @@ class RectangularSkyMap(AbstractSkyMap):
1162
1187
  # Rewrap each data array in the data_1d to the original 2D grid shape
1163
1188
  rewrapped_data = {}
1164
1189
  for key in self.data_1d.data_vars:
1190
+ # Don't rewrap non-spatial variables
1191
+ if CoordNames.GENERIC_PIXEL.value not in self.data_1d[key].coords:
1192
+ rewrapped_data[key] = self.data_1d[key]
1193
+ continue
1165
1194
  # drop pixel dim from the end, and add the spatial coords as dims
1166
1195
  rewrapped_dims = [
1167
1196
  dim
@@ -1267,18 +1296,17 @@ class RectangularSkyMap(AbstractSkyMap):
1267
1296
  name=f"{coord_name}_delta",
1268
1297
  dims=[coord_name],
1269
1298
  )
1270
- # Add energy delta_minus and delta_plus variables
1271
1299
  elif coord_name == CoordNames.ENERGY_L2.value:
1272
- cdf_ds[f"{coord_name}_delta_minus"] = xr.DataArray(
1273
- xr.full_like(cdf_ds[coord_name], np.nan),
1274
- name=f"{coord_name}_delta",
1275
- dims=[coord_name],
1276
- )
1277
- cdf_ds[f"{coord_name}_delta_plus"] = xr.DataArray(
1278
- xr.full_like(cdf_ds[coord_name], np.nan),
1279
- name=f"{coord_name}_delta",
1280
- dims=[coord_name],
1281
- )
1300
+ if f"{coord_name}_delta_minus" not in cdf_ds:
1301
+ raise KeyError(
1302
+ f"Required variable '{coord_name}_delta_minus' "
1303
+ f"not found in cdf Dataset."
1304
+ )
1305
+ if f"{coord_name}_delta_plus" not in cdf_ds:
1306
+ raise KeyError(
1307
+ f"Required variable '{coord_name}_delta_plus' "
1308
+ f"not found in cdf Dataset."
1309
+ )
1282
1310
 
1283
1311
  # Object which holds CDF attributes for the map
1284
1312
  cdf_attrs = ImapCdfAttributes()
@@ -1748,7 +1776,7 @@ class HealpixSkyMap(AbstractSkyMap):
1748
1776
  # into two lists, then convert both to numpy arrays
1749
1777
  # and move the pixel dim to the last dim of values
1750
1778
  interpolated_data_by_rect_pixel, subdiv_depth_of_value_by_pixel = zip(
1751
- *best_value_and_recursion_depth_by_pixel
1779
+ *best_value_and_recursion_depth_by_pixel, strict=False
1752
1780
  )
1753
1781
  interpolated_data_by_rect_pixel = np.moveaxis(
1754
1782
  np.array(interpolated_data_by_rect_pixel), 0, -1
@@ -0,0 +1,291 @@
1
+ """L2 corrections common to multiple IMAP ENA instruments."""
2
+
3
+ from pathlib import Path
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ from numpy.polynomial import Polynomial
8
+
9
+
10
+ class PowerLawFluxCorrector:
11
+ """
12
+ IMAP-Lo flux correction algorithm implementation.
13
+
14
+ Based on Section 5 of the Mapping Algorithm Document. Applies corrections for
15
+ ESA transmission integration over energy bandpass using iterative
16
+ predictor-corrector scheme to estimate source fluxes from observed fluxes.
17
+
18
+ Parameters
19
+ ----------
20
+ coeffs_file : str or Path
21
+ Location of CSV file containing ESA transmission coefficients.
22
+ """
23
+
24
+ def __init__(self, coeffs_file: str | Path):
25
+ """Initialize PowerLawFluxCorrector."""
26
+ # Load the csv file
27
+ eta_coeffs_df = pd.read_csv(coeffs_file, index_col="esa_step")
28
+ # Create a lookup dictionary to get the correct np.polynomial.Polynomial
29
+ # for a given esa_step
30
+ coeff_columns = ["M0", "M1", "M2", "M3", "M4", "M5"]
31
+ self.polynomial_lookup = {
32
+ row.name: Polynomial(row[coeff_columns].values)
33
+ for _, row in eta_coeffs_df.iterrows()
34
+ }
35
+
36
+ def eta_esa(self, k: np.ndarray, gamma: np.ndarray) -> np.ndarray:
37
+ """
38
+ Calculate ESA transmission scale factor η_esa,k(γ) for each energy level.
39
+
40
+ Parameters
41
+ ----------
42
+ k : np.ndarray
43
+ Energy levels.
44
+ gamma : np.ndarray
45
+ Power-law slopes.
46
+
47
+ Returns
48
+ -------
49
+ np.ndarray
50
+ ESA transmission scale factors.
51
+ """
52
+ k = np.atleast_1d(k)
53
+ gamma = np.atleast_1d(gamma)
54
+ eta = np.empty_like(gamma)
55
+ for i, esa_step in enumerate(k):
56
+ eta[i] = self.polynomial_lookup[esa_step](gamma[i])
57
+ # Negative transmissions get set to 1
58
+ if eta[i] < 0:
59
+ eta[i] = 1
60
+
61
+ return eta
62
+
63
+ @staticmethod
64
+ def estimate_power_law_slope(
65
+ fluxes: np.ndarray,
66
+ energies: np.ndarray,
67
+ uncertainties: np.ndarray | None = None,
68
+ ) -> tuple[np.ndarray, np.ndarray | None]:
69
+ """
70
+ Estimate power-law slopes γ_k for each energy level using vectorized operations.
71
+
72
+ Implements equations (36)-(41) from the Mapping Algorithm Document v7
73
+ with proper boundary handling. Uses extended arrays with repeated
74
+ endpoints for unified calculation, and handles zero fluxes by falling
75
+ back to linear differencing or returning NaN where both central and
76
+ linear differencing fail.
77
+
78
+ Parameters
79
+ ----------
80
+ fluxes : np.ndarray
81
+ Array of differential fluxes [J_1, J_2, ..., J_7].
82
+ energies : np.ndarray
83
+ Array of energy levels [E_1, E_2, ..., E_7].
84
+ uncertainties : np.ndarray, optional
85
+ Array of flux uncertainties [δJ_1, δJ_2, ..., δJ_7].
86
+
87
+ Returns
88
+ -------
89
+ gamma : np.ndarray
90
+ Array of power-law slopes.
91
+ delta_gamma : np.ndarray or None
92
+ Array of uncertainty slopes (if uncertainties provided).
93
+ """
94
+ n_levels = len(fluxes)
95
+ gamma = np.full(n_levels, 0, dtype=float)
96
+ delta_gamma = (
97
+ np.full(n_levels, 0, dtype=float) if uncertainties is not None else None
98
+ )
99
+
100
+ # Create an array of indices that can be used to create a padded array where
101
+ # the padding duplicates the first element on the front and the last element
102
+ # on the end of the array
103
+ extended_inds = np.pad(np.arange(n_levels), 1, mode="edge")
104
+
105
+ # Compute logs, setting non-positive fluxes to NaN
106
+ log_fluxes = np.log(np.where(fluxes > 0, fluxes, np.nan))
107
+ log_energies = np.log(energies)
108
+ # Create extended arrays by repeating first and last values. This allows
109
+ # for linear differencing to be used on the ends and central differencing
110
+ # to be used on the interior of the array with a single vectorized equation.
111
+ # Interior points use central differencing equation:
112
+ # gamma_k = ln(J_{k+1}/J_{k-1}) / ln(E_{k+1}/E_{k-1})
113
+ # Left boundary uses linear forward differencing:
114
+ # gamma_k = ln(J_{k+1}/J_{k}) / ln(E_{k+1}/E_{k})
115
+ # Right boundary uses linear backward differencing:
116
+ # gamma_k = ln(J_{k}/J_{k-1}) / ln(E_{k}/E_{k-1})
117
+ log_extended_fluxes = log_fluxes[extended_inds]
118
+ log_extended_energies = log_energies[extended_inds]
119
+
120
+ # Extract the left and right log values to use in slope calculation
121
+ left_log_fluxes = log_extended_fluxes[:-2] # indices 0 to n_levels-1
122
+ right_log_fluxes = log_extended_fluxes[2:] # indices 2 to n_levels+1
123
+ left_log_energies = log_extended_energies[:-2]
124
+ right_log_energies = log_extended_energies[2:]
125
+
126
+ # Compute power-law slopes for valid indices
127
+ central_valid = np.isfinite(left_log_fluxes) & np.isfinite(right_log_fluxes)
128
+ gamma[central_valid] = (
129
+ (right_log_fluxes - left_log_fluxes)
130
+ / (right_log_energies - left_log_energies)
131
+ )[central_valid]
132
+
133
+ # Compute uncertainty slopes
134
+ if uncertainties is not None:
135
+ with np.errstate(divide="ignore"):
136
+ rel_unc_sq = (uncertainties / fluxes) ** 2
137
+ extended_rel_unc_sq = rel_unc_sq[extended_inds]
138
+ delta_gamma = np.sqrt(
139
+ extended_rel_unc_sq[:-2] + extended_rel_unc_sq[2:]
140
+ ) / (log_extended_energies[2:] - log_extended_energies[:-2])
141
+ delta_gamma[~central_valid] = 0
142
+
143
+ # Handle one-sided differencing for points where central differencing failed
144
+ need_fallback = ~central_valid & np.isfinite(log_fluxes)
145
+ # Exclude first and last points since they already use the correct
146
+ # one-sided differencing
147
+ interior_fallback = np.zeros_like(need_fallback, dtype=bool)
148
+ interior_fallback[1:-1] = need_fallback[1:-1]
149
+
150
+ if np.any(interior_fallback):
151
+ indices = np.where(interior_fallback)[0]
152
+
153
+ for k in indices:
154
+ # For interior points: try forward first, then backward
155
+ if k < n_levels - 1 and np.isfinite(log_fluxes[k + 1]):
156
+ gamma[k] = (log_fluxes[k + 1] - log_fluxes[k]) / (
157
+ log_energies[k + 1] - log_energies[k]
158
+ )
159
+
160
+ # Compute uncertainty slope using same differencing
161
+ if isinstance(delta_gamma, np.ndarray):
162
+ delta_gamma[k] = np.sqrt(rel_unc_sq[k + 1] + rel_unc_sq[k]) / (
163
+ log_energies[k + 1] - log_energies[k]
164
+ )
165
+
166
+ elif k > 0 and np.isfinite(log_fluxes[k - 1]):
167
+ gamma[k] = (log_fluxes[k] - log_fluxes[k - 1]) / (
168
+ log_energies[k] - log_energies[k - 1]
169
+ )
170
+
171
+ # Compute uncertainty slope using same differencing
172
+ if isinstance(delta_gamma, np.ndarray):
173
+ delta_gamma[k] = np.sqrt(rel_unc_sq[k] + rel_unc_sq[k - 1]) / (
174
+ log_energies[k] - log_energies[k - 1]
175
+ )
176
+
177
+ return gamma, delta_gamma
178
+
179
+ def predictor_corrector_iteration(
180
+ self,
181
+ observed_fluxes: np.ndarray,
182
+ observed_uncertainties: np.ndarray,
183
+ energies: np.ndarray,
184
+ max_iterations: int = 20,
185
+ convergence_threshold: float = 0.005,
186
+ ) -> tuple[np.ndarray, np.ndarray, int]:
187
+ """
188
+ Estimate source fluxes using iterative predictor-corrector scheme.
189
+
190
+ Implements the algorithm from Appendix A of the Mapping Algorithm Document.
191
+
192
+ Parameters
193
+ ----------
194
+ observed_fluxes : np.ndarray
195
+ Array of observed fluxes.
196
+ observed_uncertainties : numpy.ndarray
197
+ Array of observed uncertainties.
198
+ energies : np.ndarray
199
+ Array of energy levels.
200
+ max_iterations : int, optional
201
+ Maximum number of iterations, by default 20.
202
+ convergence_threshold : float, optional
203
+ RMS convergence criterion, by default 0.005 (0.5%).
204
+
205
+ Returns
206
+ -------
207
+ source_fluxes : np.ndarray
208
+ Final estimate of source fluxes.
209
+ source_uncertainties : np.ndarray
210
+ Final estimate of source uncertainties.
211
+ n_iterations : int
212
+ Number of iterations run.
213
+ """
214
+ n_levels = len(observed_fluxes)
215
+ energy_levels = np.arange(n_levels) + 1
216
+
217
+ # Initial power-law estimate from observed fluxes
218
+ gamma_initial, _ = self.estimate_power_law_slope(observed_fluxes, energies)
219
+
220
+ # Initial source flux estimate
221
+ eta_initial = self.eta_esa(energy_levels, gamma_initial)
222
+ source_fluxes_n = observed_fluxes / eta_initial
223
+
224
+ for _iteration in range(max_iterations):
225
+ # Store previous iteration
226
+ source_fluxes_prev = source_fluxes_n.copy()
227
+
228
+ # Predictor step
229
+ gamma_pred, _ = self.estimate_power_law_slope(source_fluxes_n, energies)
230
+ gamma_half = 0.5 * (gamma_initial + gamma_pred)
231
+
232
+ # Predictor source flux estimate
233
+ eta_half = self.eta_esa(energy_levels, gamma_half)
234
+ source_fluxes_half = observed_fluxes / eta_half
235
+
236
+ # Corrector step
237
+ gamma_corr, _ = self.estimate_power_law_slope(source_fluxes_half, energies)
238
+ gamma_n = 0.5 * (gamma_pred + gamma_corr)
239
+
240
+ # Final source flux estimate for this iteration
241
+ eta_final = self.eta_esa(energy_levels, gamma_n)
242
+ source_fluxes_n = observed_fluxes / eta_final
243
+ source_uncertainties = observed_uncertainties / eta_final
244
+
245
+ # Check convergence
246
+ ratios_sq = (source_fluxes_n / source_fluxes_prev) ** 2
247
+ chi_n = np.sqrt(np.mean(ratios_sq)) - 1
248
+
249
+ if chi_n < convergence_threshold:
250
+ break
251
+
252
+ return source_fluxes_n, source_uncertainties, _iteration + 1
253
+
254
+ def apply_flux_correction(
255
+ self, flux: np.ndarray, flux_stat_unc: np.ndarray, energies: np.ndarray
256
+ ) -> tuple[np.ndarray, np.ndarray]:
257
+ """
258
+ Apply flux correction to observed fluxes.
259
+
260
+ Iterative predictor-corrector scheme is run on each spatial pixel
261
+ individually to correct fluxes and statistical uncertainties. This method
262
+ is intended to be used with the unwrapped data in the ena_maps.AbstractSkyMap
263
+ class or child classes.
264
+
265
+ Parameters
266
+ ----------
267
+ flux : numpy.ndarray
268
+ Input flux with shape (n_energy, n_spatial_pixels).
269
+ flux_stat_unc : np.ndarray
270
+ Statistical uncertainty for input fluxes. Shape must match the shape
271
+ of flux.
272
+ energies : numpy.ndarray
273
+ Array of energy levels in units of eV or keV.
274
+
275
+ Returns
276
+ -------
277
+ tuple[numpy.ndarray, numpy.ndarray]
278
+ Corrected fluxes and flux uncertainties.
279
+ """
280
+ corrected_flux = np.empty_like(flux)
281
+ corrected_flux_stat_unc = np.empty_like(flux_stat_unc)
282
+
283
+ # loop over spatial pixels (last dimension)
284
+ for i_pixel in range(flux.shape[-1]):
285
+ corrected_flux[:, i_pixel], corrected_flux_stat_unc[:, i_pixel], _ = (
286
+ self.predictor_corrector_iteration(
287
+ flux[:, i_pixel], flux_stat_unc[:, i_pixel], energies
288
+ )
289
+ )
290
+
291
+ return corrected_flux, corrected_flux_stat_unc
@@ -15,6 +15,7 @@ def bin_single_array_at_indices(
15
15
  projection_grid_shape: tuple[int, ...],
16
16
  projection_indices: NDArray,
17
17
  input_indices: NDArray | None = None,
18
+ input_valid_mask: NDArray | None = None,
18
19
  ) -> NDArray:
19
20
  """
20
21
  Bin an array of values at the given indices.
@@ -39,6 +40,9 @@ def bin_single_array_at_indices(
39
40
  1 dimensional. May be non-unique, depending on the projection method.
40
41
  If None (default), an arange of the same length as the
41
42
  final axis of value_array is used.
43
+ input_valid_mask : NDArray, optional
44
+ Boolean mask array for valid values in input grid.
45
+ If None, all pixels are considered valid. Default is None.
42
46
 
43
47
  Returns
44
48
  -------
@@ -55,6 +59,8 @@ def bin_single_array_at_indices(
55
59
  """
56
60
  if input_indices is None:
57
61
  input_indices = np.arange(value_array.shape[-1])
62
+ if input_valid_mask is None:
63
+ input_valid_mask = np.ones(value_array.shape[-1], dtype=bool)
58
64
 
59
65
  # Both sets of indices must be 1D with the same number of elements
60
66
  if input_indices.ndim != 1 or projection_indices.ndim != 1:
@@ -69,20 +75,25 @@ def bin_single_array_at_indices(
69
75
  " projection indices."
70
76
  )
71
77
 
78
+ input_valid_mask = np.asarray(input_valid_mask, dtype=bool)
79
+ mask_idx = input_valid_mask[input_indices]
80
+
72
81
  num_projection_indices = np.prod(projection_grid_shape)
73
82
 
83
+ # Only valid values are summed into bins.
74
84
  if value_array.ndim == 1:
85
+ values = value_array[input_indices]
75
86
  binned_values = np.bincount(
76
- projection_indices,
77
- weights=value_array[input_indices],
87
+ projection_indices[mask_idx],
88
+ weights=values[mask_idx],
78
89
  minlength=num_projection_indices,
79
90
  )
80
91
  elif value_array.ndim >= 2:
81
92
  # Apply bincount to each row independently
82
93
  binned_values = np.apply_along_axis(
83
94
  lambda x: np.bincount(
84
- projection_indices,
85
- weights=x[..., input_indices],
95
+ projection_indices[mask_idx],
96
+ weights=x[..., input_indices][mask_idx],
86
97
  minlength=num_projection_indices,
87
98
  ),
88
99
  axis=-1,
@@ -96,6 +107,7 @@ def bin_values_at_indices(
96
107
  projection_grid_shape: tuple[int, ...],
97
108
  projection_indices: NDArray,
98
109
  input_indices: NDArray | None = None,
110
+ input_valid_mask: NDArray | None = None,
99
111
  ) -> dict[str, NDArray]:
100
112
  """
101
113
  Project values from input grid to projection grid based on matched indices.
@@ -118,6 +130,9 @@ def bin_values_at_indices(
118
130
  Ordered indices for input grid, corresponding to indices in projection grid.
119
131
  1 dimensional. May be non-unique, depending on the projection method.
120
132
  If None (default), behavior is determined by bin_single_array_at_indices.
133
+ input_valid_mask : NDArray, optional
134
+ Boolean mask array for valid values in input grid.
135
+ If None, all pixels are considered valid. Default is None.
121
136
 
122
137
  Returns
123
138
  -------
@@ -137,6 +152,7 @@ def bin_values_at_indices(
137
152
  projection_grid_shape=projection_grid_shape,
138
153
  projection_indices=projection_indices,
139
154
  input_indices=input_indices,
155
+ input_valid_mask=input_valid_mask,
140
156
  )
141
157
 
142
158
  return binned_values_dict
@@ -33,7 +33,7 @@ _sensor_types = int | Literal["45", "90", "combined", "ic", "lc", ""]
33
33
  # Must be specified separately for purpose of type checking vs comparison
34
34
  valid_spice_frame_strings = ["sf", "hf", "hk"]
35
35
  _spice_frame_str_types = Literal["sf", "hf", "hk"]
36
- _coord_frame_str_types = Literal["hae",]
36
+ _coord_frame_str_types = Literal["hae", "hre", "hnu", "gcs"]
37
37
 
38
38
  # Mapping of inertial frames to their longer names used in logical source descriptors
39
39
  INERTIAL_FRAME_LONG_NAMES = {
@@ -334,7 +334,13 @@ class MapDescriptor:
334
334
  If the frame string is not recognized.
335
335
  """
336
336
  if frame_str == "hae":
337
- return SpiceFrame.ECLIPJ2000
337
+ return SpiceFrame.IMAP_HAE
338
+ elif frame_str == "hre":
339
+ return SpiceFrame.IMAP_HRE
340
+ elif frame_str == "hnu":
341
+ return SpiceFrame.IMAP_HNU
342
+ elif frame_str == "gcs":
343
+ return SpiceFrame.IMAP_GCS
338
344
  else:
339
345
  raise NotImplementedError("Coordinate frame is not yet implemented.")
340
346