imap-processing 1.0.0__py3-none-any.whl → 1.0.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.
Files changed (68) 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 +97 -254
  4. imap_processing/cdf/config/imap_codice_l2-hi-omni_variable_attrs.yaml +635 -0
  5. imap_processing/cdf/config/imap_codice_l2-hi-sectored_variable_attrs.yaml +422 -0
  6. imap_processing/cdf/config/imap_enamaps_l2-common_variable_attrs.yaml +29 -22
  7. imap_processing/cdf/config/imap_enamaps_l2-healpix_variable_attrs.yaml +2 -0
  8. imap_processing/cdf/config/imap_enamaps_l2-rectangular_variable_attrs.yaml +12 -2
  9. imap_processing/cdf/config/imap_swapi_variable_attrs.yaml +2 -13
  10. imap_processing/cdf/utils.py +2 -2
  11. imap_processing/cli.py +10 -27
  12. imap_processing/codice/codice_l1a_lo_angular.py +362 -0
  13. imap_processing/codice/codice_l1a_lo_species.py +282 -0
  14. imap_processing/codice/codice_l1b.py +62 -97
  15. imap_processing/codice/codice_l2.py +801 -174
  16. imap_processing/codice/codice_new_l1a.py +64 -0
  17. imap_processing/codice/constants.py +96 -0
  18. imap_processing/codice/utils.py +270 -0
  19. imap_processing/ena_maps/ena_maps.py +157 -95
  20. imap_processing/ena_maps/utils/coordinates.py +5 -0
  21. imap_processing/ena_maps/utils/corrections.py +450 -0
  22. imap_processing/ena_maps/utils/map_utils.py +143 -42
  23. imap_processing/ena_maps/utils/naming.py +3 -1
  24. imap_processing/hi/hi_l1c.py +34 -12
  25. imap_processing/hi/hi_l2.py +82 -44
  26. imap_processing/ialirt/constants.py +7 -1
  27. imap_processing/ialirt/generate_coverage.py +3 -1
  28. imap_processing/ialirt/l0/parse_mag.py +1 -0
  29. imap_processing/ialirt/l0/process_codice.py +66 -0
  30. imap_processing/ialirt/l0/process_hit.py +1 -0
  31. imap_processing/ialirt/l0/process_swapi.py +1 -0
  32. imap_processing/ialirt/l0/process_swe.py +2 -0
  33. imap_processing/ialirt/process_ephemeris.py +6 -2
  34. imap_processing/ialirt/utils/create_xarray.py +4 -2
  35. imap_processing/idex/idex_l2a.py +2 -2
  36. imap_processing/idex/idex_l2b.py +1 -1
  37. imap_processing/lo/l1c/lo_l1c.py +62 -4
  38. imap_processing/lo/l2/lo_l2.py +85 -15
  39. imap_processing/mag/l1a/mag_l1a.py +2 -2
  40. imap_processing/mag/l1a/mag_l1a_data.py +71 -13
  41. imap_processing/mag/l1c/interpolation_methods.py +34 -13
  42. imap_processing/mag/l1c/mag_l1c.py +117 -67
  43. imap_processing/mag/l1d/mag_l1d_data.py +3 -1
  44. imap_processing/quality_flags.py +1 -0
  45. imap_processing/spice/geometry.py +11 -9
  46. imap_processing/spice/pointing_frame.py +77 -50
  47. imap_processing/swapi/constants.py +4 -0
  48. imap_processing/swapi/l1/swapi_l1.py +59 -24
  49. imap_processing/swapi/l2/swapi_l2.py +17 -3
  50. imap_processing/swe/utils/swe_constants.py +7 -7
  51. imap_processing/ultra/l1a/ultra_l1a.py +121 -72
  52. imap_processing/ultra/l1b/de.py +57 -1
  53. imap_processing/ultra/l1b/extendedspin.py +1 -1
  54. imap_processing/ultra/l1b/ultra_l1b_annotated.py +0 -1
  55. imap_processing/ultra/l1b/ultra_l1b_culling.py +2 -2
  56. imap_processing/ultra/l1b/ultra_l1b_extended.py +25 -12
  57. imap_processing/ultra/l1c/helio_pset.py +29 -6
  58. imap_processing/ultra/l1c/l1c_lookup_utils.py +4 -2
  59. imap_processing/ultra/l1c/spacecraft_pset.py +10 -6
  60. imap_processing/ultra/l1c/ultra_l1c.py +6 -6
  61. imap_processing/ultra/l1c/ultra_l1c_pset_bins.py +82 -20
  62. imap_processing/ultra/l2/ultra_l2.py +2 -2
  63. imap_processing-1.0.2.dist-info/METADATA +121 -0
  64. {imap_processing-1.0.0.dist-info → imap_processing-1.0.2.dist-info}/RECORD +67 -61
  65. imap_processing-1.0.0.dist-info/METADATA +0 -120
  66. {imap_processing-1.0.0.dist-info → imap_processing-1.0.2.dist-info}/LICENSE +0 -0
  67. {imap_processing-1.0.0.dist-info → imap_processing-1.0.2.dist-info}/WHEEL +0 -0
  68. {imap_processing-1.0.0.dist-info → imap_processing-1.0.2.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
 
@@ -594,28 +647,21 @@ class HiPointingSet(PointingSet):
594
647
  ----------
595
648
  dataset : xarray.Dataset | str | Path
596
649
  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.
599
650
  """
600
651
 
601
- def __init__(self, dataset: xr.Dataset | str | Path, spin_phase: str):
602
- super().__init__(dataset, spice_reference_frame=geometry.SpiceFrame.ECLIPJ2000)
652
+ def __init__(self, dataset: xr.Dataset | str | Path):
653
+ super().__init__(dataset, spice_reference_frame=geometry.SpiceFrame.IMAP_HAE)
654
+
655
+ self.spatial_coords = ("spin_angle_bin",)
603
656
 
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}.")
657
+ # Naively generate the ram_mask variable assuming spacecraft frame
658
+ # binning. The ram_mask variable gets updated in the CG correction
659
+ # code if the CG correction is applied.
660
+ ram_mask = xr.zeros_like(self.data["spin_angle_bin"], dtype=bool)
607
661
  # ram only includes spin-phase interval [0, 0.5)
608
662
  # 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
- )
663
+ ram_mask[slice(0, self.data["spin_angle_bin"].data.size // 2)] = True
664
+ self.data["ram_mask"] = ram_mask
619
665
 
620
666
  # Rename some PSET vars to match L2 variables
621
667
  self.data = self.data.rename(
@@ -631,16 +677,11 @@ class HiPointingSet(PointingSet):
631
677
  self.data["exposure_factor"], self.data["epoch"].values[0]
632
678
  )
633
679
 
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
- self.spatial_coords = ("spin_angle_bin",)
680
+ # Update az_el_points using the base class method
681
+ self.update_az_el_points()
641
682
 
642
683
 
643
- class LoPointingSet(PointingSet):
684
+ class LoPointingSet(LoHiBasePointingSet):
644
685
  """
645
686
  PointingSet object specific to Lo L1C PSet data.
646
687
 
@@ -653,15 +694,11 @@ class LoPointingSet(PointingSet):
653
694
  def __init__(self, dataset: xr.Dataset):
654
695
  super().__init__(dataset, spice_reference_frame=geometry.SpiceFrame.IMAP_HAE)
655
696
 
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
697
  self.spatial_coords = ("spin_angle", "off_angle")
664
698
 
699
+ # Update az_el_points using the base class method
700
+ self.update_az_el_points()
701
+
665
702
 
666
703
  # Define the Map classes
667
704
  class AbstractSkyMap(ABC):
@@ -694,9 +731,10 @@ class AbstractSkyMap(ABC):
694
731
  max_epoch: int
695
732
 
696
733
  # ======== 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
734
+ # Azimuth and elevation coordinates of each spatial pixel. The xarray.DataArray
735
+ # should have the shape (n, 2) where n is the number of spatial pixels.
736
+ # Always a simple numpy array for maps (no need for multi-dimensional coords).
737
+ az_el_points: xr.DataArray
700
738
  # Type of sky tiling
701
739
  tiling_type: SkyTilingType
702
740
  # Dictionary of xr.DataArray objects for each non-spatial coordinate in the SkyMap
@@ -763,12 +801,12 @@ class AbstractSkyMap(ABC):
763
801
  """
764
802
  return self.az_el_points.shape[0]
765
803
 
766
- def project_pset_values_to_map(
804
+ def project_pset_values_to_map( # noqa: PLR0912
767
805
  self,
768
806
  pointing_set: PointingSet,
769
807
  value_keys: list[str] | None = None,
770
808
  index_match_method: IndexMatchMethod = IndexMatchMethod.PUSH,
771
- pset_valid_mask: NDArray | None = None,
809
+ pset_valid_mask: NDArray | xr.DataArray | None = None,
772
810
  ) -> None:
773
811
  """
774
812
  Project a pointing set's values to the map grid.
@@ -790,7 +828,7 @@ class AbstractSkyMap(ABC):
790
828
  index_match_method : IndexMatchMethod, optional
791
829
  The method of index matching to use for all values.
792
830
  Default is IndexMatchMethod.PUSH.
793
- pset_valid_mask : NDArray, optional
831
+ pset_valid_mask : xarray.DataArray or NDArray, optional
794
832
  A boolean mask of shape (number of pointing set pixels,) indicating
795
833
  which pixels in the pointing set should be considered valid for projection.
796
834
  If None, all pixels are considered valid. Default is None.
@@ -802,9 +840,9 @@ class AbstractSkyMap(ABC):
802
840
  """
803
841
  if value_keys is None:
804
842
  value_keys = list(pointing_set.data.data_vars.keys())
805
- for value_key in value_keys:
806
- if value_key not in pointing_set.data.data_vars:
807
- raise ValueError(f"Value key {value_key} not found in pointing set.")
843
+
844
+ if missing_keys := set(value_keys) - set(pointing_set.data.data_vars):
845
+ raise KeyError(f"Value keys not found in pointing set: {missing_keys}")
808
846
 
809
847
  if pset_valid_mask is None:
810
848
  pset_valid_mask = np.ones(pointing_set.num_points, dtype=bool)
@@ -829,19 +867,14 @@ class AbstractSkyMap(ABC):
829
867
  )
830
868
 
831
869
  for value_key in value_keys:
832
- pset_values = pointing_set.data[value_key]
870
+ if value_key not in pointing_set.data.data_vars:
871
+ raise ValueError(f"Value key {value_key} not found in pointing set.")
833
872
 
834
873
  # If multiple spatial axes present
835
874
  # (i.e (az, el) for rectangular coordinate PSET),
836
- # 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,
875
+ # stack them into a single coordinate to match the raveled indices
876
+ raveled_pset_data = pointing_set.data[value_key].stack(
877
+ {CoordNames.GENERIC_PIXEL.value: pointing_set.spatial_coords}
845
878
  )
846
879
 
847
880
  if value_key not in self.data_1d.data_vars:
@@ -864,11 +897,26 @@ class AbstractSkyMap(ABC):
864
897
  if index_match_method is IndexMatchMethod.PUSH:
865
898
  # Bin the values at the matched indices. There may be multiple
866
899
  # pointing set pixels that correspond to the same sky map pixel.
900
+ # Broadcast all arrays together using xarray dimension alignment
901
+ data_bc, indices_bc = xr.broadcast(
902
+ raveled_pset_data, matched_indices_push
903
+ )
904
+ # If the valid mask is a xr.DataArray, broadcast it to the same shape
905
+ if isinstance(pset_valid_mask, xr.DataArray):
906
+ stacked_valid_mask = pset_valid_mask.stack(
907
+ {CoordNames.GENERIC_PIXEL.value: pointing_set.spatial_coords}
908
+ )
909
+ pset_valid_mask_bc, _ = xr.broadcast(data_bc, stacked_valid_mask)
910
+ pset_valid_mask_values = pset_valid_mask_bc.values
911
+ else:
912
+ pset_valid_mask_values = pset_valid_mask
913
+
914
+ # Extract numpy arrays for bincount operation
867
915
  pointing_projected_values = map_utils.bin_single_array_at_indices(
868
- value_array=raveled_pset_data,
916
+ value_array=data_bc.values,
869
917
  projection_grid_shape=self.binning_grid_shape,
870
- projection_indices=matched_indices_push,
871
- input_valid_mask=pset_valid_mask,
918
+ projection_indices=indices_bc.values,
919
+ input_valid_mask=pset_valid_mask_values,
872
920
  )
873
921
  # TODO: we may need to allow for unweighted/weighted means here by
874
922
  # dividing pointing_projected_values by some binned weights.
@@ -879,7 +927,7 @@ class AbstractSkyMap(ABC):
879
927
  valid_map_mask = pset_valid_mask[matched_indices_pull]
880
928
  # We know that there will only be one value per sky map pixel,
881
929
  # so we can use the matched indices directly
882
- pointing_projected_values = raveled_pset_data[
930
+ pointing_projected_values = raveled_pset_data.values[
883
931
  ..., matched_indices_pull[valid_map_mask]
884
932
  ]
885
933
  # TODO: we may need to allow for unweighted/weighted means here by
@@ -889,10 +937,6 @@ class AbstractSkyMap(ABC):
889
937
  self.data_1d[value_key].values[..., valid_map_mask] += (
890
938
  pointing_projected_values
891
939
  )
892
- else:
893
- raise NotImplementedError(
894
- "Only PUSH and PULL index matching methods are supported."
895
- )
896
940
 
897
941
  # TODO: The max epoch needs to include the pset duration. Right now it
898
942
  # is just capturing the start epoch. See issue #1747
@@ -1120,7 +1164,10 @@ class RectangularSkyMap(AbstractSkyMap):
1120
1164
  el_points = self.sky_grid.el_grid.ravel()
1121
1165
 
1122
1166
  # 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))
1167
+ self.az_el_points = xr.DataArray(
1168
+ np.column_stack((az_points, el_points)),
1169
+ dims=[CoordNames.GENERIC_PIXEL.value, CoordNames.AZ_EL_VECTOR.value],
1170
+ )
1124
1171
 
1125
1172
  # Calculate solid angles of each pixel in the map grid in units of steradians
1126
1173
  self.solid_angle_grid = spatial_utils.build_solid_angle_map(
@@ -1218,12 +1265,13 @@ class RectangularSkyMap(AbstractSkyMap):
1218
1265
  coords={**self.non_spatial_coords, **self.spatial_coords},
1219
1266
  )
1220
1267
 
1221
- def build_cdf_dataset(
1268
+ def build_cdf_dataset( # noqa: PLR0912
1222
1269
  self,
1223
1270
  instrument: str,
1224
1271
  level: str,
1225
1272
  descriptor: str,
1226
1273
  sensor: str | None = None,
1274
+ drop_vars_with_no_attributes: bool = True,
1227
1275
  ) -> xr.Dataset:
1228
1276
  """
1229
1277
  Format the data into a xarray.Dataset and add required CDF variables.
@@ -1238,6 +1286,12 @@ class RectangularSkyMap(AbstractSkyMap):
1238
1286
  Descriptor for filename.
1239
1287
  sensor : str, optional
1240
1288
  Sensor number "45" or "90".
1289
+ drop_vars_with_no_attributes : bool, optional
1290
+ Default behavior is to drop any dataset variables that don't have
1291
+ attributes defined in the CDF attribute manager. This ensures that
1292
+ the output CDF doesn't have any of the intermedeiate variables left
1293
+ over from computations. Sometimes, it is useful to output the
1294
+ intermedeiate variables. To do so, set this to False.
1241
1295
 
1242
1296
  Returns
1243
1297
  -------
@@ -1267,7 +1321,7 @@ class RectangularSkyMap(AbstractSkyMap):
1267
1321
  if ("L2" in name)
1268
1322
  ]
