Skip to content

Commit 48641ea

Browse files
authored
GLOWS L2 - Add ecliptic coordinates for histogram bin centers (#2871)
* Rebase to get recent glows updates needed and add method to compute ecliptic coordinates of histogram bin centers. * Mock method that computes ecliptic lat/lon bin centers to update existing tests that were failing. Rebase and merge Maxine's spin angle work" * Add unit test for ecliptic coordinates. Move mocked ecliptic coordinates to conftest. Modify method computing ecliptic coordinates to take two args rather than the l1b dataset * Address PR comments - simplify doc strings and remove furnished kernels from a test that doesn't need it * Address PR comments - use the midpoint time so that it falls within the repointing time coverage * Rename et time variable to clearly indicate that it's the midpoint pointing time
1 parent b01ad61 commit 48641ea

File tree

4 files changed

+144
-15
lines changed

4 files changed

+144
-15
lines changed

imap_processing/glows/l2/glows_l2_data.py

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@
99
from imap_processing.glows import FLAG_LENGTH
1010
from imap_processing.glows.l1b.glows_l1b_data import PipelineSettings
1111
from imap_processing.glows.utils.constants import GlowsConstants
12-
from imap_processing.spice.geometry import SpiceFrame, get_instrument_mounting_az_el
12+
from imap_processing.spice.geometry import (
13+
SpiceFrame,
14+
frame_transform_az_el,
15+
get_instrument_mounting_az_el,
16+
)
17+
from imap_processing.spice.time import met_to_sclkticks, sct_to_et
1318

1419

1520
@dataclass
@@ -122,8 +127,6 @@ def __post_init__(self, l1b_data: xr.Dataset, position_angle: float) -> None:
122127
)
123128
else:
124129
self.histogram_flag_array = np.zeros(self.number_of_bins, dtype=np.uint8)
125-
self.ecliptic_lon = np.zeros(self.number_of_bins)
126-
self.ecliptic_lat = np.zeros(self.number_of_bins)
127130

128131
if self.number_of_bins:
129132
# imap_spin_angle_bin_cntr is the raw IMAP spin angle ψ (0 - 360°,
@@ -144,8 +147,18 @@ def __post_init__(self, l1b_data: xr.Dataset, position_angle: float) -> None:
144147
self.exposure_times = np.roll(self.exposure_times, roll)
145148
self.flux_uncertainties = np.roll(self.flux_uncertainties, roll)
146149
self.histogram_flag_array = np.roll(self.histogram_flag_array, roll)
147-
self.ecliptic_lon = np.roll(self.ecliptic_lon, roll)
148-
self.ecliptic_lat = np.roll(self.ecliptic_lat, roll)
150+
151+
# Get the midpoint start time covered by repointing kernels
152+
# needed to compute ecliptic coordinates
153+
mid_idx = len(l1b_data["imap_start_time"]) // 2
154+
pointing_midpoint_time_et = sct_to_et(
155+
met_to_sclkticks(l1b_data["imap_start_time"][mid_idx].data)
156+
)
157+
self.ecliptic_lon, self.ecliptic_lat = (
158+
self.compute_ecliptic_coords_of_bin_centers(
159+
pointing_midpoint_time_et, self.spin_angle
160+
)
161+
)
149162

150163
@staticmethod
151164
def calculate_histogram_sums(histograms: NDArray) -> NDArray:
@@ -167,6 +180,53 @@ def calculate_histogram_sums(histograms: NDArray) -> NDArray:
167180
histograms[histograms == GlowsConstants.HISTOGRAM_FILLVAL] = 0
168181
return np.sum(histograms, axis=0, dtype=np.int64)
169182

