imap-processing 0.18.0__py3-none-any.whl → 0.19.0__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 (104) 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_l1a_variable_attrs.yaml +301 -274
  4. imap_processing/cdf/config/imap_codice_l1b_variable_attrs.yaml +28 -28
  5. imap_processing/cdf/config/imap_codice_l2_variable_attrs.yaml +1044 -203
  6. imap_processing/cdf/config/imap_constant_attrs.yaml +4 -2
  7. imap_processing/cdf/config/imap_glows_l1b_variable_attrs.yaml +12 -0
  8. imap_processing/cdf/config/imap_hi_global_cdf_attrs.yaml +5 -0
  9. imap_processing/cdf/config/imap_hit_global_cdf_attrs.yaml +10 -4
  10. imap_processing/cdf/config/imap_idex_l2a_variable_attrs.yaml +33 -4
  11. imap_processing/cdf/config/imap_idex_l2b_variable_attrs.yaml +8 -91
  12. imap_processing/cdf/config/imap_idex_l2c_variable_attrs.yaml +106 -16
  13. imap_processing/cdf/config/imap_lo_l1a_variable_attrs.yaml +4 -15
  14. imap_processing/cdf/config/imap_lo_l1c_variable_attrs.yaml +189 -98
  15. imap_processing/cdf/config/imap_mag_global_cdf_attrs.yaml +85 -2
  16. imap_processing/cdf/config/imap_mag_l1c_variable_attrs.yaml +24 -1
  17. imap_processing/cdf/config/imap_ultra_l1b_variable_attrs.yaml +12 -4
  18. imap_processing/cdf/config/imap_ultra_l1c_variable_attrs.yaml +50 -7
  19. imap_processing/cli.py +95 -41
  20. imap_processing/codice/codice_l1a.py +131 -31
  21. imap_processing/codice/codice_l2.py +118 -10
  22. imap_processing/codice/constants.py +740 -595
  23. imap_processing/decom.py +1 -4
  24. imap_processing/ena_maps/ena_maps.py +32 -25
  25. imap_processing/ena_maps/utils/naming.py +8 -2
  26. imap_processing/glows/ancillary/imap_glows_exclusions-by-instr-team_20250923_v002.dat +10 -0
  27. imap_processing/glows/ancillary/imap_glows_map-of-excluded-regions_20250923_v002.dat +393 -0
  28. imap_processing/glows/ancillary/imap_glows_map-of-uv-sources_20250923_v002.dat +593 -0
  29. imap_processing/glows/ancillary/imap_glows_pipeline_settings_20250923_v002.json +54 -0
  30. imap_processing/glows/ancillary/imap_glows_suspected-transients_20250923_v002.dat +10 -0
  31. imap_processing/glows/l1b/glows_l1b.py +99 -9
  32. imap_processing/glows/l1b/glows_l1b_data.py +350 -38
  33. imap_processing/glows/l2/glows_l2.py +11 -0
  34. imap_processing/hi/hi_l1a.py +124 -3
  35. imap_processing/hi/hi_l1b.py +154 -71
  36. imap_processing/hi/hi_l2.py +84 -51
  37. imap_processing/hi/utils.py +153 -8
  38. imap_processing/hit/l0/constants.py +3 -0
  39. imap_processing/hit/l0/decom_hit.py +3 -6
  40. imap_processing/hit/l1a/hit_l1a.py +311 -21
  41. imap_processing/hit/l1b/hit_l1b.py +54 -126
  42. imap_processing/hit/l2/hit_l2.py +6 -6
  43. imap_processing/ialirt/calculate_ingest.py +219 -0
  44. imap_processing/ialirt/constants.py +12 -2
  45. imap_processing/ialirt/generate_coverage.py +15 -2
  46. imap_processing/ialirt/l0/ialirt_spice.py +5 -2
  47. imap_processing/ialirt/l0/parse_mag.py +293 -42
  48. imap_processing/ialirt/l0/process_hit.py +5 -3
  49. imap_processing/ialirt/l0/process_swapi.py +41 -25
  50. imap_processing/ialirt/process_ephemeris.py +70 -14
  51. imap_processing/idex/idex_l0.py +2 -2
  52. imap_processing/idex/idex_l1a.py +2 -3
  53. imap_processing/idex/idex_l1b.py +2 -3
  54. imap_processing/idex/idex_l2a.py +130 -4
  55. imap_processing/idex/idex_l2b.py +158 -143
  56. imap_processing/idex/idex_utils.py +1 -3
  57. imap_processing/lo/l0/lo_science.py +25 -24
  58. imap_processing/lo/l1b/lo_l1b.py +3 -3
  59. imap_processing/lo/l1c/lo_l1c.py +116 -50
  60. imap_processing/lo/l2/lo_l2.py +29 -29
  61. imap_processing/lo/lo_ancillary.py +55 -0
  62. imap_processing/mag/l1a/mag_l1a.py +1 -0
  63. imap_processing/mag/l1a/mag_l1a_data.py +26 -0
  64. imap_processing/mag/l1b/mag_l1b.py +3 -2
  65. imap_processing/mag/l1c/interpolation_methods.py +14 -15
  66. imap_processing/mag/l1c/mag_l1c.py +23 -6
  67. imap_processing/mag/l1d/mag_l1d.py +57 -14
  68. imap_processing/mag/l1d/mag_l1d_data.py +167 -30
  69. imap_processing/mag/l2/mag_l2_data.py +10 -2
  70. imap_processing/quality_flags.py +9 -1
  71. imap_processing/spice/geometry.py +76 -33
  72. imap_processing/spice/pointing_frame.py +0 -6
  73. imap_processing/spice/repoint.py +29 -2
  74. imap_processing/spice/spin.py +28 -8
  75. imap_processing/spice/time.py +12 -22
  76. imap_processing/swapi/l1/swapi_l1.py +10 -4
  77. imap_processing/swapi/l2/swapi_l2.py +15 -17
  78. imap_processing/swe/l1b/swe_l1b.py +1 -2
  79. imap_processing/ultra/constants.py +1 -24
  80. imap_processing/ultra/l0/ultra_utils.py +9 -11
  81. imap_processing/ultra/l1a/ultra_l1a.py +1 -2
  82. imap_processing/ultra/l1b/cullingmask.py +6 -3
  83. imap_processing/ultra/l1b/de.py +81 -23
  84. imap_processing/ultra/l1b/extendedspin.py +13 -10
  85. imap_processing/ultra/l1b/lookup_utils.py +281 -28
  86. imap_processing/ultra/l1b/quality_flag_filters.py +10 -1
  87. imap_processing/ultra/l1b/ultra_l1b_culling.py +161 -3
  88. imap_processing/ultra/l1b/ultra_l1b_extended.py +253 -47
  89. imap_processing/ultra/l1c/helio_pset.py +97 -24
  90. imap_processing/ultra/l1c/l1c_lookup_utils.py +256 -0
  91. imap_processing/ultra/l1c/spacecraft_pset.py +83 -16
  92. imap_processing/ultra/l1c/ultra_l1c.py +6 -2
  93. imap_processing/ultra/l1c/ultra_l1c_culling.py +85 -0
  94. imap_processing/ultra/l1c/ultra_l1c_pset_bins.py +385 -277
  95. imap_processing/ultra/l2/ultra_l2.py +0 -1
  96. imap_processing/ultra/utils/ultra_l1_utils.py +28 -3
  97. imap_processing/utils.py +3 -4
  98. {imap_processing-0.18.0.dist-info → imap_processing-0.19.0.dist-info}/METADATA +2 -2
  99. {imap_processing-0.18.0.dist-info → imap_processing-0.19.0.dist-info}/RECORD +102 -95
  100. imap_processing/idex/idex_l2c.py +0 -84
  101. imap_processing/spice/kernels.py +0 -187
  102. {imap_processing-0.18.0.dist-info → imap_processing-0.19.0.dist-info}/LICENSE +0 -0
  103. {imap_processing-0.18.0.dist-info → imap_processing-0.19.0.dist-info}/WHEEL +0 -0
  104. {imap_processing-0.18.0.dist-info → imap_processing-0.19.0.dist-info}/entry_points.txt +0 -0