1269
1323
  l2_coords.append(CoordNames.TIME.value)
1270
- for map_coord in cdf_ds.dims.keys():
1324
+ for map_coord in cdf_ds.dims:
1271
1325
  if map_coord not in l2_coords:
1272
1326
  cdf_ds = cdf_ds.drop_dims(map_coord)
1273
1327
 
@@ -1341,13 +1395,18 @@ class RectangularSkyMap(AbstractSkyMap):
1341
1395
  variable_name=name,
1342
1396
  check_schema=check_schema,
1343
1397
  )
1344
- except KeyError as e:
1345
- raise KeyError(
1346
- f"Attributes for variable {name} not found in "
1347
- f"loaded variable attributes."
1348
- ) from e
1349
-
1350
- cdf_ds[name].attrs.update(var_attrs)
1398
+ cdf_ds[name].attrs.update(var_attrs)
1399
+ except KeyError:
1400
+ if drop_vars_with_no_attributes:
1401
+ logger.debug(
1402
+ f"Dropping variable '{name}' that has no attributes defined."
1403
+ )
1404
+ cdf_ds = cdf_ds.drop_vars(name)
1405
+ else:
1406
+ logger.debug(
1407
+ f"Variable '{name}' has no attributes defined. It will "
1408
+ f"be included in the output dataset with no attributes."
1409
+ )
1351
1410
 