183+
@staticmethod
184+
def compute_ecliptic_coords_of_bin_centers(
185+
data_time_et: float, spin_angle_bin_centers: NDArray
186+
) -> tuple[np.ndarray, np.ndarray]:
187+
"""
188+
Compute the ecliptic coordinates of the histogram bin centers.
189+
190+
This method transforms the instrument pointing direction for each bin
191+
center from the IMAP Pointing frame (IMAP_DPS) to the ECLIPJ2000 frame.
192+
193+
Parameters
194+
----------
195+
data_time_et : float
196+
Ephemeris time corresponding to the midpoint of the histogram accumulation.
197+
198+
spin_angle_bin_centers : numpy.ndarray
199+
Spin angle bin centers for the histogram bins, measured in the IMAP frame,
200+
with shape (n_bins,), and already corrected for the northernmost point of
201+
the scanning circle.
202+
203+
Returns
204+
-------
205+
tuple[numpy.ndarray, numpy.ndarray]
206+
Longitude and latitudes in the ECLIPJ2000 frame representing the pointing
207+
direction of each histogram bin center, with shape (n_bins,).
208+
"""
209+
# In the IMAP frame, the azimuth corresponds to the spin angle bin centers
210+
azimuth = spin_angle_bin_centers
211+
212+
# Get elevation from instrument pointing direction in the DPS frame.
213+
az_el_instrument_mounting = get_instrument_mounting_az_el(SpiceFrame.IMAP_GLOWS)
214+
elevation = az_el_instrument_mounting[1]
215+
216+
# Create array of azimuth, elevation coordinates in the DPS frame (n_bins, 2)
217+
az_el = np.stack((azimuth, np.full_like(azimuth, elevation)), axis=-1)
218+
219+
# Transform coordinates to ECLIPJ2000 frame using SPICE transformations.
220+
ecliptic_coords = frame_transform_az_el(
221+
data_time_et,
222+
az_el,
223+
SpiceFrame.IMAP_DPS,
224+
SpiceFrame.ECLIPJ2000,
225+
)
226+
227+
# Return ecliptic longitudes and latitudes as separate arrays.
228+
return ecliptic_coords[:, 0], ecliptic_coords[:, 1]
229+
170230

171231
@dataclass
172232
class HistogramL2:

imap_processing/tests/glows/conftest.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
AncillaryParameters,
1414
)
1515
from imap_processing.glows.l2.glows_l2 import glows_l2
16+
from imap_processing.glows.l2.glows_l2_data import DailyLightcurve
1617

1718

1819
@pytest.fixture
@@ -278,6 +279,23 @@ def mock_pipeline_settings():
278279
return mock_pipeline_dataset
279280

280281

282+
@pytest.fixture
283+
def mock_ecliptic_bin_centers(monkeypatch):
284+
"""Mock ecliptic coordinates for bin centers."""
285+
286+
def _mock_compute_coords(
287+
_data_start_time_et: float, spin_angle: np.ndarray
288+
) -> tuple[np.ndarray, np.ndarray]:
289+
n_bins = len(spin_angle)
290+
return np.zeros(n_bins, dtype=float), np.zeros(n_bins, dtype=float)
291+
292+
monkeypatch.setattr(
293+
DailyLightcurve,
294+
"compute_ecliptic_coords_of_bin_centers",
295+
staticmethod(_mock_compute_coords),
296+
)
297+
298+
281299
def mock_update_spice_parameters(self, *args, **kwargs):
282300
self.spin_period_ground_average = np.float64(0.0)
283301
self.spin_period_ground_std_dev = np.float64(0.0)

imap_processing/tests/glows/test_glows_l2.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
from imap_processing.glows.l2.glows_l2_data import DailyLightcurve, HistogramL2
1616
from imap_processing.glows.utils.constants import GlowsConstants
1717
from imap_processing.spice.time import et_to_datetime64, ttj2000ns_to_et
18-
from imap_processing.tests.glows.conftest import mock_update_spice_parameters
18+
from imap_processing.tests.glows.conftest import (
19+
mock_update_spice_parameters,
20+
)
1921

2022

