imap-processing 0.19.4__py3-none-any.whl → 1.0.1__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 (50) hide show
  1. imap_processing/_version.py +2 -2
  2. imap_processing/cdf/config/imap_codice_global_cdf_attrs.yaml +13 -1
  3. imap_processing/cdf/config/imap_codice_l1a_variable_attrs.yaml +44 -44
  4. imap_processing/cdf/config/imap_codice_l1b_variable_attrs.yaml +127 -126
  5. imap_processing/cdf/config/imap_codice_l2-hi-omni_variable_attrs.yaml +635 -0
  6. imap_processing/cdf/config/imap_codice_l2-hi-sectored_variable_attrs.yaml +422 -0
  7. imap_processing/cdf/config/imap_constant_attrs.yaml +1 -1
  8. imap_processing/cdf/config/imap_enamaps_l2-common_variable_attrs.yaml +61 -55
  9. imap_processing/cdf/config/imap_enamaps_l2-healpix_variable_attrs.yaml +3 -2
  10. imap_processing/cdf/config/imap_enamaps_l2-rectangular_variable_attrs.yaml +17 -5
  11. imap_processing/cli.py +6 -11
  12. imap_processing/codice/codice_l1a.py +42 -21
  13. imap_processing/codice/codice_l2.py +640 -127
  14. imap_processing/codice/constants.py +224 -129
  15. imap_processing/ena_maps/ena_maps.py +124 -70
  16. imap_processing/ena_maps/utils/coordinates.py +5 -0
  17. imap_processing/ena_maps/utils/corrections.py +268 -0
  18. imap_processing/ena_maps/utils/map_utils.py +143 -42
  19. imap_processing/hi/hi_l2.py +10 -15
  20. imap_processing/ialirt/constants.py +7 -1
  21. imap_processing/ialirt/generate_coverage.py +1 -1
  22. imap_processing/ialirt/l0/ialirt_spice.py +1 -1
  23. imap_processing/ialirt/l0/parse_mag.py +33 -0
  24. imap_processing/ialirt/l0/process_codice.py +66 -0
  25. imap_processing/ialirt/utils/create_xarray.py +2 -0
  26. imap_processing/idex/idex_l2a.py +2 -2
  27. imap_processing/idex/idex_l2b.py +1 -1
  28. imap_processing/lo/l1c/lo_l1c.py +61 -3
  29. imap_processing/lo/l2/lo_l2.py +79 -11
  30. imap_processing/mag/l1a/mag_l1a.py +2 -2
  31. imap_processing/mag/l1a/mag_l1a_data.py +71 -13
  32. imap_processing/mag/l1c/interpolation_methods.py +34 -13
  33. imap_processing/mag/l1c/mag_l1c.py +117 -67
  34. imap_processing/mag/l1d/mag_l1d_data.py +3 -1
  35. imap_processing/spice/geometry.py +39 -28
  36. imap_processing/spice/pointing_frame.py +77 -50
  37. imap_processing/swapi/l1/swapi_l1.py +12 -4
  38. imap_processing/swe/utils/swe_constants.py +7 -7
  39. imap_processing/ultra/l1b/extendedspin.py +1 -1
  40. imap_processing/ultra/l1b/ultra_l1b_culling.py +2 -2
  41. imap_processing/ultra/l1b/ultra_l1b_extended.py +1 -1
  42. imap_processing/ultra/l1c/helio_pset.py +1 -1
  43. imap_processing/ultra/l1c/spacecraft_pset.py +2 -2
  44. imap_processing/ultra/l2/ultra_l2.py +3 -3
  45. imap_processing-1.0.1.dist-info/METADATA +121 -0
  46. {imap_processing-0.19.4.dist-info → imap_processing-1.0.1.dist-info}/RECORD +49 -47
  47. imap_processing-0.19.4.dist-info/METADATA +0 -120
  48. {imap_processing-0.19.4.dist-info → imap_processing-1.0.1.dist-info}/LICENSE +0 -0
  49. {imap_processing-0.19.4.dist-info → imap_processing-1.0.1.dist-info}/WHEEL +0 -0
  50. {imap_processing-0.19.4.dist-info → imap_processing-1.0.1.dist-info}/entry_points.txt +0 -0
