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,17 +1,23 @@
1
1
  # mypy: disable-error-code="unused-ignore"
2
2
  """Data classes for MAG L1D processing."""
3
3
 
4
+ import logging
4
5
  from dataclasses import InitVar, dataclass
5
6
 
6
7
  import numpy as np
7
8
  import xarray as xr
8
9
 
10
+ from imap_processing.cdf.imap_cdf_manager import ImapCdfAttributes
11
+ from imap_processing.mag import imap_mag_sdc_configuration_v001 as configuration
9
12
  from imap_processing.mag.constants import FILLVAL, DataMode
10
13
  from imap_processing.mag.l1c.interpolation_methods import linear
11
14
  from imap_processing.mag.l2.mag_l2 import retrieve_matrix_from_l2_calibration
12
15
  from imap_processing.mag.l2.mag_l2_data import MagL2L1dBase, ValidFrames
13
16
  from imap_processing.spice import spin
14
17
  from imap_processing.spice.geometry import frame_transform
18
+ from imap_processing.spice.time import ttj2000ns_to_et, ttj2000ns_to_met
19
+
20
+ logger = logging.getLogger(__name__)
15
21
 
16
22
 
17
23
  @dataclass
@@ -57,7 +63,7 @@ class MagL1dConfiguration:
57
63
  mago_calibration: np.ndarray
58
64
  magi_calibration: np.ndarray
59
65
  spin_count_calibration: int
60
- quality_flag_threshold: np.float64
66
+ quality_flag_threshold: float
61
67
  spin_average_application_factor: np.float64
62
68
  gradiometer_factor: np.ndarray
63
69
  apply_gradiometry: bool = True
@@ -144,8 +150,6 @@ class MagL1d(MagL2L1dBase): # type: ignore[misc]
144
150
  truncate the data to exactly 24 hours.
145
151
  """
146
152
 
147
- # TODO Quality flags
148
- # TODO generate and output ancillary files
149
153
  magi_vectors: np.ndarray
150
154
  magi_range: np.ndarray
151
155
  magi_epoch: np.ndarray
@@ -165,6 +169,9 @@ class MagL1d(MagL2L1dBase): # type: ignore[misc]
165
169
  The day we are processing, in np.datetime64[D] format. This is used to
166
170
  truncate the data to exactly 24 hours.
167
171
  """
172
+ # The main data frame is MAGO, even though we have MAGI data included.
173
+ self.frame = ValidFrames.MAGO
174
+
168
175
  # set the magnitude before truncating
169
176
  self.magnitude = np.zeros(self.vectors.shape[0], dtype=np.float64) # type: ignore[has-type]
170
177
  self.truncate_to_24h(day)
@@ -182,7 +189,7 @@ class MagL1d(MagL2L1dBase): # type: ignore[misc]
182
189
 
183
190
  self.vectors = self.apply_spin_offsets(
184
191
  self.spin_offsets,
185
- self.epoch,
192
+ self.epoch, # type: ignore[has-type]
186
193
  self.vectors,
187
194
  self.config.spin_average_application_factor,
188
195
  )
@@ -198,7 +205,11 @@ class MagL1d(MagL2L1dBase): # type: ignore[misc]
198
205
 
199
206
  if self.config.apply_gradiometry:
200
207
  self.gradiometry_offsets = self.calculate_gradiometry_offsets(
201
- self.vectors, self.epoch, self.magi_vectors, self.magi_epoch
208
+ self.vectors,
209
+ self.epoch, # type: ignore[has-type]
210
+ self.magi_vectors,
211
+ self.magi_epoch,
212
+ self.config.quality_flag_threshold,
202
213
  )
