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
@@ -2,13 +2,22 @@
2
2
 
3
3
  from imap_processing.quality_flags import (
4
4
  FlagNameMixin,
5
+ ImapDEOutliersUltraFlags,
6
+ ImapDEScatteringUltraFlags,
5
7
  ImapRatesUltraFlags,
6
8
  )
7
9
 
8
- QUALITY_FLAG_FILTERS: dict[str, list[FlagNameMixin]] = {
10
+ SPIN_QUALITY_FLAG_FILTERS: dict[str, list[FlagNameMixin]] = {
9
11
  "quality_attitude": [],
10
12
  "quality_ena_rates": [
11
13
  ImapRatesUltraFlags.FIRSTSPIN,
12
14
  ImapRatesUltraFlags.LASTSPIN,
13
15
  ],
14
16
  }
17
+
18
+ DE_QUALITY_FLAG_FILTERS: dict[str, list[FlagNameMixin]] = {
19
+ "quality_outliers": [ImapDEOutliersUltraFlags.FOV],
20
+ "quality_scattering": [
21
+ ImapDEScatteringUltraFlags.ABOVE_THRESHOLD,
22
+ ],
23
+ }
@@ -1,6 +1,7 @@
1
1
  """Culls Events for ULTRA L1b."""
2
2
 
3
3
  import logging
4
+ from collections import namedtuple
4
5
 
5
6
  import numpy as np
6
7
  import pandas as pd
@@ -9,18 +10,36 @@ from numpy.typing import NDArray
9
10
 
10
11
  from imap_processing.quality_flags import (
11
12
  ImapAttitudeUltraFlags,
13
+ ImapDEScatteringUltraFlags,
12
14
  ImapHkUltraFlags,
13
15
  ImapInstrumentUltraFlags,
14
16
  ImapRatesUltraFlags,
15
17
  )
16
18
  from imap_processing.spice.spin import get_spin_data
17
19
  from imap_processing.ultra.constants import UltraConstants
20
+ from imap_processing.ultra.l1b.lookup_utils import (
21
+ get_scattering_coefficients,
22
+ get_scattering_thresholds,
23
+ )
24
+ from imap_processing.ultra.l1b.quality_flag_filters import DE_QUALITY_FLAG_FILTERS
18
25
 
19
26
  logging.basicConfig(level=logging.INFO)
20
27
  logger = logging.getLogger(__name__)
21
28
 
22
29
  SPIN_DURATION = 15 # Default spin duration in seconds.
23
30
 
31
+ RateResult = namedtuple(
32
+ "RateResult",
33
+ [
34
+ "start_per_spin",
35
+ "stop_per_spin",
36
+ "coin_per_spin",
37
+ "start_pulses",
38
+ "stop_pulses",
39
+ "coin_pulses",
40
+ ],
41
+ )
42
+
24
43
 
25
44
  def get_energy_histogram(
26
45
  spin_number: NDArray, energy: NDArray
@@ -369,7 +388,7 @@ def get_spin_and_duration(met: NDArray, spin: NDArray) -> tuple[NDArray, NDArray
369
388
  possible_spins = spin_numbers & 0xFF
370
389
 
371
390
  # Assign each group based on time.
372
- for start, end in zip(spin_start_indices, spin_end_indices):
391
+ for start, end in zip(spin_start_indices, spin_end_indices, strict=False):
373
392
  # Now that we have the possible spins from the Universal Spin Table,
374
393
  # we match the times of those spins to the nearest times in the DE data.
375
394
  possible_times = spin_start_mets[
@@ -394,7 +413,7 @@ def get_spin_and_duration(met: NDArray, spin: NDArray) -> tuple[NDArray, NDArray
394
413
  return assigned_spin_number, assigned_duration
395
414
 
396
415
 
397
- def get_pulses_per_spin(rates: xr.Dataset) -> tuple[NDArray, NDArray, NDArray]:
416
+ def get_pulses_per_spin(rates: xr.Dataset) -> RateResult:
398
417
  """
399
418
  Get the total number of pulses per spin.
400
419
 
@@ -411,6 +430,12 @@ def get_pulses_per_spin(rates: xr.Dataset) -> tuple[NDArray, NDArray, NDArray]:
411
430
  Total stop pulses per spin.
412
431
  coin_per_spin : NDArray
413
432
  Total coincidence pulses per spin.
433
+ start_pulses : NDArray
434
+ Total start pulses.
435
+ stop_pulses : NDArray
436
+ Total stop pulses.
437
+ coin_pulses : NDArray
438
+ Total coincidence pulses.
414
439
  """
415
440
  spin_number, duration = get_spin_and_duration(rates["shcoarse"], rates["spin"])
416
441
 
@@ -448,4 +473,137 @@ def get_pulses_per_spin(rates: xr.Dataset) -> tuple[NDArray, NDArray, NDArray]:
448
473
  stop_per_spin = np.bincount(spin_idx, weights=stop_pulses)
449
474
  coin_per_spin = np.bincount(spin_idx, weights=coin_pulses)
450
475
 
451
- return start_per_spin, stop_per_spin, coin_per_spin
476
+ return RateResult(
477
+ start_per_spin=start_per_spin,
478
+ stop_per_spin=stop_per_spin,
479
+ coin_per_spin=coin_per_spin,
480
+ start_pulses=start_pulses,
481
+ stop_pulses=stop_pulses,
482
+ coin_pulses=coin_pulses,
483
+ )
484
+
485
+
486
+ def flag_scattering(
487
+ tof_energy: NDArray,
488
+ theta: NDArray,
489
+ phi: NDArray,
490
+ ancillary_files: dict,
491
+ sensor: str,
492
+ quality_flags: NDArray,
493
+ ) -> None:
494
+ """
495
+ Flag events where either theta or phi FWHM exceed the threshold or equal nan.
496
+
497
+ Parameters
498
+ ----------
499
+ tof_energy : NDArray
500
+ TOF energy for each event in keV.
501
+ theta : NDArray
502
+ Elevation angles in degrees.
503
+ phi : NDArray
504
+ Azimuth angles in degrees.
505
+ ancillary_files : dict[Path]
506
+ Ancillary files.
507
+ sensor : str
508
+ Sensor name: "ultra45" or "ultra90".
509
+ quality_flags : NDArray
510
+ Quality flags.
511
+ """
512
+ scattering_thresholds = get_scattering_thresholds(ancillary_files)
513
+
514
+ for (e_min, e_max), threshold in scattering_thresholds.items():
515
+ event_mask = (tof_energy >= e_min) & (tof_energy < e_max)
516
+ # Input the theta and phi values for the current energy range.
517
+ # Returns a_theta_val, g_theta_val, a_phi_val, g_phi_val
518
+ theta_coeffs, phi_coeffs = get_scattering_coefficients(
519
+ theta[event_mask],
520
+ phi[event_mask],
521
+ lookup_tables=None,
522
+ ancillary_files=ancillary_files,
523
+ instrument_id=int(sensor[-2:]),
524
+ )
525
+ # FWHM_PHI = A_PHI * E^G_PHI
526
+ # FWHM_THETA = A_THETA * E^G_THETA
527
+ fwhm_theta = theta_coeffs[:, 0] * tof_energy[event_mask] ** theta_coeffs[:, 1]
528
+ fwhm_phi = phi_coeffs[:, 0] * tof_energy[event_mask] ** phi_coeffs[:, 1]
529
+ is_nan = np.isnan(fwhm_theta) | np.isnan(fwhm_phi)
530
+ quality_flags[np.where(event_mask)[0][is_nan]] |= (
531
+ ImapDEScatteringUltraFlags.NAN_PHI_OR_THETA.value
532
+ )
533
+
534
+ theta_exceeds = fwhm_theta > threshold
535
+ phi_exceeds = fwhm_phi > threshold
536
+ either_exceeds = theta_exceeds | phi_exceeds
537
+
538
+ # Set flags for events where either theta or phi FWHM exceed the threshold
539
+ quality_flags[np.where(event_mask)[0][either_exceeds]] |= (
540
+ ImapDEScatteringUltraFlags.ABOVE_THRESHOLD.value
541
+ )
542
+
543
+
544
+ def get_de_rejection_mask(
545
+ quality_scattering: NDArray, quality_outliers: NDArray
546
+ ) -> NDArray:
547
+ """
548
+ Create boolean mask where event is rejected due to relevant flags.
549
+
550
+ Parameters
551
+ ----------
552
+ quality_scattering : NDArray
553
+ Quality scattering flags.
554
+ quality_outliers : NDArray
555
+ Quality outliers flags.
556
+
557
+ Returns
558
+ -------
559
+ rejected : NDArray
560
+ Rejected events where True = rejected.
561
+ """
562
+ # Bitmasks from the DE_QUALITY_FLAG_FILTERS
563
+ scattering_mask = sum(
564
+ flag.value for flag in DE_QUALITY_FLAG_FILTERS["quality_scattering"]
565
+ )
566
+ outliers_mask = sum(
567
+ flag.value for flag in DE_QUALITY_FLAG_FILTERS["quality_outliers"]
568
+ )
569
+
570
+ # Boolean mask where event is rejected due to relevant flags
571
+ rejected = ((quality_scattering & scattering_mask) != 0) | (
572
+ (quality_outliers & outliers_mask) != 0
573
+ )
574
+
575
+ return rejected
576
+
577
+
578
+ def count_rejected_events_per_spin(
579
+ spins: NDArray, quality_scattering: NDArray, quality_outliers: NDArray
580
+ ) -> NDArray:
581
+ """
582
+ Count rejected events per spin based on DE_QUALITY_FLAG_FILTERS.
583
+
584
+ Parameters
585
+ ----------
586
+ spins : NDArray
587
+ Spins in which each direct event is within.
588
+ quality_scattering : NDArray
589
+ Quality scattering flags.
590
+ quality_outliers : NDArray
591
+ Quality outliers flags.
592
+
593
+ Returns
594
+ -------
595
+ rejected_counts : NDArray
596
+ Rejected counts per spin.
597
+ """
598
+ # Boolean mask where event is rejected due to relevant flags
599
+ rejected = get_de_rejection_mask(quality_scattering, quality_outliers)
600
+
601
+ # Unique spin numbers
602
+ unique_spins = np.unique(spins)
603
+
604
+ # Count rejected events per spin
605
+ rejected_counts = np.array(
606
+ [np.count_nonzero(rejected[spins == spin]) for spin in unique_spins], dtype=int
607
+ )
608
+
609
+ return rejected_counts
@@ -2,6 +2,7 @@
2
2
 
3
3
  # TODO: Come back and add in FSW logic.
4
4
  import logging
5
+ from collections import namedtuple
5
6
  from enum import Enum
6
7
 
7
8
  import numpy as np
@@ -16,6 +17,7 @@ from imap_processing.ultra.constants import UltraConstants
16
17
  from imap_processing.ultra.l1b.lookup_utils import (
17
18
  get_angular_profiles,
18
19
  get_back_position,
20
+ get_ebins,
19
21
  get_energy_efficiencies,
20
22
  get_energy_norm,
21
23
  get_image_params,
@@ -26,6 +28,10 @@ from imap_processing.ultra.l1b.lookup_utils import (
26
28
 
27
29
  logger = logging.getLogger(__name__)
28
30
 
31
+ FILLVAL_UINT8 = 255
32
+ FILLVAL_FLOAT32 = -1.0e31
33
+ FILLVAL_FLOAT64 = -1.0e31
34
+
29
35
 
30
36
  class StartType(Enum):
31
37
  """Start Type: 1=Left, 2=Right."""
@@ -50,6 +56,9 @@ class CoinType(Enum):
50
56
  Bottom = 2
51
57
 
52
58
 
59
+ PHTOFResult = namedtuple("PHTOFResult", ["tof", "t2", "xb", "yb", "tofx", "tofy"])
60
+
61
+
53
62
  def get_front_x_position(
54
63
  start_type: ndarray, start_position_tdc: ndarray, sensor: str, ancillary_files: dict
55
64
  ) -> ndarray:
@@ -161,7 +170,7 @@ def get_front_y_position(
161
170
 
162
171
  def get_ph_tof_and_back_positions(
163
172
  de_dataset: xarray.Dataset, xf: np.ndarray, sensor: str, ancillary_files: dict
164
- ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
173
+ ) -> PHTOFResult:
165
174
  """
166
175
  Calculate back xb, yb position and tof.
167
176
 
@@ -196,6 +205,10 @@ def get_ph_tof_and_back_positions(
196
205
  Back positions in x direction (hundredths of a millimeter).
197
206
  yb : np.array
198
207
  Back positions in y direction (hundredths of a millimeter).
208
+ tofx : np.array
209
+ X front position tof offset (tenths of a nanosecond).
210
+ tofy : np.array
211
+ Y front position tof offset (tenths of a nanosecond).
199
212
  """
200
213
  indices = np.nonzero(
201
214
  np.isin(de_dataset["stop_type"], [StopType.Top.value, StopType.Bottom.value])
@@ -278,7 +291,7 @@ def get_ph_tof_and_back_positions(
278
291
  stop_type_bottom
279
292
  ] / 10 * get_image_params("XFTTOF", sensor, ancillary_files)
280
293
 
281
- return tof, t2, xb, yb
294
+ return PHTOFResult(tof=tof, t2=t2, xb=xb, yb=yb, tofx=tofx, tofy=tofy)
282
295
 
283
296
 
284
297
  def get_path_length(
@@ -530,9 +543,9 @@ def get_de_velocity(
530
543
  v_y = delta_v[:, 1] / tof * 1e3
531
544
  v_z = delta_v[:, 2] / tof * 1e3
532
545
 
533
- v_x[tof < 0] = np.nan # used as fillvals
534
- v_y[tof < 0] = np.nan
535
- v_z[tof < 0] = np.nan
546
+ v_x[tof < 0] = FILLVAL_FLOAT32 # used as fillvals
547
+ v_y[tof < 0] = FILLVAL_FLOAT32
548
+ v_z[tof < 0] = FILLVAL_FLOAT32
536
549
 
537
550
  velocities = np.vstack((v_x, v_y, v_z)).T
538
551
 
@@ -621,13 +634,17 @@ def get_de_energy_kev(v: np.ndarray, species: np.ndarray) -> NDArray:
621
634
  # Compute the sum of squares.
622
635
  v2 = np.sum(vv**2, axis=1)
623
636
 
624
- index_hydrogen = np.where(species == 1)
625
- energy = np.full_like(v2, np.nan)
637
+ # Only compute where species == 1 and v is valid
638
+ index_hydrogen = species == 1
639
+ valid_velocity = np.isfinite(v2)
640
+ valid_mask = index_hydrogen & valid_velocity
641
+
642
+ energy = np.full_like(v2, FILLVAL_FLOAT32)
626
643
 
627
644
  # TODO: we will calculate the energies of the different species here.
628
645
  # 1/2 mv^2 in Joules, convert to keV
629
- energy[index_hydrogen] = (
630
- 0.5 * UltraConstants.MASS_H * v2[index_hydrogen] * UltraConstants.J_KEV
646
+ energy[valid_mask] = (
647
+ 0.5 * UltraConstants.MASS_H * v2[valid_mask] * UltraConstants.J_KEV
631
648
  )
632
649
 
633
650
  return energy
@@ -933,7 +950,7 @@ def get_spin_number(de_met: NDArray, de_spin: NDArray) -> NDArray:
933
950
  possible_spins = spin_numbers & 0xFF
934
951
 
935
952
  # Assign each group based on time.
936
- for start, end in zip(spin_start_indices, spin_end_indices):
953
+ for start, end in zip(spin_start_indices, spin_end_indices, strict=False):
937
954
  # Now that we have the possible spins from the Universal Spin Table,
938
955
  # we match the times of those spins to the nearest times in the DE data.
939
956
  possible_times = spin_start_mets[possible_spins == de_spin_sorted[start]]
@@ -1030,8 +1047,11 @@ def interpolate_fwhm(
1030
1047
  )
1031
1048
 
1032
1049
  # Note: will return nan for those out-of-bounds inputs.
1033
- phi_interp = interp_phi((energy, phi_inst))
1034
- theta_interp = interp_theta((energy, theta_inst))
1050
+ phi_vals = interp_phi((energy, phi_inst))
1051
+ theta_vals = interp_theta((energy, theta_inst))
1052
+
1053
+ phi_interp = np.where(np.isnan(phi_vals), FILLVAL_FLOAT32, phi_vals)
1054
+ theta_interp = np.where(np.isnan(theta_vals), FILLVAL_FLOAT32, theta_vals)
1035
1055
 
1036
1056
  return phi_interp, theta_interp
1037
1057
 
@@ -1069,8 +1089,8 @@ def get_fwhm(
1069
1089
  theta_interp : NDArray
1070
1090
  Interpolated theta FWHM values.
1071
1091
  """
1072
- phi_interp = np.full_like(phi_inst, np.nan, dtype=np.float64)
1073
- theta_interp = np.full_like(theta_inst, np.nan, dtype=np.float64)
1092
+ phi_interp = np.full_like(phi_inst, FILLVAL_FLOAT64, dtype=np.float64)
1093
+ theta_interp = np.full_like(theta_inst, FILLVAL_FLOAT64, dtype=np.float64)
1074
1094
  lt_table = get_angular_profiles("left", sensor, ancillary_files)
1075
1095
  rt_table = get_angular_profiles("right", sensor, ancillary_files)
1076
1096
 
@@ -1089,20 +1109,12 @@ def get_fwhm(
1089
1109
  return phi_interp, theta_interp
1090
1110
 
1091
1111
 
1092
- def get_efficiency(
1093
- energy: NDArray, phi_inst: NDArray, theta_inst: NDArray, ancillary_files: dict
1094
- ) -> NDArray:
1112
+ def get_efficiency_interpolator(ancillary_files: dict) -> RegularGridInterpolator:
1095
1113
  """
1096
- Interpolate efficiency values for each event.
1114
+ Return a callable function that interpolates efficiency values for each event.
1097
1115
 
1098
1116
  Parameters
1099
1117
  ----------
1100
- energy : NDArray
1101
- Energy values for each event.
1102
- phi_inst : NDArray
1103
- Instrument-frame azimuth angle for each event.
1104
- theta_inst : NDArray
1105
- Instrument-frame elevation angle for each event.
1106
1118
  ancillary_files : dict
1107
1119
  Ancillary files.
1108
1120
 
@@ -1127,14 +1139,54 @@ def get_efficiency(
1127
1139
  (theta_vals, phi_vals, energy_vals),
1128
1140
  efficiency_grid,
1129
1141
  bounds_error=False,
1130
- fill_value=np.nan,
1142
+ fill_value=FILLVAL_FLOAT32,
1131
1143
  )
1132
1144
 
1145
+ return interpolator
1146
+
1147
+
1148
+ def get_efficiency(
1149
+ energy: NDArray,
1150
+ phi_inst: NDArray,
1151
+ theta_inst: NDArray,
1152
+ ancillary_files: dict,
1153
+ interpolator: RegularGridInterpolator = None,
1154
+ ) -> np.ndarray:
1155
+ """
1156
+ Return interpolated efficiency values for each event.
1157
+
1158
+ Parameters
1159
+ ----------
1160
+ energy : NDArray
1161
+ Energy values for each event.
1162
+ phi_inst : NDArray
1163
+ Instrument-frame azimuth angle for each event.
1164
+ theta_inst : NDArray
1165
+ Instrument-frame elevation angle for each event.
1166
+ ancillary_files : dict
1167
+ Ancillary files.
1168
+ interpolator : RegularGridInterpolator, optional
1169
+ Precomputed interpolator to use for efficiency lookup.
1170
+ If None, a new interpolator will be created from the ancillary files.
1171
+
1172
+ Returns
1173
+ -------
1174
+ efficiency : NDArray
1175
+ Interpolated efficiency values.
1176
+ """
1177
+ if not interpolator:
1178
+ interpolator = get_efficiency_interpolator(ancillary_files)
1179
+
1133
1180
  return interpolator((theta_inst, phi_inst, energy))
1134
1181
 
1135
1182
 
1136
1183
  def determine_ebin_pulse_height(
1137
- energy: np.ndarray, tof: np.ndarray, path_length: np.ndarray
1184
+ energy: NDArray,
1185
+ tof: NDArray,
1186
+ path_length: NDArray,
1187
+ backtofvalid: NDArray,
1188
+ coinphvalid: NDArray,
1189
+ ancillary_files: dict,
1138
1190
  ) -> NDArray:
1139
1191
  """
1140
1192
  Determine the species for pulse-height events.
@@ -1152,12 +1204,18 @@ def determine_ebin_pulse_height(
1152
1204
 
1153
1205
  Parameters
1154
1206
  ----------
1155
- energy : np.ndarray
1207
+ energy : NDArray
1156
1208
  Energy from the PH event (keV).
1157
- tof : np.ndarray
1209
+ tof : NDArray
1158
1210
  Time of flight of the PH event (tenths of a nanosecond).
1159
- path_length : np.ndarray
1211
+ path_length : NDArray
1160
1212
  Path length (r) (hundredths of a millimeter).
1213
+ backtofvalid : NDArray
1214
+ Boolean array indicating if the back TOF is valid.
1215
+ coinphvalid : NDArray
1216
+ Boolean array indicating if the Coincidence PH is valid.
1217
+ ancillary_files : dict
1218
+ Ancillary files containing the lookup tables.
1161
1219
 
1162
1220
  Returns
1163
1221
  -------
@@ -1166,15 +1224,22 @@ def determine_ebin_pulse_height(
1166
1224
  """
1167
1225
  # PH event TOF normalization to Z axis
1168
1226
  ctof, _ = get_ctof(tof, path_length, type="PH")
1169
- # TODO: need lookup tables
1170
- # placeholder
1171
- ebin = np.full(len(ctof), 255, dtype=np.uint8)
1172
1227
 
1173
- return ebin
1228
+ ebins = np.full(path_length.shape, FILLVAL_UINT8, dtype=np.uint8)
1229
+ valid = backtofvalid & coinphvalid
1230
+ ebins[valid] = get_ebins(
1231
+ "l1b-tofxph", energy[valid], ctof[valid], ebins[valid], ancillary_files
1232
+ )
1233
+
1234
+ return ebins
1174
1235
 
1175
1236
 
1176
1237
  def determine_ebin_ssd(
1177
- energy: np.ndarray, tof: np.ndarray, path_length: np.ndarray
1238
+ energy: NDArray,
1239
+ tof: NDArray,
1240
+ path_length: NDArray,
1241
+ sensor: str,
1242
+ ancillary_files: dict,
1178
1243
  ) -> NDArray:
1179
1244
  """
1180
1245
  Determine the species for SSD events.
@@ -1194,29 +1259,170 @@ def determine_ebin_ssd(
1194
1259
 
1195
1260
  Parameters
1196
1261
  ----------
1197
- energy : np.ndarray
1262
+ energy : NDArray
1198
1263
  Energy from the SSD event (keV).
1199
- tof : np.ndarray
1264
+ tof : NDArray
1200
1265
  Time of flight of the SSD event (tenths of a nanosecond).
1201
- path_length : np.ndarray
1266
+ path_length : NDArray
1202
1267
  Path length (r) (hundredths of a millimeter).
1268
+ sensor : str
1269
+ Sensor name: "ultra45" or "ultra90".
1270
+ ancillary_files : dict
1271
+ Ancillary files containing the lookup tables.
1203
1272
 
1204
1273
  Returns
1205
1274
  -------
1206
- bin : np.ndarray
1275
+ bin : NDArray
1207
1276
  Species bin.
1208
1277
  """
1209
1278
  # SSD event TOF normalization to Z axis
1210
1279
  ctof, _ = get_ctof(tof, path_length, type="SSD")
1211
1280
 
1212
- ebin = np.full(len(ctof), 255, dtype=np.uint8) # placeholder
1281
+ ebins = np.full(path_length.shape, FILLVAL_UINT8, dtype=np.uint8)
1282
+ steep_path_length = get_image_params("PathSteepThresh", sensor, ancillary_files)
1283
+ medium_path_length = get_image_params("PathMediumThresh", sensor, ancillary_files)
1284
+
1285
+ steep_mask = path_length < steep_path_length
1286
+ medium_mask = (path_length >= steep_path_length) & (
1287
+ path_length < medium_path_length
1288
+ )
1289
+ flat_mask = path_length >= medium_path_length
1290
+
1291
+ ebins[steep_mask] = get_ebins(
1292
+ f"l1b-{sensor[5::]}sensor-tofxesteep",
1293
+ energy[steep_mask],
1294
+ ctof[steep_mask],
1295
+ ebins[steep_mask],
1296
+ ancillary_files,
1297
+ )
1298
+ ebins[medium_mask] = get_ebins(
1299
+ f"l1b-{sensor[5::]}sensor-tofxemedium",
1300
+ energy[medium_mask],
1301
+ ctof[medium_mask],
1302
+ ebins[medium_mask],
1303
+ ancillary_files,
1304
+ )
1305
+ ebins[flat_mask] = get_ebins(
1306
+ f"l1b-{sensor[5::]}sensor-tofxeflat",
1307
+ energy[flat_mask],
1308
+ ctof[flat_mask],
1309
+ ebins[flat_mask],
1310
+ ancillary_files,
1311
+ )
1312
+
1313
+ return ebins
1314
+
1315
+
1316
+ def is_back_tof_valid(
1317
+ de_dataset: xarray.Dataset,
1318
+ xf: NDArray,
1319
+ sensor: str,
1320
+ ancillary_files: dict,
1321
+ ) -> NDArray:
1322
+ """
1323
+ Determine whether back TOF is valid based on stop type.
1324
+
1325
+ Parameters
1326
+ ----------
1327
+ de_dataset : xarray.Dataset
1328
+ Data in xarray format.
1329
+ xf : NDArray
1330
+ X front position in (hundredths of a millimeter).
1331
+ Has same length as de_dataset.
1332
+ sensor : str
1333
+ Sensor name: "ultra45" or "ultra90".
1334
+ ancillary_files : dict
1335
+ Ancillary files for lookup.
1336
+
1337
+ Returns
1338
+ -------
1339
+ valid_mask : NDArray
1340
+ Boolean array indicating whether back TOF is valid.
1341
+
1342
+ Notes
1343
+ -----
1344
+ From page 33 of the IMAP-Ultra Flight Software Specification document.
1345
+ """
1346
+ _, _, _, _, tofx, tofy = get_ph_tof_and_back_positions(
1347
+ de_dataset, xf, "ultra45", ancillary_files
1348
+ )
1349
+ diff = tofy - tofx
1350
+
1351
+ indices = np.nonzero(
1352
+ np.isin(de_dataset["stop_type"], [StopType.Top.value, StopType.Bottom.value])
1353
+ )[0]
1354
+ de_ph = de_dataset.isel(epoch=indices)
1355
+
1356
+ top_mask = de_ph["stop_type"] == StopType.Top.value
1357
+ bottom_mask = de_ph["stop_type"] == StopType.Bottom.value
1358
+
1359
+ valid = np.zeros_like(diff, dtype=bool)
1360
+
1361
+ diff_tp_min = get_image_params("TOFDiffTpMin", sensor, ancillary_files)
1362
+ diff_tp_max = get_image_params("TOFDiffTpMax", sensor, ancillary_files)
1363
+ diff_bt_min = get_image_params("TOFDiffBtMin", sensor, ancillary_files)
1364
+ diff_bt_max = get_image_params("TOFDiffBtMax", sensor, ancillary_files)
1365
+
1366
+ valid[top_mask] = (diff[top_mask] >= diff_tp_min) & (diff[top_mask] <= diff_tp_max)
1367
+ valid[bottom_mask] = (diff[bottom_mask] >= diff_bt_min) & (
1368
+ diff[bottom_mask] <= diff_bt_max
1369
+ )
1370
+
1371
+ return valid
1372
+
1373
+
1374
+ def is_coin_ph_valid(
1375
+ etof: NDArray,
1376
+ xc: NDArray,
1377
+ xb: NDArray,
1378
+ sensor: str,
1379
+ ancillary_files: dict,
1380
+ ) -> NDArray:
1381
+ """
1382
+ Determine whether Coincidence-PH data are valid.
1383
+
1384
+ This is based on thresholds defined in the IMAP-Ultra Flight Software Specification
1385
+ (see page 36).
1386
+
1387
+ Parameters
1388
+ ----------
1389
+ etof : NDArray
1390
+ Electron TOF (tenths of a nanosecond).
1391
+ xc : NDArray
1392
+ Coincidence X position (hundredths of a mm).
1393
+ xb : NDArray
1394
+ Back X position (hundredths of a mm).
1395
+ sensor : str
1396
+ Sensor name: "ultra45" or "ultra90".
1397
+ ancillary_files : dict
1398
+ Ancillary files for lookup.
1399
+
1400
+ Returns
1401
+ -------
1402
+ valid_mask : NDArray
1403
+ Boolean array indicating Coin-PH validity.
1404
+
1405
+ Notes
1406
+ -----
1407
+ Logic derived from page 36 of the IMAP-Ultra Flight Software Specification document.
1408
+ """
1409
+ etof_min = get_image_params("eTOFMin", sensor, ancillary_files)
1410
+ etof_max = get_image_params("eTOFMax", sensor, ancillary_files)
1411
+
1412
+ etof_valid = (etof >= etof_min) & (etof <= etof_max)
1413
+
1414
+ diff_x = xc - xb
1415
+ etof_offset1 = get_image_params("eTOFOff1", sensor, ancillary_files)
1416
+ etof_offset2 = get_image_params("eTOFOff2", sensor, ancillary_files)
1417
+ etof_slope1 = get_image_params("eTOFSlope1", sensor, ancillary_files)
1418
+ etof_slope2 = get_image_params("eTOFSlope2", sensor, ancillary_files)
1419
+
1420
+ t1 = (etof - etof_offset1) * etof_slope1 / 1024
1421
+ t2 = (etof - etof_offset2) * etof_slope2 / 1024
1422
+
1423
+ condition_1 = (diff_x >= t1) & (diff_x <= t2)
1424
+ condition_2 = (diff_x >= -t2) & (diff_x <= -t1)
1213
1425
 
1214
- # TODO: get these lookup tables
1215
- # if r < get_image_params("PathSteepThresh"):
1216
- # # bin = ExTOFSpeciesSteep[energy, ctof]
1217
- # elif r < get_image_params("PathMediumThresh"):
1218
- # # bin = ExTOFSpeciesMedium[energy, ctof]
1219
- # else:
1220
- # # bin = ExTOFSpeciesFlat[energy, ctof]
1426
+ spatial_valid = condition_1 | condition_2
1221
1427
 
1222
- return ebin
1428
+ return etof_valid & spatial_valid