@@ -73,7 +73,7 @@ def match_coords_to_indices(
73
73
  input_object: PointingSet | AbstractSkyMap,
74
74
  output_object: PointingSet | AbstractSkyMap,
75
75
  event_et: float | None = None,
76
- ) -> NDArray:
76
+ ) -> NDArray | xr.DataArray:
77
77
  """
78
78
  Find the output indices corresponding to each input coord between 2 spatial objects.
79
79
 
@@ -87,7 +87,7 @@ def match_coords_to_indices(
87
87
  This function always "pushes" the pixels of the input object to corresponding pixels
88
88
  in the output object's unwrapped rectangular grid or healpix tessellation;
89
89
  however, by swapping the input and output objects, one can apply the "pull" method
90
- of index matching.
90
+ of index matching.
91
91
 
92
92
  At present, the allowable inputs are either:
93
93
  - A PointingSet object and a SkyMap object, in either order of input/output.
@@ -97,7 +97,7 @@ def match_coords_to_indices(
97
97
  Parameters
98
98
  ----------
99
99
  input_object : PointingSet | AbstractSkyMap
100
- An object containing 1D spatial pixel centers in azimuth and elevation,
100
+ An object containing spatial pixel centers in azimuth and elevation,
101
101
  which will be matched to 1D indices of spatial pixels in the output frame.
102
102
  Must contain the Spice frame in which the pixel centers are defined.
103
103
  output_object : PointingSet | AbstractSkyMap
@@ -114,11 +114,13 @@ def match_coords_to_indices(
114
114
 
115
115
  Returns
116
116
  -------
117
- flat_indices_input_grid_output_frame : NDArray
118
- 1D array of pixel indices of the output object corresponding to each pixel in
119
- the input object. The length of the array is equal to the number of pixels in
120
- the input object, and may contain 0, 1, or multiple occurrences of the same
121
- output index.
117
+ flat_indices_input_grid_output_frame : xr.DataArray
118
+ Array of pixel indices mapping each input object pixel center to a pixel
119
+ in the output object. The output xr.DataArray will have the same leading
120
+ dimension labels preserved. The shape of the output array is (..., n)
121
+ where ... matches the non-spatial dimensions of the input object, and n
122
+ is the number of spatial pixels in the input object. Output indices may
123
+ contain 0, 1, or multiple occurrences of the same output index.
122
124
 
123
125
  Raises
124
126
  ------
@@ -166,14 +168,14 @@ def match_coords_to_indices(
166
168
  # use ravel_multi_index to get the 1D indices of the pixels in the output frame.
167
169
  az_indices = (
168
170
  np.digitize(
169
- input_obj_az_el_output_frame[:, 0],
171
+ input_obj_az_el_output_frame[..., 0],
170
172
  output_object.sky_grid.az_bin_edges,
171
173
  )
172
174
  - 1
173
175
  )
174
176
  el_indices = (
175
177
  np.digitize(
176
- input_obj_az_el_output_frame[:, 1],
178
+ input_obj_az_el_output_frame[..., 1],
177
179
  output_object.sky_grid.el_bin_edges,
178
180
  )
179
181
  - 1
@@ -191,8 +193,8 @@ def match_coords_to_indices(
191
193
  # which directly returns the index on the output frame's Healpix tessellation.
192
194
  flat_indices_input_grid_output_frame = hp.ang2pix(
193
195
  nside=output_object.nside,
194
- theta=input_obj_az_el_output_frame[:, 0], # Lon in degrees
195
- phi=input_obj_az_el_output_frame[:, 1], # Lat in degrees
196
+ theta=input_obj_az_el_output_frame[..., 0], # Lon in degrees
197
+ phi=input_obj_az_el_output_frame[..., 1], # Lat in degrees
196
198
  nest=output_object.nested,
197
199
  lonlat=True,
198
200
  )
@@ -202,6 +204,14 @@ def match_coords_to_indices(
202
204
  f"Received: {output_object.tiling_type}"
203
205
  )
204
206
 
207
+ # Wrap the output indices in a DataArray with the same leading dimensions as
208
+ # the input object az_el_points to preserve broadcasting information
209
+ input_dims = input_obj_az_el_input_frame.dims[:-1]
210
+ flat_indices_input_grid_output_frame = xr.DataArray(
211
+ flat_indices_input_grid_output_frame,
212
+ dims=input_dims,
213
+ )
214
+
205
215
  return flat_indices_input_grid_output_frame
206
216
 
207
217
 
@@ -236,9 +246,10 @@ class PointingSet(ABC):
236
246
  spice_reference_frame: geometry.SpiceFrame
237
247
 
238
248
  # ======== Attributes required to be set in a subclass ========
239
- # Azimuth and elevation coordinates of each spatial pixel. The ndarray should
240
- # have the shape (n, 2) where n is the number of spatial pixels
241
- az_el_points: np.ndarray
249
+ # Azimuth and elevation coordinates of each spatial pixel. Must be an
250
+ # xr.DataArray with dimensions (..., spatial_dim, az_el_coord) to preserve
251
+ # dimension labels
252
+ az_el_points: xr.DataArray
242
253
  # Tuple containing the names of each spatial coordinate of the xarray.Dataset
243
254
  # stored in the data attribute
244
255
  spatial_coords: tuple[str, ...]
@@ -274,7 +285,9 @@ class PointingSet(ABC):
274
285
  num_points: int
275
286
  The number of spatial pixels in the pointing set.
276
287
  """