203
214
  self.vectors = self.apply_gradiometry_offsets(
204
215
  self.gradiometry_offsets, self.vectors, self.config.gradiometer_factor
@@ -207,6 +218,54 @@ class MagL1d(MagL2L1dBase): # type: ignore[misc]
207
218
  self.magnitude = MagL2L1dBase.calculate_magnitude(vectors=self.vectors)
208
219
  self.is_l1d = True
209
220
 
221
+ def generate_dataset(
222
+ self,
223
+ attribute_manager: ImapCdfAttributes,
224
+ day: np.datetime64,
225
+ ) -> xr.Dataset:
226
+ """
227
+ Generate an xarray dataset from the dataclass.
228
+
229
+ This overrides the parent method to conditionally swap MAGO/MAGI data
230
+ based on the always_output_mago configuration setting.
231
+
232
+ Parameters
233
+ ----------
234
+ attribute_manager : ImapCdfAttributes
235
+ CDF attributes object for the correct level.
236
+ day : np.datetime64
237
+ The 24 hour day to process, as a numpy datetime format.
238
+
239
+ Returns
240
+ -------
241
+ xr.Dataset
242
+ Complete dataset ready to write to CDF file.
243
+ """
244
+ always_output_mago = configuration.ALWAYS_OUTPUT_MAGO
245
+
246
+ if not always_output_mago:
247
+ # Swap vectors and epochs to use MAGI data instead of MAGO
248
+ original_vectors: np.ndarray = self.vectors.copy()
249
+ original_epoch: np.ndarray = self.epoch.copy() # type: ignore[has-type]
250
+ original_range: np.ndarray = self.range.copy() # type: ignore[has-type]
251
+
252
+ self.vectors = self.magi_vectors # type: ignore[no-redef]
253
+ self.epoch = self.magi_epoch # type: ignore[no-redef]
254
+ self.range = self.magi_range # type: ignore[no-redef]
255
+
256
+ # Call parent generate_dataset method
257
+ dataset = super().generate_dataset(attribute_manager, day)
258
+
259
+ # Restore original vectors for any further processing
260
+ self.vectors = original_vectors
261
+ self.epoch = original_epoch
262
+ self.range = original_range
263
+ else:
264
+ # Use MAGO data (default behavior)
265
+ dataset = super().generate_dataset(attribute_manager, day)
266
+
267
+ return dataset
268
+
210
269
  def rotate_frame(self, end_frame: ValidFrames) -> None:
211
270
  """
212
271
  Rotate the vectors to the desired frame.
@@ -219,15 +278,42 @@ class MagL1d(MagL2L1dBase): # type: ignore[misc]
219
278
  end_frame : ValidFrames
220
279
  The frame to rotate to. Should be one of the ValidFrames enum.
221
280
  """
281
+ # Self.frame should refer to the main data in self.vectors, which is MAGO
282
+ # data. For most frames, MAGO and MAGI are in the same frame, except the
283
+ # instrument reference frame.
284
+ if ValidFrames.MAGI in (self.frame, end_frame):
285
+ raise ValueError(
286
+ "MAGL1d.frame should never be equal to MAGI frame. If the "
287
+ "data is in the instrument frame, use MAGO."
288
+ )
289
+
222
290
  start_frame = self.frame
223
- super().rotate_frame(end_frame)
291
+
292
+ if self.epoch_et is None:
293
+ self.epoch_et: np.ndarray = ttj2000ns_to_et(self.epoch)
294
+ self.magi_epoch_et: np.ndarray = ttj2000ns_to_et(self.magi_epoch)
295
+
296
+ self.vectors = frame_transform(
297
+ self.epoch_et,
298
+ self.vectors,
299
+ from_frame=start_frame.value,
300
+ to_frame=end_frame.value,
301
+ )
302
+
303
+ # If we were in MAGO frame, we need to rotate MAGI vectors from MAGI to
304
+ # end_frame
305
+ if start_frame == ValidFrames.MAGO:
306
+ start_frame = ValidFrames.MAGI
307
+
224
308
  self.magi_vectors = frame_transform(
225
- self.magi_epoch,
309
+ self.magi_epoch_et,
226
310
  self.magi_vectors,
227
311
  from_frame=start_frame.value,
228
312
  to_frame=end_frame.value,
229
313
  )
230
314
 
315
+ self.frame = end_frame
316
+
231
317
  def _calibrate_and_offset_vectors(
232
318
  self,
233
319
  mago_calibration: np.ndarray,
@@ -312,7 +398,7 @@ class MagL1d(MagL2L1dBase): # type: ignore[misc]
312
398
  The offset vector, shape (4,) where the last element is unchanged.
313
399
  """
314
400
  # Offsets are in shape (sensor, range, axis)
315
- updated_vector = input_vector.copy().astype(np.int64)
401
+ updated_vector = input_vector.copy()
316
402
  rng = int(input_vector[3])
317
403
  x_y_z = input_vector[:3]
318
404
  updated_vector[:3] = x_y_z - offsets[int(is_magi), rng, :]
@@ -348,64 +434,132 @@ class MagL1d(MagL2L1dBase): # type: ignore[misc]
348
434
  "Spin offsets can only be calculated in NORM mode and SRF frame."
349
435
  )
350
436
 
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
437
+ epoch_met = ttj2000ns_to_met(self.epoch)
438
+ sc_spin_phase = spin.get_spacecraft_spin_phase(epoch_met)
353
439
  # mark vectors as nan where they are nan in sc_spin_phase
354
440
  vectors = self.vectors.copy().astype(np.float64)
355
441
 
356
442
  vectors[np.isnan(sc_spin_phase), :] = np.nan
357
443
 
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
444
  # first timestamp where spin phase is less than the previous value
362
445
  # this is when the spin crosses zero
363
446
  spin_starts = np.where(np.diff(sc_spin_phase) < 0)[0] + 1
364
447
 
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
- )
448
+ # if the value switches from nan to a number, or from a number to nan, that
449
+ # is also a spin start
450
+ nan_to_number = np.where(np.diff(np.isnan(sc_spin_phase)) != 0)[0] + 1
370
451
 
371
452
  # find the places spins start while skipping over invalid or missing data
372
453
  # (marked as nan by get_spacecraft_spin_phase)
373
454
  spin_starts = np.sort(np.concatenate((spin_starts, nan_to_number)))
374
455
 
456
+ # Get the expected spin period from the spin table
457
+ # Convert to nanoseconds to match epoch
458
+ spin_data = spin.get_spin_data()
459
+ # Use the median spin period as the expected value
460
+ expected_spin = np.median(spin_data["spin_period_sec"]) * 1e9
461
+
462
+ paired_nans = nan_to_number.reshape(-1, 2)
463
+
464
+ for start_of_gap, end_of_gap in paired_nans:
465
+ # in nan_to_number, we have the start and end for every nan gap
466
+ # if this gap spans more than 1 spin period, we need to insert
467
+ # additional spin_starts into spin_starts.
468
+
469
+ gap_start_time = self.epoch[start_of_gap]
470
+ gap_end_time = self.epoch[end_of_gap]
471
+
472
+ # Calculate the number of spins in this gap
473
+ number_of_spins = int((gap_end_time - gap_start_time) // expected_spin)
474
+ if number_of_spins > 1:
475
+ # Insert new spin starts into spin_starts
476
+ for i in range(1, number_of_spins):
477
+ estimated_start = gap_start_time + i * expected_spin
478
+ new_spin_index = (np.abs(self.epoch - estimated_start)).argmin()
479
+
480
+ spin_starts = np.append(spin_starts, new_spin_index)
481
+
482
+ # Now spin_starts contains all the indices where spins begin, including
483
+ # estimating skipped or missing spins.
484
+ spin_starts = np.sort(spin_starts)
485
+
375
486
  chunk_start = 0
376
487
  offset_epochs = []
377
- x_avg = []
378
- y_avg = []
488
+ x_avg_calcs: list[np.float64] = []
489
+ y_avg_calcs: list[np.float64] = []
490
+ validity_start_times = []
491
+ validity_end_times = []
492
+ start_spin_counters = []
493
+ end_spin_counters = []
494
+
379
495
  while chunk_start < len(spin_starts):
380
496
  # Take self.spin_count_calibration number of spins and put them into a chunk
381
497
  chunk_indices = spin_starts[
382
498
  chunk_start : chunk_start + self.config.spin_count_calibration + 1
383
499
  ]
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))
500
+ chunk_start_idx = chunk_start
389
501
 
390
502
  chunk_vectors = self.vectors[chunk_indices[0] : chunk_indices[-1]]
391
503
  chunk_epoch = self.epoch[chunk_indices[0] : chunk_indices[-1]]
392
504
 
505
+ # Check if more than half of the chunk data is NaN before processing
506
+ x_valid_count: int = int(np.sum(~np.isnan(chunk_vectors[:, 0])))
507
+ y_valid_count: int = int(np.sum(~np.isnan(chunk_vectors[:, 1])))
508
+ total_points = len(chunk_vectors)
509
+
393
510
  # average the x and y axes (z is fixed, as the spin axis)
394
- # TODO: is z the correct axis here?
395
511
  avg_x = np.nanmean(chunk_vectors[:, 0])
396
512
  avg_y = np.nanmean(chunk_vectors[:, 1])
397
513
 
514
+ # Skip chunk if more than half of x or y data is NaN, or if we have less
515
+ # than half a spin.
516
+ # in this case, we should reuse the previous averages.
517
+ if (
518
+ x_valid_count <= total_points / 2
519
+ or y_valid_count <= total_points / 2
520
+ or total_points <= self.config.spin_count_calibration / 2
521
+ ):
522
+ avg_x = x_avg_calcs[-1] if x_avg_calcs else np.float64(FILLVAL)
523
+ avg_y = y_avg_calcs[-1] if y_avg_calcs else np.float64(FILLVAL)
524
+
398
525
  if not np.isnan(avg_x) and not np.isnan(avg_y):
399
526
  offset_epochs.append(chunk_epoch[0])
400
- x_avg.append(avg_x)
401
- y_avg.append(avg_y)
527
+ x_avg_calcs.append(avg_x)
528
+ y_avg_calcs.append(avg_y)
529
+
530
+ # Add validity time range for this chunk
531
+ validity_start_times.append(chunk_epoch[0])
532
+ validity_end_times.append(chunk_epoch[-1])
533
+
534
+ # Add spin counter information
535
+ start_spin_counters.append(chunk_start_idx)
536
+ end_spin_counters.append(
537
+ min(
538
+ chunk_start_idx + self.config.spin_count_calibration - 1,
539
+ len(spin_starts) - 1,
540
+ )
541
+ )
542
+
543
+ chunk_start = chunk_start + self.config.spin_count_calibration
402
544
 
403
545
  spin_epoch_dataarray = xr.DataArray(np.array(offset_epochs))
404
546
 
405
547
  spin_offsets = xr.Dataset(coords={"epoch": spin_epoch_dataarray})
406
548
 
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"])
549
+ spin_offsets["x_offset"] = xr.DataArray(np.array(x_avg_calcs), dims=["epoch"])
550
+ spin_offsets["y_offset"] = xr.DataArray(np.array(y_avg_calcs), dims=["epoch"])
551
+ spin_offsets["validity_start_time"] = xr.DataArray(
552
+ np.array(validity_start_times), dims=["epoch"]
553
+ )
554
+ spin_offsets["validity_end_time"] = xr.DataArray(
555
+ np.array(validity_end_times), dims=["epoch"]
556
+ )
557
+ spin_offsets["start_spin_counter"] = xr.DataArray(
558
+ np.array(start_spin_counters), dims=["epoch"]
559
+ )
560
+ spin_offsets["end_spin_counter"] = xr.DataArray(
561
+ np.array(end_spin_counters), dims=["epoch"]
562
+ )
409
563
 
410
564
  return spin_offsets
411
565
 
@@ -462,7 +616,7 @@ class MagL1d(MagL2L1dBase): # type: ignore[misc]
462
616
  if spin_offsets is None:
463
617
  raise ValueError("No spin offsets calculated to apply.")
464
618
 
465
- output_vectors = np.full(vectors.shape, FILLVAL, dtype=np.int64)
619
+ output_vectors = np.full(vectors.shape, FILLVAL, dtype=np.float64)
466
620
 
467
621
  for index in range(spin_offsets["epoch"].data.shape[0] - 1):
468
622
  timestamp = spin_offsets["epoch"].data[index]
@@ -483,7 +637,6 @@ class MagL1d(MagL2L1dBase): # type: ignore[misc]
483
637
  if not np.any(mask):
484
638
  continue
485
639
 
486
- # TODO: should vectors be a float?
487
640
  x_offset = (
488
641
  spin_offsets["x_offset"].data[index] * spin_average_application_factor
489
642
  )
@@ -504,6 +657,7 @@ class MagL1d(MagL2L1dBase): # type: ignore[misc]
504
657
  mago_epoch: np.ndarray,
505
658
  magi_vectors: np.ndarray,
506
659
  magi_epoch: np.ndarray,
660
+ quality_flag_threshold: float = np.inf,
507
661
  ) -> xr.Dataset:
508
662
  """
509
663
  Calculate the gradiometry offsets between MAGo and MAGi.
@@ -525,6 +679,10 @@ class MagL1d(MagL2L1dBase): # type: ignore[misc]
525
679
  The MAGi vectors, shape (N, 3).
526
680
  magi_epoch : np.ndarray
527
681
  The MAGi epoch values, shape (N,).
682
+ quality_flag_threshold : np.float64, optional
683
+ Threshold for quality flags. If the magnitude of gradiometer offset
684
+ exceeds this threshold, quality flag will be set. Default is np.inf
685
+ (no quality flags set).
528
686
 
529
687
  Returns
530
688
  -------
@@ -532,6 +690,8 @@ class MagL1d(MagL2L1dBase): # type: ignore[misc]
532
690
  The gradiometer offsets dataset, with variables:
533
691
  - epoch: the timestamp of the MAGo data
534
692
  - gradiometer_offsets: the offset values (MAGi - MAGo) for each axis
693
+ - gradiometer_offset_magnitude: magnitude of the offset vector
694
+ - quality_flags: quality flags (1 if magnitude > threshold, 0 otherwise)
535
695
  """
536
696
  aligned_magi = linear(
537
697
  magi_vectors,
@@ -541,10 +701,20 @@ class MagL1d(MagL2L1dBase): # type: ignore[misc]
541
701
 
542
702
  diff = aligned_magi - mago_vectors
543
703
 
704
+ # Calculate magnitude of gradiometer offset for each vector
705
+ magnitude = np.linalg.norm(diff, axis=1)
706
+
707
+ # Set quality flags: 0 = good data (below threshold), 1 = bad data
708
+ quality_flags = (magnitude > quality_flag_threshold).astype(int)
709
+
544
710
  grad_epoch = xr.DataArray(mago_epoch, dims=["epoch"])
545
711
  direction = xr.DataArray(["x", "y", "z"], dims=["axis"])
546
712
  grad_ds = xr.Dataset(coords={"epoch": grad_epoch, "direction": direction})
547
713
  grad_ds["gradiometer_offsets"] = xr.DataArray(diff, dims=["epoch", "direction"])
714
+ grad_ds["gradiometer_offset_magnitude"] = xr.DataArray(
715
+ magnitude, dims=["epoch"]
716
+ )
717
+ grad_ds["quality_flags"] = xr.DataArray(quality_flags, dims=["epoch"])
548
718
 
549
719
  return grad_ds
550
720
 
@@ -90,6 +90,7 @@ def mag_l2(
90
90
  )
91
91
  # level 2 vectors don't include range
92
92
  vectors = cal_vectors[:, :3]
93
+ instrument_frame = ValidFrames.MAGO if always_output_mago else ValidFrames.MAGI
93
94
 
94
95
  l2_data = MagL2(
95
96
  vectors=vectors,
@@ -101,6 +102,7 @@ def mag_l2(
101
102
  data_mode=mode,
102
103
  offsets=offsets_dataset["offsets"].data,
103
104
  timedelta=offsets_dataset["timedeltas"].data,
105
+ frame=instrument_frame,
104
106
  )
105
107
 
106
108
  attributes = ImapCdfAttributes()
@@ -13,16 +13,19 @@ 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
 
19
20
  class ValidFrames(Enum):
20
21
  """SPICE reference frames for output."""
21
22
 
22
- MAG = SpiceFrame.IMAP_MAG
23
+ MAGO = SpiceFrame.IMAP_MAG_O
24
+ MAGI = SpiceFrame.IMAP_MAG_I
23
25
  DSRF = SpiceFrame.IMAP_DPS
24
26
  SRF = SpiceFrame.IMAP_SPACECRAFT
25
- # TODO: include RTN and GSE as valid frames
27
+ GSE = SpiceFrame.IMAP_GSE
28
+ RTN = SpiceFrame.IMAP_RTN
26
29
 
27
30
 
28
31
  @dataclass(kw_only=True)
@@ -54,7 +57,10 @@ class MagL2L1dBase:
54
57
  Quality bitmask for each vector. Should be of length n. Copied from offset
55
58
  file in L2, marked as good always in L1D.
56
59
  frame:
57
- The reference frame of the input vectors. Starts as the MAG instrument frame.
60
+ The reference frame of the input vectors. Defaults to the MAGO instrument frame.
61
+ epoch_et: np.ndarray
62
+ The epoch timestamps converted to ET format. Used for frame transformations.
63
+ Calculated on first use and then saved. Should not be passed in.
58
64
  """
59
65
 
60
66
  vectors: np.ndarray
@@ -65,7 +71,8 @@ class MagL2L1dBase:
65
71
  quality_bitmask: np.ndarray
66
72
  data_mode: DataMode
67
73
  magnitude: np.ndarray = field(init=False)
68
- frame: ValidFrames = ValidFrames.MAG
74
+ frame: ValidFrames = ValidFrames.MAGO
75
+ epoch_et: np.ndarray | None = field(init=False, default=None)
69
76
 
70
77
  def generate_dataset(
71
78
  self,
@@ -301,8 +308,10 @@ class MagL2L1dBase:
301
308
  The frame to rotate the data to. Must be one of the ValidFrames enum
302
309
  values.
303
310
  """
311
+ if self.epoch_et is None:
312
+ self.epoch_et = ttj2000ns_to_et(self.epoch)
304
313
  self.vectors = frame_transform(
305
- self.epoch,
314
+ self.epoch_et,
306
315
  self.vectors,
307
316
  from_frame=self.frame.value,
308
317
  to_frame=end_frame.value,
@@ -37,12 +37,13 @@ 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
44
44
  FOV = 2**0 # bit 0
45
45
  PHCORR = 2**1 # bit 1
46
+ COINPH = 2**2 # bit 4 # Event validity
46
47
 
47
48
 
48
49
  class ImapHkUltraFlags(FlagNameMixin):
@@ -75,6 +76,21 @@ class ImapRatesUltraFlags(FlagNameMixin):
75
76
  PARTIALSPIN = 2**2 # bit 2
76
77
 
77
78
 
79
+ class ImapDEScatteringUltraFlags(FlagNameMixin):
80
+ """IMAP Ultra Scattering flags."""
81
+
82
+ NONE = CommonFlags.NONE
83
+ ABOVE_THRESHOLD = 2**0 # bit 0
84
+ NAN_PHI_OR_THETA = 2**1 # bit 1
85
+
86
+
87
+ class ImapPSETUltraFlags(FlagNameMixin):
88
+ """IMAP Ultra Rates flags."""
89
+
90
+ NONE = CommonFlags.NONE
91
+ EARTH_FOV = 2**0 # bit 0
92
+
93
+
78
94
  class ImapInstrumentUltraFlags(FlagNameMixin):
79
95
  """IMAP Ultra flags using other instruments."""
80
96
 
@@ -123,3 +139,9 @@ class SWAPIFlags(
123
139
  SCEM_V_ST = 2**12 # bit 12
124
140
  SCEM_I_ST = 2**13 # bit 13
125
141
  SCEM_INT_ST = 2**14 # bit 14
142
+
143
+
144
+ class GLOWSL1bFlags(FlagNameMixin):
145
+ """Glows L1b flags."""
146
+
147
+ NONE = CommonFlags.NONE