2123
@pytest.fixture
@@ -51,6 +53,7 @@ def test_glows_l2(
5153
mock_ancillary_exclusions,
5254
mock_pipeline_settings,
5355
mock_conversion_table_dict,
56+
mock_ecliptic_bin_centers,
5457
caplog,
5558
):
5659
mock_spice_function.side_effect = mock_update_spice_parameters
@@ -93,6 +96,7 @@ def test_generate_l2(
9396
mock_ancillary_exclusions,
9497
mock_pipeline_settings,
9598
mock_conversion_table_dict,
99+
mock_ecliptic_bin_centers,
96100
):
97101
mock_spice_function.side_effect = mock_update_spice_parameters
98102

imap_processing/tests/glows/test_glows_l2_data.py

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from imap_processing.glows.l1b.glows_l1b_data import PipelineSettings
88
from imap_processing.glows.l2.glows_l2_data import DailyLightcurve, HistogramL2
99
from imap_processing.glows.utils.constants import GlowsConstants
10+
from imap_processing.spice.time import met_to_sclkticks, sct_to_et
1011

1112

1213
@pytest.fixture
@@ -78,6 +79,7 @@ def l1b_dataset():
7879
"spin_period_average": (["epoch"], [15.0, 15.0]),
7980
"number_of_spins_per_block": (["epoch"], [5, 5]),
8081
"imap_spin_angle_bin_cntr": (["epoch", "bins"], spin_angle),
82+
"imap_start_time": (["epoch"], [0.0, 1.0]),
8183
"histogram_flag_array": (
8284
["epoch", "bad_angle_flags", "bins"],
8385
histogram_flag_array,
@@ -89,7 +91,48 @@ def l1b_dataset():
8991
return ds
9092

9193

92-
def test_photon_flux(l1b_dataset):
94+
@pytest.mark.external_kernel
95+
def test_ecliptic_coords_computation(furnish_kernels):
96+
"""Test method that computes ecliptic coordinates."""
97+
98+
# Use a met value within the SPICE kernel coverage (2026-01-01).
99+
data_start_time_et = sct_to_et(met_to_sclkticks(504975603.125))
100+
n_bins = 4
101+
spin_angle = np.linspace(0, 270, n_bins)
102+
103+
kernels = [
104+
"naif0012.tls",
105+
"imap_sclk_0000.tsc",
106+
"imap_130.tf",
107+
"imap_science_120.tf",
108+
"sim_1yr_imap_pointing_frame.bc",
109+
]
110+
111+
with furnish_kernels(kernels):
112+
ecliptic_lon, ecliptic_lat = (
113+
DailyLightcurve.compute_ecliptic_coords_of_bin_centers(
114+
data_start_time_et, spin_angle
115+
)
116+
)
117+
118+
# ecliptic_lon and ecliptic_lat must have one entry per bin
119+
assert len(ecliptic_lon) == n_bins
120+
assert len(ecliptic_lat) == n_bins
121+
122+
# ecliptic longitude must be in [0, 360)
123+
assert np.all(ecliptic_lon >= 0.0)
124+
assert np.all(ecliptic_lon < 360.0)
125+
126+
# ecliptic latitude must be in [-90, 90]
127+
assert np.all(ecliptic_lat >= -90.0)
128+
assert np.all(ecliptic_lat <= 90.0)
129+
130+
# values must be finite (no NaN / Inf from SPICE)
131+
assert np.all(np.isfinite(ecliptic_lon))
132+
assert np.all(np.isfinite(ecliptic_lat))
133+
134+
135+
def test_photon_flux(l1b_dataset, mock_ecliptic_bin_centers):
93136
"""Flux = sum(histograms) / sum(exposure_times) per bin (Eq. 50)."""
94137
lc = DailyLightcurve(l1b_dataset, position_angle=0.0)
95138

@@ -108,15 +151,15 @@ def test_photon_flux(l1b_dataset):
108151
assert np.allclose(lc.photon_flux, expected_flux)
109152

110153

111-
def test_flux_uncertainty(l1b_dataset):
154+
def test_flux_uncertainty(l1b_dataset, mock_ecliptic_bin_centers):
112155
"""Uncertainty = sqrt(sum_hist) / exposure per bin (Eq. 54)."""
113156
lc = DailyLightcurve(l1b_dataset, position_angle=0.0)
114157

115158
expected_uncertainty = np.sqrt(lc.raw_histograms) / lc.exposure_times
116159
assert np.allclose(lc.flux_uncertainties, expected_uncertainty)
117160

118161

119-
def test_zero_exposure_bins(l1b_dataset):
162+
def test_zero_exposure_bins(l1b_dataset, mock_ecliptic_bin_centers):
120163
"""Bins with all-masked histograms get zero flux and uncertainty.
121164
122165
Exposure time still accumulates uniformly from each good-time file even
@@ -132,16 +175,18 @@ def test_zero_exposure_bins(l1b_dataset):
132175
assert np.allclose(lc.exposure_times, expected_exposure)
133176

134177

135-
def test_number_of_bins(l1b_dataset):
178+
def test_number_of_bins(l1b_dataset, mock_ecliptic_bin_centers):
136179
lc = DailyLightcurve(l1b_dataset, position_angle=0.0)
137180
assert lc.number_of_bins == 4
138181
assert len(lc.spin_angle) == 4
139182
assert len(lc.photon_flux) == 4
140183
assert len(lc.flux_uncertainties) == 4
141184
assert len(lc.exposure_times) == 4
185+
assert len(lc.ecliptic_lon) == 4
186+
assert len(lc.ecliptic_lat) == 4
142187

143188

144-
def test_histogram_flag_array_or_propagation(l1b_dataset):
189+
def test_histogram_flag_array_or_propagation(l1b_dataset, mock_ecliptic_bin_centers):
145190
"""histogram_flag_array is OR'd across all L1B epochs and flag rows per bin.
146191
147192
Per Section 12.3.4: a flag is True in L2 if it is True in any L1B block.
@@ -163,7 +208,7 @@ def test_histogram_flag_array_or_propagation(l1b_dataset):
163208
assert lc.histogram_flag_array[3] == 0 # no flags on bin 3
164209

165210

166-
def test_histogram_flag_array_zero_epochs():
211+
def test_histogram_flag_array_zero_epochs(mock_ecliptic_bin_centers):
167212
"""histogram_flag_array is all zeros when the input dataset is empty.
168213
169214
Note: this is NEVER expected to happen in production
@@ -212,7 +257,7 @@ def test_filter_good_times():
212257
# ── spin_angle tests ──────────────────────────────────────────────────────────
213258

214259

215-
def test_spin_angle_offset_formula(l1b_dataset):
260+
def test_spin_angle_offset_formula(l1b_dataset, mock_ecliptic_bin_centers):
216261
"""spin_angle = (imap_spin_angle_bin_cntr - position_angle + 360) % 360.
217262
218263
Fixture spin_angle_bin_cntr = [0, 90, 180, 270], position_angle = 90.
@@ -224,7 +269,7 @@ def test_spin_angle_offset_formula(l1b_dataset):
224269
assert np.allclose(lc.spin_angle, expected)
225270

226271

227-
def test_spin_angle_starts_at_minimum(l1b_dataset):
272+
def test_spin_angle_starts_at_minimum(l1b_dataset, mock_ecliptic_bin_centers):
228273
"""After rolling, lc.spin_angle[0] is the minimum value.
229274
230275
Fixture spin_angle_bin_cntr = [0, 90, 180, 270], position_angle = 45.
@@ -301,7 +346,9 @@ def l1b_dataset_full():
301346
)
302347

303348

304-
def test_position_angle_offset_average(l1b_dataset_full, pipeline_settings):
349+
def test_position_angle_offset_average(
350+
l1b_dataset_full, pipeline_settings, mock_ecliptic_bin_centers
351+
):
305352
"""position_angle_offset_average is a scalar equal to the result of
306353
compute_position_angle (Eq. 30, Section 10.6). It is constant across the
307354
observational day since it depends only on instrument mounting geometry.

0 commit comments

Comments
 (0)