277
- return self.az_el_points.shape[0]
288
+ # Last dimension is az/el vector, the second to last dimension is
289
+ # the number of pixels.
290
+ return self.az_el_points.shape[-2]
278
291
 
279
292
  @property
280
293
  def epoch(self) -> int:
@@ -430,11 +443,12 @@ class RectangularPointingSet(PointingSet):
430
443
  # into shape (number of points in tiling of the sky, 2) where
431
444
  # column 0 (az_el_points[:, 0]) is the azimuth of that point and
432
445
  # column 1 (az_el_points[:, 1]) is the elevation of that point.
433
- self.az_el_points = np.column_stack(
434
- (
435
- self.sky_grid.az_grid.ravel(),
436
- self.sky_grid.el_grid.ravel(),
437
- )
446
+ self.az_el_points = xr.DataArray(
447
+ np.stack(
448
+ (self.sky_grid.az_grid.ravel(), self.sky_grid.el_grid.ravel()),
449
+ axis=-1,
450
+ ),
451
+ dims=[CoordNames.GENERIC_PIXEL.value, CoordNames.AZ_EL_VECTOR.value],
438
452
  )
439
453
 
440
454
 
@@ -540,8 +554,9 @@ class UltraPointingSet(HealpixPointingSet):
540
554
  # The coordinates of the healpix pixel centers are stored as a 2D array
541
555
  # of shape (num_points, 2) where column 0 is the lon/az
542
556
  # and column 1 is the lat/el.
543
- self.az_el_points = np.column_stack(
544
- (azimuth_pixel_center, elevation_pixel_center)
557
+ self.az_el_points = xr.DataArray(
558
+ np.stack((azimuth_pixel_center, elevation_pixel_center), axis=-1),
559
+ dims=[CoordNames.GENERIC_PIXEL.value, CoordNames.AZ_EL_VECTOR.value],
545
560
  )
546
561
 
547
562
  @property
@@ -586,7 +601,45 @@ class UltraPointingSet(HealpixPointingSet):
586
601
  )
587
602
 
588
603
 
589
- class HiPointingSet(PointingSet):
604
+ class LoHiBasePointingSet(PointingSet):
605
+ """
606
+ Base class for Lo and Hi pointing sets with HAE coordinate data.
607
+
608
+ This class provides common functionality for pointing sets that contain
609
+ hae_longitude and hae_latitude coordinates in the dataset.
610
+ """
611
+
612
+ tiling_type: SkyTilingType = SkyTilingType.RECTANGULAR
613
+
614
+ def update_az_el_points(self) -> None:
615
+ """
616
+ Update the az_el_points instance variable with new az/el coordinates.
617
+
618
+ The values store in the "hae_longitude" and "hae_latitude" variables
619
+ are used to construct the azimuth and elevation coordinates.
620
+ """
621
+ # Get lon/lat coordinates, squeeze the epoch dimension and stack along
622
+ # the spatial dimensions. xarray.stack() takes possibly multiple spatial
623
+ # dimensions and reshapes those into a single dimension.
624
+ az_stacked = (
625
+ self.data["hae_longitude"]
626
+ .squeeze("epoch")
627
+ .stack({CoordNames.GENERIC_PIXEL.value: self.spatial_coords})
628
+ )
629
+ el_stacked = (
630
+ self.data["hae_latitude"]
631
+ .squeeze("epoch")
632
+ .stack({CoordNames.GENERIC_PIXEL.value: self.spatial_coords})
633
+ )
634
+
635
+ # Stack lon/lat along last axis to create shape (..., 2)
636
+ self.az_el_points = xr.DataArray(
637
+ np.stack([az_stacked.values, el_stacked.values], axis=-1),
638
+ dims=[*az_stacked.dims, CoordNames.AZ_EL_VECTOR.value],
639
+ )
640
+
641
+
642
+ class HiPointingSet(LoHiBasePointingSet):
590
643
  """
591
644
  PointingSet object specific to Hi L1C PSet data.
592
645
 
@@ -602,7 +655,7 @@ class HiPointingSet(PointingSet):
602
655
  super().__init__(dataset, spice_reference_frame=geometry.SpiceFrame.ECLIPJ2000)
603
656
 
604
657
  # Filter out ENAs from non-selected portions of the spin.
605
- if spin_phase not in ["full", "ram", "anti-ram"]:
658
+ if spin_phase not in ["full", "ram", "anti"]:
606
659
  raise ValueError(f"Unrecognized spin_phase value: {spin_phase}.")
607
660
  # ram only includes spin-phase interval [0, 0.5)
608
661
  # which is the first half of the spin_angle_bins
@@ -612,7 +665,7 @@ class HiPointingSet(PointingSet):
612
665
  )
613
666
  # anti-ram includes spin-phase interval [0.5, 1)
614
667
  # which is the second half of the spin_angle_bins
615
- elif spin_phase == "anti-ram":
668
+ elif spin_phase == "anti":
616
669
  self.data = self.data.isel(
617
670
  spin_angle_bin=slice(self.data["spin_angle_bin"].data.size // 2, None)
618
671
  )
@@ -631,16 +684,13 @@ class HiPointingSet(PointingSet):
631
684
  self.data["exposure_factor"], self.data["epoch"].values[0]
632
685
  )
633
686
 
634
- self.az_el_points = np.column_stack(
635
- (
636
- np.squeeze(self.data["hae_longitude"]),
637
- np.squeeze(self.data["hae_latitude"]),
638
- )
639
- )
640
687
  self.spatial_coords = ("spin_angle_bin",)
641
688
 
689
+ # Update az_el_points using the base class method
690
+ self.update_az_el_points()
691
+
642
692
 
643
- class LoPointingSet(PointingSet):
693
+ class LoPointingSet(LoHiBasePointingSet):
644
694
  """
645
695
  PointingSet object specific to Lo L1C PSet data.
646
696
 
@@ -653,15 +703,11 @@ class LoPointingSet(PointingSet):
653
703
  def __init__(self, dataset: xr.Dataset):
654
704
  super().__init__(dataset, spice_reference_frame=geometry.SpiceFrame.IMAP_HAE)
655
705
 
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
- )
662
- )
663
706
  self.spatial_coords = ("spin_angle", "off_angle")