1352
1411
  # Manually adjust epoch attributes
1353
1412
  cdf_ds["epoch"].attrs.update(
@@ -1431,7 +1490,10 @@ class HealpixSkyMap(AbstractSkyMap):
1431
1490
  nside=nside, ipix=np.arange(hp.nside2npix(nside)), nest=nested, lonlat=True
1432
1491
  )
1433
1492
  # Stack so axis 0 is different pixels, and axis 1 is (az, el) of the pixel
1434
- self.az_el_points = np.column_stack((pixel_az, pixel_el))
1493
+ self.az_el_points = xr.DataArray(
1494
+ np.column_stack((pixel_az, pixel_el)),
1495
+ dims=[CoordNames.GENERIC_PIXEL.value, CoordNames.AZ_EL_VECTOR.value],
1496
+ )
1435
1497
 
1436
1498
  self.spatial_coords = {
1437
1499
  CoordNames.HEALPIX_INDEX.value: xr.DataArray(
@@ -1767,7 +1829,7 @@ class HealpixSkyMap(AbstractSkyMap):
1767
1829
  value_array=healpix_values_array,
1768
1830
  max_subdivision_depth=max_subdivision_depth,
1769
1831
  )
1770
- for lon_lat in rect_map.az_el_points
1832
+ for lon_lat in rect_map.az_el_points.values
1771
1833
  ]
1772
1834
 
1773
1835
  # 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"