@@ -1,7 +1,6 @@
1
1
  """MAG L1C processing module."""
2
2
 
3
3
  import logging
4
- from typing import Optional
5
4
 
6
5
  import numpy as np
7
6
  import xarray as xr
@@ -125,6 +124,23 @@ def mag_l1c(
125
124
  try:
126
125
  global_attributes["is_mago"] = normal_mode_dataset.attrs["is_mago"]
127
126
  global_attributes["is_active"] = normal_mode_dataset.attrs["is_active"]
127
+
128
+ # Check if all vectors are primary in both normal and burst datasets
129
+ is_mago = normal_mode_dataset.attrs.get("is_mago", "False") == "True"
130
+ normal_all_primary = normal_mode_dataset.attrs.get("all_vectors_primary", False)
131
+
132
+ # Default for missing burst dataset: 1 if MAGO (expected primary), 0 if MAGI
133
+ burst_all_primary = is_mago
134
+ if burst_mode_dataset is not None:
135
+ burst_all_primary = burst_mode_dataset.attrs.get(
136
+ "all_vectors_primary", False
137
+ )
138
+
139
+ # Both datasets must have all vectors primary for the combined result to be True
140
+ global_attributes["all_vectors_primary"] = (
141
+ normal_all_primary and burst_all_primary
142
+ )
143
+
128
144
  global_attributes["missing_sequences"] = normal_mode_dataset.attrs[
129
145
  "missing_sequences"
130
146
  ]
@@ -162,8 +178,9 @@ def mag_l1c(
162
178
  output_core_dims=[[]],
163
179
  vectorize=True,
164
180
  )
165
- # output_dataset['vector_magnitude'].attrs =
166
- # attribute_manager.get_variable_attributes("vector_magnitude_attrs")
181
+ output_dataset[
182
+ "vector_magnitude"
183
+ ].attrs = attribute_manager.get_variable_attributes("vector_magnitude_attrs")
167
184
 
168
185
  output_dataset["compression_flags"] = xr.DataArray(
169
186
  completed_timeline[:, 6:8],
@@ -176,14 +193,14 @@ def mag_l1c(
176
193
  completed_timeline[:, 5],
177
194
  name="generated_flag",
178
195
  dims=["epoch"],
179
- # attrs=attribute_manager.get_variable_attributes("generated_flag_attrs"),
196
+ attrs=attribute_manager.get_variable_attributes("generated_flag_attrs"),
180
197
  )
181
198
 
182
199
  return output_dataset
183
200
 
184
201
 
185
202
  def select_datasets(
186
- first_input_dataset: xr.Dataset, second_input_dataset: Optional[xr.Dataset] = None
203
+ first_input_dataset: xr.Dataset, second_input_dataset: xr.Dataset | None = None
187
204
  ) -> tuple[xr.Dataset, xr.Dataset]:
188
205
  """
189
206
  Given one or two datasets, assign one to norm and one to burst.
@@ -503,7 +520,7 @@ def generate_timeline(epoch_data: np.ndarray, gaps: np.ndarray) -> np.ndarray:
503
520
 
504
521
 
505
522
  def find_all_gaps(
506
- epoch_data: np.ndarray, vecsec_dict: Optional[dict] = None
523
+ epoch_data: np.ndarray, vecsec_dict: dict | None = None
507
524
  ) -> np.ndarray:
508
525
  """
509
526
  Find all the gaps in the epoch data.
@@ -9,7 +9,7 @@ from imap_processing.mag.l1d.mag_l1d_data import MagL1d, MagL1dConfiguration
9
9
  from imap_processing.mag.l2.mag_l2_data import ValidFrames
10
10
 
11
11
 
12
- def mag_l1d(
12
+ def mag_l1d( # noqa: PLR0912
13
13
  science_data: list[xr.Dataset],
14
14
  calibration_dataset: xr.Dataset,
15
15
  day_to_process: np.datetime64,
@@ -45,16 +45,18 @@ def mag_l1d(
45
45
  input_mago_burst = None
46
46
  for dataset in science_data:
47
47
  source = dataset.attrs.get("Logical_source", "")
48
- if "norm-magi" in source:
49
- input_magi_norm = dataset
50
- elif "norm-mago" in source:
51
- input_mago_norm = dataset
52
- elif "burst-magi" in source:
53
- input_magi_burst = dataset
54
- elif "burst-mago" in source:
55
- input_mago_burst = dataset
56
- else:
57
- raise ValueError(f"Input data has invalid logical source {source}")
48
+ instrument_mode = source.split("_")[-1]
49
+ match instrument_mode:
50
+ case "norm-magi":
51
+ input_magi_norm = dataset
52
+ case "norm-mago":
53
+ input_mago_norm = dataset
54
+ case "burst-magi":
55
+ input_magi_burst = dataset
56
+ case "burst-mago":
57
+ input_mago_burst = dataset
58
+ case _:
59
+ raise ValueError(f"Input data has invalid logical source {source}")
58
60
 
59
61
  if input_magi_norm is None or input_mago_norm is None:
60
62
  raise ValueError(
@@ -72,8 +74,9 @@ def mag_l1d(
72
74
  mago_vectors = input_mago_norm["vectors"].data[:, :3]
73
75
  magi_vectors = input_magi_norm["vectors"].data[:, :3]
74
76
 
75
- # TODO: verify that MAGO is primary sensor for all vectors before applying
76
- # gradiometry
77
+ # Verify that MAGO is primary sensor for all vectors before applying gradiometry
78
+ if not input_mago_norm.attrs.get("all_vectors_primary", 1):
79
+ config.apply_gradiometry = False
77
80
 
78
81
  # TODO: L1D attributes
79
82
  attributes = ImapCdfAttributes()
@@ -95,12 +98,21 @@ def mag_l1d(
95
98
  day=day,
96
99
  )
97
100
 
101
+ # Nominally, this is expected to create MAGO data. However, if the configuration
102
+ # setting for always_output_mago is set to False, it will create MAGI data.
103
+
98
104
  l1d_norm.rotate_frame(ValidFrames.SRF)
99
105
  norm_srf_dataset = l1d_norm.generate_dataset(attributes, day_to_process)
100
106
  l1d_norm.rotate_frame(ValidFrames.DSRF)
101
107
  norm_dsrf_dataset = l1d_norm.generate_dataset(attributes, day_to_process)
108
+ l1d_norm.rotate_frame(ValidFrames.GSE)
109
+ norm_gse_dataset = l1d_norm.generate_dataset(attributes, day_to_process)
110
+ l1d_norm.rotate_frame(ValidFrames.RTN)
111
+ norm_rtn_dataset = l1d_norm.generate_dataset(attributes, day_to_process)
102
112
  output_datasets.append(norm_srf_dataset)
103
113
  output_datasets.append(norm_dsrf_dataset)
114
+ output_datasets.append(norm_gse_dataset)
115
+ output_datasets.append(norm_rtn_dataset)
104
116
 
105
117
  if input_mago_burst is not None and input_magi_burst is not None:
106
118
  # If burst data is provided, use it to create the burst L1d dataset
@@ -122,12 +134,43 @@ def mag_l1d(
122
134
  spin_offsets=l1d_norm.spin_offsets,
123
135
  day=day,
124
136
  )
137
+
138
+ # TODO: frame specific attributes may be required
125
139
  l1d_burst.rotate_frame(ValidFrames.SRF)
126
140
  burst_srf_dataset = l1d_burst.generate_dataset(attributes, day_to_process)
127
141
  l1d_burst.rotate_frame(ValidFrames.DSRF)
128
142
  burst_dsrf_dataset = l1d_burst.generate_dataset(attributes, day_to_process)
143
+ l1d_burst.rotate_frame(ValidFrames.GSE)
144
+ burst_gse_dataset = l1d_burst.generate_dataset(attributes, day_to_process)
145
+ l1d_burst.rotate_frame(ValidFrames.RTN)
146
+ burst_rtn_dataset = l1d_burst.generate_dataset(attributes, day_to_process)
129
147
  output_datasets.append(burst_srf_dataset)
130
148
  output_datasets.append(burst_dsrf_dataset)
149
+ output_datasets.append(burst_gse_dataset)
150
+ output_datasets.append(burst_rtn_dataset)
151
+
152
+ # Output ancillary files
153
+ # Add spin offsets dataset from normal mode processing
154
+ if l1d_norm.spin_offsets is not None:
155
+ spin_offset_dataset = l1d_norm.generate_spin_offset_dataset()
156
+ spin_offset_dataset.attrs["Logical_source"] = "imap_mag_l1d-spin-offsets"
157
+ output_datasets.append(spin_offset_dataset)
158
+
159
+ # Add gradiometry offsets dataset if gradiometry was applied
160
+ if l1d_norm.config.apply_gradiometry and hasattr(l1d_norm, "gradiometry_offsets"):
161
+ gradiometry_dataset = l1d_norm.gradiometry_offsets.copy()
162
+ gradiometry_dataset.attrs["Logical_source"] = (
163
+ "imap_mag_l1d-gradiometry-offsets-norm"
164
+ )
165
+ output_datasets.append(gradiometry_dataset)
166
+
167
+ # Also add burst gradiometry offsets if burst data was processed
168
+ if input_mago_burst is not None and input_magi_burst is not None:
169
+ if hasattr(l1d_burst, "gradiometry_offsets"):
170
+ burst_gradiometry_dataset = l1d_burst.gradiometry_offsets.copy()
171
+ burst_gradiometry_dataset.attrs["Logical_source"] = (
172
+ "imap_mag_l1d-gradiometry-offsets-burst"
173
+ )
174
+ output_datasets.append(burst_gradiometry_dataset)
131
175
 
132
- # TODO: Output ancillary files
133
176
  return output_datasets
@@ -6,12 +6,15 @@ from dataclasses import InitVar, dataclass
6
6
  import numpy as np
7
7
  import xarray as xr
8
8
 
9
+ from imap_processing.cdf.imap_cdf_manager import ImapCdfAttributes
10
+ from imap_processing.mag import imap_mag_sdc_configuration_v001 as configuration
9
11
  from imap_processing.mag.constants import FILLVAL, DataMode
10
12
  from imap_processing.mag.l1c.interpolation_methods import linear
11
13
  from imap_processing.mag.l2.mag_l2 import retrieve_matrix_from_l2_calibration
12
14
  from imap_processing.mag.l2.mag_l2_data import MagL2L1dBase, ValidFrames
13
15
  from imap_processing.spice import spin
14
16
  from imap_processing.spice.geometry import frame_transform
17
+ from imap_processing.spice.time import ttj2000ns_to_met
15
18
 
16
19
 
17
20
  @dataclass
@@ -57,7 +60,7 @@ class MagL1dConfiguration:
57
60
  mago_calibration: np.ndarray
58
61
  magi_calibration: np.ndarray
59
62
  spin_count_calibration: int
60
- quality_flag_threshold: np.float64
63
+ quality_flag_threshold: float
61
64
  spin_average_application_factor: np.float64
62
65
  gradiometer_factor: np.ndarray
63
66
  apply_gradiometry: bool = True
@@ -144,8 +147,6 @@ class MagL1d(MagL2L1dBase): # type: ignore[misc]
144
147
  truncate the data to exactly 24 hours.
145
148
  """
146
149
 
147
- # TODO Quality flags
148
- # TODO generate and output ancillary files
149
150
  magi_vectors: np.ndarray
150
151
  magi_range: np.ndarray
151
152
  magi_epoch: np.ndarray
@@ -182,7 +183,7 @@ class MagL1d(MagL2L1dBase): # type: ignore[misc]
182
183
 
183
184
  self.vectors = self.apply_spin_offsets(
184
185
  self.spin_offsets,
185
- self.epoch,
186
+ self.epoch, # type: ignore[has-type]
186
187
  self.vectors,
187
188
  self.config.spin_average_application_factor,
188
189
  )
@@ -198,7 +199,11 @@ class MagL1d(MagL2L1dBase): # type: ignore[misc]
198
199
 
199
200
  if self.config.apply_gradiometry:
200
201
  self.gradiometry_offsets = self.calculate_gradiometry_offsets(
201
- self.vectors, self.epoch, self.magi_vectors, self.magi_epoch
202
+ self.vectors,
203
+ self.epoch, # type: ignore[has-type]
204
+ self.magi_vectors,
205
+ self.magi_epoch,
206
+ self.config.quality_flag_threshold,
202
207
  )
203
208
  self.vectors = self.apply_gradiometry_offsets(
204
209
  self.gradiometry_offsets, self.vectors, self.config.gradiometer_factor
@@ -207,6 +212,54 @@ class MagL1d(MagL2L1dBase): # type: ignore[misc]
207
212
  self.magnitude = MagL2L1dBase.calculate_magnitude(vectors=self.vectors)
208
213
  self.is_l1d = True
209
214
 
215
+ def generate_dataset(
216
+ self,
217
+ attribute_manager: ImapCdfAttributes,
218
+ day: np.datetime64,
219
+ ) -> xr.Dataset:
220
+ """
221
+ Generate an xarray dataset from the dataclass.
222
+
223
+ This overrides the parent method to conditionally swap MAGO/MAGI data
224
+ based on the always_output_mago configuration setting.
225
+
226
+ Parameters
227
+ ----------
228
+ attribute_manager : ImapCdfAttributes
229
+ CDF attributes object for the correct level.
230
+ day : np.datetime64
231
+ The 24 hour day to process, as a numpy datetime format.
232
+
233
+ Returns
234
+ -------
235
+ xr.Dataset
236
+ Complete dataset ready to write to CDF file.
237
+ """
238
+ always_output_mago = configuration.ALWAYS_OUTPUT_MAGO
239
+
240
+ if not always_output_mago:
241
+ # Swap vectors and epochs to use MAGI data instead of MAGO
242
+ original_vectors: np.ndarray = self.vectors.copy()
243
+ original_epoch: np.ndarray = self.epoch.copy() # type: ignore[has-type]
244
+ original_range: np.ndarray = self.range.copy() # type: ignore[has-type]
245
+
246
+ self.vectors = self.magi_vectors # type: ignore[no-redef]
247
+ self.epoch = self.magi_epoch # type: ignore[no-redef]
248
+ self.range = self.magi_range # type: ignore[no-redef]
249
+
250
+ # Call parent generate_dataset method
251
+ dataset = super().generate_dataset(attribute_manager, day)
252
+
253
+ # Restore original vectors for any further processing
254
+ self.vectors = original_vectors
255
+ self.epoch = original_epoch
256
+ self.range = original_range
257
+ else:
258
+ # Use MAGO data (default behavior)
259
+ dataset = super().generate_dataset(attribute_manager, day)
260
+
261
+ return dataset
262
+
210
263
  def rotate_frame(self, end_frame: ValidFrames) -> None:
211
264
  """
212
265
  Rotate the vectors to the desired frame.
@@ -312,7 +365,7 @@ class MagL1d(MagL2L1dBase): # type: ignore[misc]
312
365
  The offset vector, shape (4,) where the last element is unchanged.
313
366
  """
314
367
  # Offsets are in shape (sensor, range, axis)
315
- updated_vector = input_vector.copy().astype(np.int64)
368
+ updated_vector = input_vector.copy()
316
369
  rng = int(input_vector[3])
317
370
  x_y_z = input_vector[:3]
318
371
  updated_vector[:3] = x_y_z - offsets[int(is_magi), rng, :]
@@ -348,64 +401,132 @@ class MagL1d(MagL2L1dBase): # type: ignore[misc]
348
401
  "Spin offsets can only be calculated in NORM mode and SRF frame."
349
402
  )
350
403
 
351
- # TODO: get the spin numbers which correspond to the epoch values for output
352
- sc_spin_phase: np.ndarray = spin.get_spacecraft_spin_phase(self.epoch) # type: ignore
404
+ epoch_met = ttj2000ns_to_met(self.epoch)
405
+ sc_spin_phase = spin.get_spacecraft_spin_phase(epoch_met)
353
406
  # mark vectors as nan where they are nan in sc_spin_phase
354
407
  vectors = self.vectors.copy().astype(np.float64)
355
408
 
356
409
  vectors[np.isnan(sc_spin_phase), :] = np.nan
357
410
 
358
- # TODO: currently fully skipping spins with no valid data (not including
359
- # them in the averaging OR IN SPIN COUNTING!) is this correct?
360
-
361
411
  # first timestamp where spin phase is less than the previous value
362
412
  # this is when the spin crosses zero
363
413
  spin_starts = np.where(np.diff(sc_spin_phase) < 0)[0] + 1
364
414
 
365
- # if the value switches from nan to a number, that is also a spin start (for an
366
- # invalid spin)
367
- nan_to_number = (
368
- np.where(np.isnan(sc_spin_phase[:-1]) & ~np.isnan(sc_spin_phase[1:]))[0] + 1
369
- )
415
+ # if the value switches from nan to a number, or from a number to nan, that
416
+ # is also a spin start
417
+ nan_to_number = np.where(np.diff(np.isnan(sc_spin_phase)) != 0)[0] + 1
370
418
 
371
419
  # find the places spins start while skipping over invalid or missing data
372
420
  # (marked as nan by get_spacecraft_spin_phase)
373
421
  spin_starts = np.sort(np.concatenate((spin_starts, nan_to_number)))
374
422
 
423
+ # Get the expected spin period from the spin table
424
+ # Convert to nanoseconds to match epoch
425
+ spin_data = spin.get_spin_data()
426
+ # Use the median spin period as the expected value
427
+ expected_spin = np.median(spin_data["spin_period_sec"]) * 1e9
428
+
429
+ paired_nans = nan_to_number.reshape(-1, 2)
430
+
431
+ for start_of_gap, end_of_gap in paired_nans:
432
+ # in nan_to_number, we have the start and end for every nan gap
433
+ # if this gap spans more than 1 spin period, we need to insert
434
+ # additional spin_starts into spin_starts.
435
+
436
+ gap_start_time = self.epoch[start_of_gap]
437
+ gap_end_time = self.epoch[end_of_gap]
438
+
439
+ # Calculate the number of spins in this gap
440
+ number_of_spins = int((gap_end_time - gap_start_time) // expected_spin)
441
+ if number_of_spins > 1:
442
+ # Insert new spin starts into spin_starts
443
+ for i in range(1, number_of_spins):
444
+ estimated_start = gap_start_time + i * expected_spin
445
+ new_spin_index = (np.abs(self.epoch - estimated_start)).argmin()
446
+
447
+ spin_starts = np.append(spin_starts, new_spin_index)
448
+
449
+ # Now spin_starts contains all the indices where spins begin, including
450
+ # estimating skipped or missing spins.
451
+ spin_starts = np.sort(spin_starts)
452
+
375
453
  chunk_start = 0
376
454
  offset_epochs = []
377
- x_avg = []
378
- y_avg = []
455
+ x_avg_calcs: list[np.float64] = []
456
+ y_avg_calcs: list[np.float64] = []
457
+ validity_start_times = []
458
+ validity_end_times = []
459
+ start_spin_counters = []
460
+ end_spin_counters = []
461
+
379
462
  while chunk_start < len(spin_starts):
380
463
  # Take self.spin_count_calibration number of spins and put them into a chunk
381
464
  chunk_indices = spin_starts[
382
465
  chunk_start : chunk_start + self.config.spin_count_calibration + 1
383
466
  ]
384
- chunk_start = chunk_start + self.config.spin_count_calibration
385
-
386
- # If we are in the end of the chunk, just grab all remaining data
387
- if chunk_start >= len(spin_starts):
388
- chunk_indices = np.append(chunk_indices, len(self.epoch))
467
+ chunk_start_idx = chunk_start
389
468
 
390
469
  chunk_vectors = self.vectors[chunk_indices[0] : chunk_indices[-1]]
391
470
  chunk_epoch = self.epoch[chunk_indices[0] : chunk_indices[-1]]
392
471
 
472
+ # Check if more than half of the chunk data is NaN before processing
473
+ x_valid_count: int = int(np.sum(~np.isnan(chunk_vectors[:, 0])))
474
+ y_valid_count: int = int(np.sum(~np.isnan(chunk_vectors[:, 1])))
475
+ total_points = len(chunk_vectors)
476
+
393
477
  # average the x and y axes (z is fixed, as the spin axis)
394
- # TODO: is z the correct axis here?
395
478
  avg_x = np.nanmean(chunk_vectors[:, 0])
396
479
  avg_y = np.nanmean(chunk_vectors[:, 1])
397
480
 
481
+ # Skip chunk if more than half of x or y data is NaN, or if we have less
482
+ # than half a spin.
483
+ # in this case, we should reuse the previous averages.
484
+ if (
485
+ x_valid_count <= total_points / 2
486
+ or y_valid_count <= total_points / 2
487
+ or total_points <= self.config.spin_count_calibration / 2
488
+ ):
489
+ avg_x = x_avg_calcs[-1] if x_avg_calcs else np.float64(FILLVAL)
490
+ avg_y = y_avg_calcs[-1] if y_avg_calcs else np.float64(FILLVAL)
491
+
398
492
  if not np.isnan(avg_x) and not np.isnan(avg_y):
399
493
  offset_epochs.append(chunk_epoch[0])
400
- x_avg.append(avg_x)
401
- y_avg.append(avg_y)
494
+ x_avg_calcs.append(avg_x)
495
+ y_avg_calcs.append(avg_y)
496
+
497
+ # Add validity time range for this chunk
498
+ validity_start_times.append(chunk_epoch[0])
499
+ validity_end_times.append(chunk_epoch[-1])
500
+
501
+ # Add spin counter information
502
+ start_spin_counters.append(chunk_start_idx)
503
+ end_spin_counters.append(
504
+ min(
505
+ chunk_start_idx + self.config.spin_count_calibration - 1,
506
+ len(spin_starts) - 1,
507
+ )
508
+ )
509
+
510
+ chunk_start = chunk_start + self.config.spin_count_calibration
402
511
 
403
512
  spin_epoch_dataarray = xr.DataArray(np.array(offset_epochs))
404
513
 
405
514
  spin_offsets = xr.Dataset(coords={"epoch": spin_epoch_dataarray})
406
515
 
407
- spin_offsets["x_offset"] = xr.DataArray(np.array(x_avg), dims=["epoch"])
408
- spin_offsets["y_offset"] = xr.DataArray(np.array(y_avg), dims=["epoch"])
516
+ spin_offsets["x_offset"] = xr.DataArray(np.array(x_avg_calcs), dims=["epoch"])
517
+ spin_offsets["y_offset"] = xr.DataArray(np.array(y_avg_calcs), dims=["epoch"])
518
+ spin_offsets["validity_start_time"] = xr.DataArray(
519
+ np.array(validity_start_times), dims=["epoch"]
520
+ )
521
+ spin_offsets["validity_end_time"] = xr.DataArray(
522
+ np.array(validity_end_times), dims=["epoch"]
523
+ )
524
+ spin_offsets["start_spin_counter"] = xr.DataArray(
525
+ np.array(start_spin_counters), dims=["epoch"]
526
+ )
527
+ spin_offsets["end_spin_counter"] = xr.DataArray(
528
+ np.array(end_spin_counters), dims=["epoch"]
529
+ )
409
530
 
410
531
  return spin_offsets
411
532
 
@@ -462,7 +583,7 @@ class MagL1d(MagL2L1dBase): # type: ignore[misc]
462
583
  if spin_offsets is None:
463
584
  raise ValueError("No spin offsets calculated to apply.")
464
585
 
465
- output_vectors = np.full(vectors.shape, FILLVAL, dtype=np.int64)
586
+ output_vectors = np.full(vectors.shape, FILLVAL, dtype=np.float64)
466
587
 
467
588
  for index in range(spin_offsets["epoch"].data.shape[0] - 1):
468
589
  timestamp = spin_offsets["epoch"].data[index]
@@ -483,7 +604,6 @@ class MagL1d(MagL2L1dBase): # type: ignore[misc]
483
604
  if not np.any(mask):
484
605
  continue
485
606
 
486
- # TODO: should vectors be a float?
487
607
  x_offset = (
488
608
  spin_offsets["x_offset"].data[index] * spin_average_application_factor
489
609
  )
@@ -504,6 +624,7 @@ class MagL1d(MagL2L1dBase): # type: ignore[misc]
504
624
  mago_epoch: np.ndarray,
505
625
  magi_vectors: np.ndarray,
506
626
  magi_epoch: np.ndarray,
627
+ quality_flag_threshold: float = np.inf,
507
628
  ) -> xr.Dataset:
508
629
  """
509
630
  Calculate the gradiometry offsets between MAGo and MAGi.
@@ -525,6 +646,10 @@ class MagL1d(MagL2L1dBase): # type: ignore[misc]
525
646
  The MAGi vectors, shape (N, 3).
526
647
  magi_epoch : np.ndarray
527
648
  The MAGi epoch values, shape (N,).
649
+ quality_flag_threshold : np.float64, optional
650
+ Threshold for quality flags. If the magnitude of gradiometer offset
651
+ exceeds this threshold, quality flag will be set. Default is np.inf
652
+ (no quality flags set).
528
653
 
529
654
  Returns
530
655
  -------
@@ -532,6 +657,8 @@ class MagL1d(MagL2L1dBase): # type: ignore[misc]
532
657
  The gradiometer offsets dataset, with variables:
533
658
  - epoch: the timestamp of the MAGo data
534
659
  - gradiometer_offsets: the offset values (MAGi - MAGo) for each axis
660
+ - gradiometer_offset_magnitude: magnitude of the offset vector
661
+ - quality_flags: quality flags (1 if magnitude > threshold, 0 otherwise)
535
662
  """
536
663
  aligned_magi = linear(
537
664
  magi_vectors,
@@ -541,10 +668,20 @@ class MagL1d(MagL2L1dBase): # type: ignore[misc]
541
668
 
542
669
  diff = aligned_magi - mago_vectors
543
670
 
671
+ # Calculate magnitude of gradiometer offset for each vector
672
+ magnitude = np.linalg.norm(diff, axis=1)
673
+
674
+ # Set quality flags: 0 = good data (below threshold), 1 = bad data
675
+ quality_flags = (magnitude > quality_flag_threshold).astype(int)
676
+
544
677
  grad_epoch = xr.DataArray(mago_epoch, dims=["epoch"])
545
678
  direction = xr.DataArray(["x", "y", "z"], dims=["axis"])
546
679
  grad_ds = xr.Dataset(coords={"epoch": grad_epoch, "direction": direction})
547
680
  grad_ds["gradiometer_offsets"] = xr.DataArray(diff, dims=["epoch", "direction"])
681
+ grad_ds["gradiometer_offset_magnitude"] = xr.DataArray(
682
+ magnitude, dims=["epoch"]
683
+ )
684
+ grad_ds["quality_flags"] = xr.DataArray(quality_flags, dims=["epoch"])
548
685
 
549
686
  return grad_ds
550
687
 
@@ -13,6 +13,7 @@ from imap_processing.spice.geometry import SpiceFrame, frame_transform
13
13
  from imap_processing.spice.time import (
14
14
  et_to_ttj2000ns,
15
15
  str_to_et,
16
+ ttj2000ns_to_et,
16
17
  )
17
18
 
18
19
 
@@ -22,7 +23,8 @@ class ValidFrames(Enum):
22
23
  MAG = SpiceFrame.IMAP_MAG
23
24
  DSRF = SpiceFrame.IMAP_DPS
24
25
  SRF = SpiceFrame.IMAP_SPACECRAFT
25
- # TODO: include RTN and GSE as valid frames
26
+ GSE = SpiceFrame.IMAP_GSE
27
+ RTN = SpiceFrame.IMAP_RTN
26
28
 
27
29
 
28
30
  @dataclass(kw_only=True)
@@ -55,6 +57,9 @@ class MagL2L1dBase:
55
57
  file in L2, marked as good always in L1D.
56
58
  frame:
57
59
  The reference frame of the input vectors. Starts as the MAG instrument frame.
60
+ epoch_et: np.ndarray
61
+ The epoch timestamps converted to ET format. Used for frame transformations.
62
+ Calculated on first use and then saved. Should not be passed in.
58
63
  """
59
64
 
60
65
  vectors: np.ndarray
@@ -66,6 +71,7 @@ class MagL2L1dBase:
66
71
  data_mode: DataMode
67
72
  magnitude: np.ndarray = field(init=False)
68
73
  frame: ValidFrames = ValidFrames.MAG
74
+ epoch_et: np.ndarray | None = field(init=False, default=None)
69
75
 
70
76
  def generate_dataset(
71
77
  self,
@@ -301,8 +307,10 @@ class MagL2L1dBase:
301
307
  The frame to rotate the data to. Must be one of the ValidFrames enum
302
308
  values.
303
309
  """
310
+ if self.epoch_et is None:
311
+ self.epoch_et = ttj2000ns_to_et(self.epoch)
304
312
  self.vectors = frame_transform(
305
- self.epoch,
313
+ self.epoch_et,
306
314
  self.vectors,
307
315
  from_frame=self.frame.value,
308
316
  to_frame=end_frame.value,
@@ -37,7 +37,7 @@ class ENAFlags(FlagNameMixin):
37
37
  BADSPIN = 2**2 # bit 2, Bad spin
38
38
 
39
39
 
40
- class ImapDEUltraFlags(FlagNameMixin):
40
+ class ImapDEOutliersUltraFlags(FlagNameMixin):
41
41
  """IMAP Ultra flags."""
42
42
 
43
43
  NONE = CommonFlags.NONE
@@ -75,6 +75,14 @@ class ImapRatesUltraFlags(FlagNameMixin):
75
75
  PARTIALSPIN = 2**2 # bit 2
76
76
 
77
77
 
78
+ class ImapDEScatteringUltraFlags(FlagNameMixin):
79
+ """IMAP Ultra Scattering flags."""
80
+
81
+ NONE = CommonFlags.NONE
82
+ ABOVE_THRESHOLD = 2**0 # bit 0
83
+ NAN_PHI_OR_THETA = 2**1 # bit 1
84
+
85
+
78
86
  class ImapInstrumentUltraFlags(FlagNameMixin):
79
87
  """IMAP Ultra flags using other instruments."""
80
88