664
707
 
708
+ # Update az_el_points using the base class method
709
+ self.update_az_el_points()
710
+
665
711
 
666
712
  # Define the Map classes
667
713
  class AbstractSkyMap(ABC):
@@ -694,9 +740,10 @@ class AbstractSkyMap(ABC):
694
740
  max_epoch: int
695
741
 
696
742
  # ======== Attributes required to be set in a subclass ========
697
- # Azimuth and elevation coordinates of each spatial pixel. The ndarray should
698
- # have the shape (n, 2) where n is the number of spatial pixels
699
- az_el_points: np.ndarray
743
+ # Azimuth and elevation coordinates of each spatial pixel. The xarray.DataArray
744
+ # should have the shape (n, 2) where n is the number of spatial pixels.
745
+ # Always a simple numpy array for maps (no need for multi-dimensional coords).
746
+ az_el_points: xr.DataArray
700
747
  # Type of sky tiling
701
748
  tiling_type: SkyTilingType
702
749
  # Dictionary of xr.DataArray objects for each non-spatial coordinate in the SkyMap
@@ -829,19 +876,11 @@ class AbstractSkyMap(ABC):
829
876
  )
830
877
 
831
878
  for value_key in value_keys:
832
- pset_values = pointing_set.data[value_key]
833
-
834
879
  # If multiple spatial axes present
835
880
  # (i.e (az, el) for rectangular coordinate PSET),
836
881
  # flatten them in the values array to match the raveled indices
837
- non_spatial_axes_shape = tuple(
838
- size
839
- for key, size in pset_values.sizes.items()
840
- if key not in pointing_set.spatial_coords
841
- )
842
- raveled_pset_data = pset_values.data.reshape(
843
- *non_spatial_axes_shape,
844
- pointing_set.num_points,
882
+ raveled_pset_data = pointing_set.data[value_key].stack(
883
+ {CoordNames.GENERIC_PIXEL.value: pointing_set.spatial_coords}
845
884
  )
846
885
 
847
886
  if value_key not in self.data_1d.data_vars:
@@ -864,10 +903,16 @@ class AbstractSkyMap(ABC):
864
903
  if index_match_method is IndexMatchMethod.PUSH:
865
904
  # Bin the values at the matched indices. There may be multiple
866
905
  # pointing set pixels that correspond to the same sky map pixel.
906
+ # Broadcast all arrays together using xarray dimension alignment
907
+ data_bc, indices_bc = xr.broadcast(
908
+ raveled_pset_data, matched_indices_push
909
+ )
910
+
911
+ # Extract numpy arrays for bincount operation
867
912
  pointing_projected_values = map_utils.bin_single_array_at_indices(
868
- value_array=raveled_pset_data,
913
+ value_array=data_bc.values,
869
914
  projection_grid_shape=self.binning_grid_shape,
870
- projection_indices=matched_indices_push,
915
+ projection_indices=indices_bc.values,
871
916
  input_valid_mask=pset_valid_mask,
872
917
  )
873
918
  # TODO: we may need to allow for unweighted/weighted means here by
@@ -879,7 +924,7 @@ class AbstractSkyMap(ABC):
879
924
  valid_map_mask = pset_valid_mask[matched_indices_pull]
880
925
  # We know that there will only be one value per sky map pixel,
881
926
  # so we can use the matched indices directly
882
- pointing_projected_values = raveled_pset_data[
927
+ pointing_projected_values = raveled_pset_data.values[
883
928
  ..., matched_indices_pull[valid_map_mask]
884
929
  ]
885
930
  # TODO: we may need to allow for unweighted/weighted means here by
@@ -1120,7 +1165,10 @@ class RectangularSkyMap(AbstractSkyMap):
1120
1165
  el_points = self.sky_grid.el_grid.ravel()
1121
1166
 
1122
1167
  # Stack so axis 0 is different pixels, and axis 1 is (az, el) of the pixel
1123
- self.az_el_points = np.column_stack((az_points, el_points))
1168
+ self.az_el_points = xr.DataArray(
1169
+ np.column_stack((az_points, el_points)),
1170
+ dims=[CoordNames.GENERIC_PIXEL.value, CoordNames.AZ_EL_VECTOR.value],
1171
+ )
1124
1172
 
1125
1173
  # Calculate solid angles of each pixel in the map grid in units of steradians
1126
1174
  self.solid_angle_grid = spatial_utils.build_solid_angle_map(
@@ -1267,7 +1315,7 @@ class RectangularSkyMap(AbstractSkyMap):
1267
1315
  if ("L2" in name)
1268
1316
  ]
1269
1317
  l2_coords.append(CoordNames.TIME.value)
1270
- for map_coord in cdf_ds.dims.keys():
1318
+ for map_coord in cdf_ds.dims:
1271
1319
  if map_coord not in l2_coords:
1272
1320
  cdf_ds = cdf_ds.drop_dims(map_coord)
1273
1321
 
@@ -1331,25 +1379,28 @@ class RectangularSkyMap(AbstractSkyMap):
1331
1379
  )
1332
1380
  cdf_ds.attrs.update(map_attrs)
1333
1381
 
1334
- # Set the variable attributes
1335
- for var in [*cdf_ds.data_vars, *cdf_ds.coords]:
1382
+ # Set the variable and coordinate attributes
1383
+ for name, data_array in {**cdf_ds.data_vars, **cdf_ds.coords}.items():
1336
1384
  try:
1337
- # Don't check schema on label or delta variables
1338
- ignore_schema_substrings = ["_label", "_delta"]
1339
- check_schema = (
1340
- False if any(s in var for s in ignore_schema_substrings) else True
1341
- )
1385
+ # We only check the schema on data variables that include "epoch"
1386
+ # in their list of dimensions (But not epoch itself).
1387
+ check_schema = name != "epoch" and "epoch" in data_array.dims
1342
1388
  var_attrs = cdf_attrs.get_variable_attributes(
1343
- variable_name=var,
1389
+ variable_name=name,
1344
1390
  check_schema=check_schema,
1345
1391
  )
1346
1392
  except KeyError as e:
1347
1393
  raise KeyError(
1348
- f"Attributes for variable {var} not found in "
1394
+ f"Attributes for variable {name} not found in "
1349
1395
  f"loaded variable attributes."
1350
1396
  ) from e
1351
1397
 
1352
- cdf_ds[var].attrs.update(var_attrs)
1398
+ cdf_ds[name].attrs.update(var_attrs)
1399
+
1400
+ # Manually adjust epoch attributes
1401
+ cdf_ds["epoch"].attrs.update(
1402
+ {"DELTA_PLUS_VAR": "epoch_delta", "BIN_LOCATION": 0}
1403
+ )
1353
1404
 
1354
1405
  return cdf_ds
1355
1406
 
@@ -1428,7 +1479,10 @@ class HealpixSkyMap(AbstractSkyMap):
1428
1479
  nside=nside, ipix=np.arange(hp.nside2npix(nside)), nest=nested, lonlat=True
1429
1480
  )
1430
1481
  # Stack so axis 0 is different pixels, and axis 1 is (az, el) of the pixel
1431
- self.az_el_points = np.column_stack((pixel_az, pixel_el))
1482
+ self.az_el_points = xr.DataArray(
1483
+ np.column_stack((pixel_az, pixel_el)),
1484
+ dims=[CoordNames.GENERIC_PIXEL.value, CoordNames.AZ_EL_VECTOR.value],
1485
+ )
1432
1486
 
1433
1487
  self.spatial_coords = {
1434
1488
  CoordNames.HEALPIX_INDEX.value: xr.DataArray(
@@ -1764,7 +1818,7 @@ class HealpixSkyMap(AbstractSkyMap):
1764
1818
  value_array=healpix_values_array,
1765
1819
  max_subdivision_depth=max_subdivision_depth,
1766
1820
  )
1767
- for lon_lat in rect_map.az_el_points
1821
+ for lon_lat in rect_map.az_el_points.values
1768
1822
  ]
1769
1823
 
1770
1824
  # Separate the best value and the recursion depth for each pixel
@@ -18,3 +18,8 @@ class CoordNames(Enum):
18
18
  ELEVATION_L1C = "latitude"
19
19
  AZIMUTH_L2 = "longitude"
20
20
  ELEVATION_L2 = "latitude"
21
+
22
+ # Common name for dimension along azimuth/elevation vector
23
+ AZ_EL_VECTOR = "az_el"
24
+ # Commoon name for dimension along Cartesian vector
25
+ CARTESIAN_VECTOR = "x_y_z"
@@ -4,7 +4,23 @@ from pathlib import Path
4
4
 
5
5
  import numpy as np
6
6
  import pandas as pd
7
+ import xarray as xr
7
8
  from numpy.polynomial import Polynomial
9
+ from scipy.constants import electron_volt, erg, proton_mass
10
+
11
+ from imap_processing.ena_maps.ena_maps import LoHiBasePointingSet
12
+ from imap_processing.ena_maps.utils.coordinates import CoordNames
13
+ from imap_processing.spice import geometry
14
+ from imap_processing.spice.time import ttj2000ns_to_et
15
+
16
+ # Physical constants for Compton-Getting correction
17
+ # Units: electron_volt = [J / eV]
18
+ # erg = [J / erg]
19
+ # To get [erg / eV], => electron_volt [J / eV] / erg [J / erg] = erg_per_ev [erg / eV]
20
+ ERG_PER_EV = electron_volt / erg # erg per eV - unit conversion factor
21
+ # Units: proton_mass = [kg]
22
+ # Here, we convert proton_mass to grams
23
+ PROTON_MASS_GRAMS = proton_mass * 1e3 # proton mass in grams
8
24
 
9
25
 
10
26
  class PowerLawFluxCorrector:
@@ -289,3 +305,255 @@ class PowerLawFluxCorrector:
289
305
  )
290
306
 
291
307
  return corrected_flux, corrected_flux_stat_unc
308
+
309
+
310
+ def _add_spacecraft_velocity_to_pset(pset: LoHiBasePointingSet) -> None:
311
+ """
312
+ Calculate and add spacecraft velocity data to pointing set.
313
+
314
+ Parameters
315
+ ----------
316
+ pset : LoHiBasePointingSet
317
+ Pointing set object to be updated.
318
+
319
+ Notes
320
+ -----
321
+ Adds the following DataArrays to pset.data:
322
+ - "sc_velocity": Spacecraft velocity vector (km/s) with dims ["x_y_z"]
323
+ - "sc_direction_vector": Spacecraft velocity unit vector with dims ["x_y_z"]
324
+ """
325
+ # Compute ephemeris time (J2000 seconds) of PSET midpoint time
326
+ # TODO: Use the Pointing midpoint time. Epoch should be start time
327
+ # but use it until we can make Lo and Hi PSETs have a consistent
328
+ # variable to hold the midpoint time.
329
+ et = ttj2000ns_to_et(pset.data["epoch"].values[0])
330
+ # Get spacecraft state in HAE frame
331
+ sc_state = geometry.imap_state(et, ref_frame=geometry.SpiceFrame.IMAP_HAE)
332
+ sc_velocity_vector = sc_state[3:6]
333
+
334
+ # Store spacecraft velocity as DataArray
335
+ pset.data["sc_velocity"] = xr.DataArray(
336
+ sc_velocity_vector, dims=[CoordNames.CARTESIAN_VECTOR.value]
337
+ )
338
+
339
+ # Calculate spacecraft speed and direction
340
+ sc_velocity_km_per_sec = np.linalg.norm(
341
+ pset.data["sc_velocity"], axis=-1, keepdims=True
342
+ )
343
+ pset.data["sc_direction_vector"] = pset.data["sc_velocity"] / sc_velocity_km_per_sec
344
+
345
+
346
+ def _add_cartesian_look_direction(pset: LoHiBasePointingSet) -> None:
347
+ """
348
+ Calculate and add look direction vectors to pointing set.
349
+
350
+ Parameters
351
+ ----------
352
+ pset : LoHiBasePointingSet
353
+ Pointing set object to be updated.
354
+
355
+ Notes
356
+ -----
357
+ Adds the following DataArray to pset.data:
358
+ - "look_direction": Cartesian unit vectors with dims [...spatial_dims, "x_y_z"]
359
+ """
360
+ longitudes = pset.data["hae_longitude"]
361
+ latitudes = pset.data["hae_latitude"]
362
+
363
+ # Stack spherical coordinates (r=1 for unit vectors, azimuth, elevation)
364
+ spherical_coords = np.stack(
365
+ [
366
+ np.ones_like(longitudes), # r = 1 for unit vectors
367
+ longitudes, # azimuth = longitude
368
+ latitudes, # elevation = latitude
369
+ ],
370
+ axis=-1,
371
+ )
372
+
373
+ # Convert to Cartesian coordinates and store as DataArray
374
+ pset.data["look_direction"] = xr.DataArray(
375
+ geometry.spherical_to_cartesian(spherical_coords),
376
+ dims=[*longitudes.dims, CoordNames.CARTESIAN_VECTOR.value],
377
+ )
378
+
379
+
380
+ def _calculate_compton_getting_transform(
381
+ pset: LoHiBasePointingSet,
382
+ energy_hf: xr.DataArray,
383
+ ) -> None:
384
+ """
385
+ Apply Compton-Getting transformation to compute ENA source directions.
386
+
387
+ This implements the Compton-Getting velocity transformation to correct
388
+ for the motion of the spacecraft through the heliosphere. The transformation
389
+ accounts for the Doppler shift of ENA energies and the aberration of
390
+ arrival directions.
391
+
392
+ All calculations are performed using xarray DataArrays to preserve
393
+ dimension information throughout the computation.
394
+
395
+ Parameters
396
+ ----------
397
+ pset : LoHiBasePointingSet
398
+ Pointing set object with sc_velocity, sc_direction_vector, and
399
+ look_direction already added.
400
+ energy_hf : xr.DataArray
401
+ ENA energies in the heliosphere frame in eV.
402
+
403
+ Notes
404
+ -----
405
+ The algorithm is based on the "Appendix A. The IMAP-Lo Mapping Algorithms"
406
+ document.
407
+ Adds the following DataArrays to pset.data:
408
+ - "energy_sc": ENA energies in spacecraft frame (eV)
409
+ - "ena_source_hae_longitude": ENA source longitudes in heliosphere frame (degrees)
410
+ - "ena_source_hae_latitude": ENA source latitudes in heliosphere frame (degrees)
411
+ """
412
+ # Store heliosphere frame energies
413
+ pset.data["energy_hf"] = energy_hf
414
+
415
+ # Calculate spacecraft speed
416
+ sc_velocity_km_per_sec = np.linalg.norm(
417
+ pset.data["sc_velocity"], axis=-1, keepdims=True
418
+ )
419
+
420
+ # Calculate dot product between look directions and spacecraft direction vector
421
+ # Use Einstein summation for efficient vectorized dot product
422
+ dot_product = xr.DataArray(
423
+ np.einsum(
424
+ "...i,...i->...",
425
+ pset.data["look_direction"],
426
+ pset.data["sc_direction_vector"],
427
+ ),
428
+ dims=pset.data["look_direction"].dims[:-1],
429
+ )
430
+
431
+ # Calculate the kinetic energy of a hydrogen ENA traveling at spacecraft velocity
432
+ # E_u = (1/2) * m * U_sc^2 (convert km/s to cm/s with 1.0e5 factor)
433
+ energy_u = (
434
+ 0.5 * PROTON_MASS_GRAMS * (sc_velocity_km_per_sec * 1e5) ** 2 / ERG_PER_EV
435
+ )
436
+
437
+ # Note: Tim thinks that this approach seems backwards. Here, we are assuming
438
+ # that ENAs are observed in the heliosphere frame at the ESA energy levels.
439
+ # We then calculate the velocity that said ENAs would have in the spacecraft
440
+ # frame as well as the CG corrected energy level in the spacecraft frame.
441
+ # We then use this velocity to calculate and the velocity of the spacecraft
442
+ # to do the vector math which determines the ENA source direction in the
443
+ # heliosphere frame.
444
+ # The ENAs are in fact observed in the spacecraft frame at a known energy
445
+ # level in the spacecraft frame. Why don't we use that energy level to
446
+ # calculate the source direction in the spacecraft frame and then do the
447
+ # vector math to find the source direction in the heliosphere frame? We
448
+ # would also need to calculate the CG corrected ENA energy in the heliosphere
449
+ # frame and keep track of that when binning.
450
+
451
+ # Calculate y values for each energy level (Equation 61)
452
+ # y_k = sqrt(E^h_k / E^u)
453
+ y = np.sqrt(pset.data["energy_hf"] / energy_u)
454
+
455
+ # Velocity magnitude factor calculation (Equation 62)
456
+ # x_k = (êₛ · û_sc) + sqrt(y² + (êₛ · û_sc)² - 1)
457
+ x = dot_product + np.sqrt(y**2 + dot_product**2 - 1)
458
+
459
+ # Calculate ENA speed in the spacecraft frame
460
+ # |v⃗_sc| = x_k * U_sc
461
+ velocity_sc = x * sc_velocity_km_per_sec
462
+
463
+ # Calculate the kinetic energy in the spacecraft frame
464
+ # E_sc = (1/2) * M_p * v_sc² (convert km/s to cm/s with 1.0e5 factor)
465
+ pset.data["energy_sc"] = (
466
+ 0.5 * PROTON_MASS_GRAMS * (velocity_sc * 1e5) ** 2 / ERG_PER_EV
467
+ )
468
+
469
+ # Calculate the velocity vector in the spacecraft frame
470
+ # v⃗_sc = |v_sc| * êₛ (velocity direction follows look direction)
471
+ velocity_vector_sc = velocity_sc * pset.data["look_direction"]
472
+
473
+ # Calculate the ENA velocity vector in the heliosphere frame
474
+ # v⃗_helio = v⃗_sc - U⃗_sc (simple velocity addition)
475
+ velocity_vector_helio = velocity_vector_sc - pset.data["sc_velocity"]
476
+
477
+ # Convert to spherical coordinates to get ENA source directions
478
+ ena_source_direction_helio = geometry.cartesian_to_spherical(
479
+ velocity_vector_helio.data
480
+ )
481
+
482
+ # Update the PSET hae_longitude and hae_latitude variables with the new
483
+ # energy-dependent values.
484
+ pset.data["hae_longitude"] = (
485
+ pset.data["energy_sc"].dims,
486
+ ena_source_direction_helio[..., 1],
487
+ )
488
+ pset.data["hae_latitude"] = (
489
+ pset.data["energy_sc"].dims,
490
+ ena_source_direction_helio[..., 2],
491
+ )
492
+
493
+ # For ram/anti-ram filtering we can use the sign of the scalar projection
494
+ # of the ENA source direction onto the spacecraft velocity vector.
495
+ # ram_mask = (v⃗_helio · û_sc) >= 0
496
+ ram_mask = (
497
+ np.einsum(
498
+ "...i,...i->...", velocity_vector_helio, pset.data["sc_direction_vector"]
499
+ )
500
+ >= 0
501
+ )
502
+ pset.data["ram_mask"] = xr.DataArray(
503
+ ram_mask,
504
+ dims=velocity_vector_helio.dims[:-1],
505
+ )
506
+
507
+
508
+ def apply_compton_getting_correction(
509
+ pset: LoHiBasePointingSet,
510
+ energy_hf: xr.DataArray,
511
+ ) -> None:
512
+ """
513
+ Apply Compton-Getting correction to a pointing set and update coordinates.
514
+
515
+ This function performs the Compton-Getting velocity transformation to correct
516
+ ENA observations for the motion of the spacecraft through the heliosphere.
517
+ The corrected coordinates represent the true source directions of the ENAs
518
+ in the heliosphere frame.
519
+
520
+ The pointing set is modified in-place: new variables are added to the dataset
521
+ for the corrected coordinates and energies, and the az_el_points attribute
522
+ is updated to use the corrected coordinates for binning.
523
+
524
+ All calculations are performed using xarray DataArrays to preserve dimension
525
+ information throughout the computation.
526
+
527
+ Parameters
528
+ ----------
529
+ pset : LoHiBasePointingSet
530
+ Pointing set object containing HAE longitude/latitude coordinates.
531
+ energy_hf : xr.DataArray
532
+ ENA energies in the heliosphere frame in eV. Must be 1D with an
533
+ energy dimension.
534
+
535
+ Notes
536
+ -----
537
+ This function adds the following variables to the pointing set dataset:
538
+ - "sc_velocity": Spacecraft velocity vector (km/s)
539
+ - "sc_direction_vector": Spacecraft velocity unit vector
540
+ - "look_direction": Cartesian unit vectors of observation directions
541
+ - "energy_hf": ENA energies in heliosphere frame (eV)
542
+ - "energy_sc": ENA energies in spacecraft frame (eV)
543
+ - "ena_source_hae_longitude": ENA source longitudes in heliosphere frame (degrees)
544
+ - "ena_source_hae_latitude": ENA source latitudes in heliosphere frame (degrees)
545
+
546
+ The az_el_points attribute is updated to use the corrected coordinates,
547
+ which will be used for subsequent binning operations.
548
+ """
549
+ # Step 1: Add spacecraft velocity and direction to pset
550
+ _add_spacecraft_velocity_to_pset(pset)
551
+
552
+ # Step 2: Calculate and add look direction vectors to pset
553
+ _add_cartesian_look_direction(pset)
554
+
555
+ # Step 3: Apply Compton-Getting transformation
556
+ _calculate_compton_getting_transform(pset, energy_hf)
557
+
558
+ # Step 4: Update az_el_points to use the corrected coordinates
559
+ pset.update_az_el_points()