From 93a4757cd2e0aad32531c7b53ebeb44186f6c4e3 Mon Sep 17 00:00:00 2001 From: Vineet Bansal Date: Thu, 1 Oct 2026 10:08:37 -0400 Subject: [PATCH 1/3] Lo L1B star: get spin-phase offset from IMAP_LO_STAR_SENSOR via SPICE --- imap_processing/lo/l1b/lo_l1b.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/imap_processing/lo/l1b/lo_l1b.py b/imap_processing/lo/l1b/lo_l1b.py index 47831a919..362df3968 100644 --- a/imap_processing/lo/l1b/lo_l1b.py +++ b/imap_processing/lo/l1b/lo_l1b.py @@ -22,7 +22,7 @@ SpiceFrame, cartesian_to_latitudinal, frame_transform, - get_spacecraft_to_instrument_spin_phase_offset, + get_instrument_mounting_az_el, instrument_pointing, ) from imap_processing.spice.repoint import ( @@ -2372,9 +2372,14 @@ def l1b_star( spin_duration = float(avg_spin_durations.mean().values) logger.info(f"Using spin duration from spin data: {spin_duration:.6f} s") - # TODO: Read from ancillary config file when available - sc_to_inst_angle_offset = 360 * get_spacecraft_to_instrument_spin_phase_offset( - SpiceFrame.IMAP_LO + # Use the first epoch to determine which pointing we're in + first_met = l1a_star["shcoarse"].values[0] + pointing_mid_met = get_pointing_mid_time(first_met) + + + et = ttj2000ns_to_et(met_to_ttj2000ns(pointing_mid_met)) + sc_to_inst_angle_offset = float( + get_instrument_mounting_az_el(SpiceFrame.IMAP_LO_STAR_SENSOR, et)[0] ) end_bins_to_exclude = c.STAR_END_BINS_TO_EXCLUDE min_count_threshold = c.STAR_MIN_COUNT_THRESHOLD @@ -2455,11 +2460,6 @@ def l1b_star( # Sort the dataset by spin_angle l1b_star_ds = l1b_star_ds.sortby("spin_angle") - # Add pointing mid time (MET) as a scalar value - # Use the first epoch to determine which pointing we're in - first_met = l1a_star["shcoarse"].values[0] - pointing_mid_met = get_pointing_mid_time(first_met) - # Add global start and end day of year as scalar values start_doy = epoch_to_fractional_doy(global_start_epoch) end_doy = epoch_to_fractional_doy(global_end_epoch) From 548f1c746c94cfb541449a962d13c16eafbe8c66 Mon Sep 17 00:00:00 2001 From: Vineet Bansal Date: Mon, 5 Oct 2026 14:21:05 -0400 Subject: [PATCH 2/3] test l1b_star star sensor angle offset is ~60 deg across Lo pivot angles --- imap_processing/lo/l1b/lo_l1b.py | 1 - imap_processing/tests/lo/test_lo_l1b.py | 106 ++++++++++++++++++++++-- 2 files changed, 97 insertions(+), 10 deletions(-) diff --git a/imap_processing/lo/l1b/lo_l1b.py b/imap_processing/lo/l1b/lo_l1b.py index 362df3968..8fb47030e 100644 --- a/imap_processing/lo/l1b/lo_l1b.py +++ b/imap_processing/lo/l1b/lo_l1b.py @@ -2376,7 +2376,6 @@ def l1b_star( first_met = l1a_star["shcoarse"].values[0] pointing_mid_met = get_pointing_mid_time(first_met) - et = ttj2000ns_to_et(met_to_ttj2000ns(pointing_mid_met)) sc_to_inst_angle_offset = float( get_instrument_mounting_az_el(SpiceFrame.IMAP_LO_STAR_SENSOR, et)[0] diff --git a/imap_processing/tests/lo/test_lo_l1b.py b/imap_processing/tests/lo/test_lo_l1b.py index fff2f6157..afe5067ef 100644 --- a/imap_processing/tests/lo/test_lo_l1b.py +++ b/imap_processing/tests/lo/test_lo_l1b.py @@ -1893,10 +1893,32 @@ def test_unexpected_state(self): class TestL1bStar: """Tests for l1b_star function.""" + @pytest.fixture + def pivot_angle(self): + """Lo pivot angle held by the test CK; parametrize to override.""" + return 90.0 + + @pytest.fixture + def pointing_mid_met(self, pivot_angle, lo_pivot_ck, furnish_kernels): + """ + Load the kernels l1b_star needs and return a pointing mid-time MET. + + l1b_star gets the star sensor azimuth from SPICE at the pointing + mid-time, so that time must fall inside the Lo pivot CK. + """ + kernels = [ + "naif0012.tls", + "imap_sclk_0036.tsc", + "imap_140.tf", + lo_pivot_ck(pivot_angle), + ] + with furnish_kernels(kernels): + yield float(et_to_met(str_to_et("2026-09-09T12:00:00"))) + @patch("imap_processing.lo.l1b.lo_l1b.get_pointing_mid_time") @patch("imap_processing.lo.l1b.lo_l1b.interpolate_repoint_data") def test_initializes_with_spin_data( - self, mock_repoint, mock_pointing_mid, attr_mgr_l1b + self, mock_repoint, mock_pointing_mid, attr_mgr_l1b, pointing_mid_met ): """Test successful initialization of L1B star dataset with spin data.""" # Arrange - Create 150 records to produce multiple groups @@ -1904,7 +1926,7 @@ def test_initializes_with_spin_data( mock_repoint.return_value = pd.DataFrame( {"repoint_in_progress": [False] * n_records} ) - mock_pointing_mid.return_value = 1000.0 # Mock pointing mid time in MET + mock_pointing_mid.return_value = pointing_mid_met np.random.seed(42) met_times = np.arange(n_records, dtype=np.float64) * 15.0 l1a_star = xr.Dataset( @@ -1983,17 +2005,17 @@ def test_initializes_with_spin_data( assert l1b_star_ds["avg_amplitude"].shape == (3, 720) assert l1b_star_ds["count_per_bin"].shape == (3, 720) # Check pointing_mid_met is a scalar with expected value - assert float(l1b_star_ds.attrs["pointing_mid_met"]) == 1000.0 + assert float(l1b_star_ds.attrs["pointing_mid_met"]) == pointing_mid_met @patch("imap_processing.lo.l1b.lo_l1b.get_pointing_mid_time") @patch("imap_processing.lo.l1b.lo_l1b.interpolate_repoint_data") def test_dataset_structure_and_attributes( - self, mock_repoint, mock_pointing_mid, attr_mgr_l1b + self, mock_repoint, mock_pointing_mid, attr_mgr_l1b, pointing_mid_met ): """Test that L1B star dataset has correct structure and attributes.""" # Arrange mock_repoint.return_value = pd.DataFrame({"repoint_in_progress": [False]}) - mock_pointing_mid.return_value = 1000.0 + mock_pointing_mid.return_value = pointing_mid_met l1a_star = xr.Dataset( { "count": ("epoch", [720]), @@ -2062,11 +2084,11 @@ def test_dataset_structure_and_attributes( @patch("imap_processing.lo.l1b.lo_l1b.get_pointing_mid_time") @patch("imap_processing.lo.l1b.lo_l1b.interpolate_repoint_data") def test_start_and_end_doy_variables( - self, mock_repoint, mock_pointing_mid, attr_mgr_l1b + self, mock_repoint, mock_pointing_mid, attr_mgr_l1b, pointing_mid_met ): """Test that start_doy and end_doy variables are computed correctly.""" # Arrange - mock_pointing_mid.return_value = 1000.0 # Mock pointing mid time in MET + mock_pointing_mid.return_value = pointing_mid_met mock_repoint.return_value = pd.DataFrame( {"repoint_in_progress": [False, False, False]} ) @@ -2128,12 +2150,12 @@ def test_start_and_end_doy_variables( @patch("imap_processing.lo.l1b.lo_l1b.get_pointing_mid_time") @patch("imap_processing.lo.l1b.lo_l1b.interpolate_repoint_data") def test_multiple_groups_created( - self, mock_repoint, mock_pointing_mid, attr_mgr_l1b + self, mock_repoint, mock_pointing_mid, attr_mgr_l1b, pointing_mid_met ): """Test that multiple 64-spin groups are created correctly.""" # Arrange - Create 150 records to produce 3 groups (64 + 64 + 22) n_records = 150 - mock_pointing_mid.return_value = 1000.0 # Mock pointing mid time in MET + mock_pointing_mid.return_value = pointing_mid_met mock_repoint.return_value = pd.DataFrame( {"repoint_in_progress": [False] * n_records} ) @@ -2194,6 +2216,72 @@ def test_multiple_groups_created( l1b_star_ds.coords["epoch"].values[2] == met_to_ttj2000ns([128 * 15.0])[0] ) + @pytest.mark.parametrize("pivot_angle", [65.0, 90.0, 105.0]) + @patch("imap_processing.lo.l1b.lo_l1b.get_pointing_mid_time") + @patch("imap_processing.lo.l1b.lo_l1b.interpolate_repoint_data") + def test_l1b_star_angle_offset_from_spice( + self, + mock_repoint, + mock_pointing_mid, + attr_mgr_l1b, + pivot_angle, + pointing_mid_met, + ): + """l1b_star offsets spin angles by the star sensor azimuth from SPICE.""" + n_records = 64 + mock_repoint.return_value = pd.DataFrame( + {"repoint_in_progress": [False] * n_records} + ) + met_times = np.arange(n_records, dtype=np.float64) * 15.0 + l1a_star = xr.Dataset( + { + "count": ("epoch", [720] * n_records), + "shcoarse": ("epoch", met_times), + "data": ( + ("epoch", "samples"), + np.full((n_records, 720), 150, dtype=np.uint16), + ), + }, + coords={ + "epoch": met_to_ttj2000ns(met_times), + "samples": np.arange(720), + }, + ) + l1b_nhk = xr.Dataset( + { + "ifb_data_interval": ("epoch", [21.0] * n_records), + "ifb_ctrl_star_sync": ("epoch", ["DS"] * n_records), + }, + coords={"epoch": list(range(n_records))}, + ) + spin_data = xr.Dataset( + { + "acq_start_sec": ("epoch", [0, 15]), + "acq_start_subsec": ("epoch", [0, 0]), + "acq_end_sec": ("epoch", [420, 435]), + "acq_end_subsec": ("epoch", [0, 0]), + "num_completed": ("epoch", [28, 28]), + }, + coords={"epoch": [0, 1]}, + ) + sci_dependencies = { + "imap_lo_l1a_star": l1a_star, + "imap_lo_l1b_nhk": l1b_nhk, + "imap_lo_l1a_spin": spin_data, + } + + mock_pointing_mid.return_value = pointing_mid_met + with patch( + "imap_processing.lo.l1b.lo_l1b.calculate_star_sensor_profiles_by_group", + wraps=calculate_star_sensor_profiles_by_group, + ) as mock_profiles: + l1b_star(sci_dependencies, attr_mgr_l1b, group_size=64) + + # The pivot tilts the star sensor in elevation; azimuth stays near 60 deg. + # Allow for 0.75 degrees of mounting error, as in test_geometry.py. + offset = mock_profiles.call_args.kwargs["start_angle_offset"] + np.testing.assert_allclose(offset, 60.0, atol=0.75) + def test_get_pivot_angle_from_nhk(): """Test get_pivot_angle_from_nhk function.""" From 4fde2a30251b9de93f1034ef7cb50d158ba9c70d Mon Sep 17 00:00:00 2001 From: Vineet Bansal Date: Tue, 6 Oct 2026 11:56:39 -0400 Subject: [PATCH 3/3] Lo L1B prostar: add Lo spin-start offset from the spin table --- .../config/imap_lo_l1b_variable_attrs.yaml | 2 +- imap_processing/lo/constants.py | 5 + imap_processing/lo/l1b/lo_l1b.py | 66 ++++++- imap_processing/tests/lo/test_lo_l1b.py | 177 ++++++++++++++++++ 4 files changed, 248 insertions(+), 2 deletions(-) diff --git a/imap_processing/cdf/config/imap_lo_l1b_variable_attrs.yaml b/imap_processing/cdf/config/imap_lo_l1b_variable_attrs.yaml index e56880dbe..ba4d71684 100644 --- a/imap_processing/cdf/config/imap_lo_l1b_variable_attrs.yaml +++ b/imap_processing/cdf/config/imap_lo_l1b_variable_attrs.yaml @@ -129,7 +129,7 @@ met: VAR_TYPE: support_data spin_angle: - CATDESC: Spin angle in degrees + CATDESC: Star sensor spin angle from spacecraft spin phase 0 (spin table) FIELDNAM: Spin Angle FILLVAL: -1.0e31 FORMAT: F8.3 diff --git a/imap_processing/lo/constants.py b/imap_processing/lo/constants.py index d9ee6147a..95cad5331 100644 --- a/imap_processing/lo/constants.py +++ b/imap_processing/lo/constants.py @@ -173,3 +173,8 @@ class LoConstants: STAR_END_BINS_TO_EXCLUDE: int = 2 # Minimum COUNT value for a star-sensor record to be considered valid. STAR_MIN_COUNT_THRESHOLD: int = 700 + # Sub-second ticks per second of the L1A spin start_subsec_spin field. The + # XTCE labels it microseconds, but it only ever spans 0-65535 and matches the + # spin table when read as 1/65536 s. + # TODO: confirm the units with SwRI. + SPIN_SUBSEC_PER_SEC: float = 65536.0 diff --git a/imap_processing/lo/l1b/lo_l1b.py b/imap_processing/lo/l1b/lo_l1b.py index 8fb47030e..0d0642a80 100644 --- a/imap_processing/lo/l1b/lo_l1b.py +++ b/imap_processing/lo/l1b/lo_l1b.py @@ -2294,6 +2294,67 @@ def calculate_star_sensor_profiles_by_group( return spin_angle, group_mets, avg_amplitudes, counts_per_bin +def get_lo_spin_start_phase_offset(spin_data: xr.Dataset) -> float: + """ + Get the spacecraft spin angle at which Lo starts each spin. + + Lo starts its star-sensor sampling on its own spin pulse, which lags the + spacecraft spin phase 0 of the spin table slightly. This returns that lag as + an angle, the circular mean over all spins where both Lo and the spin table + flag the phase as valid. + + Parameters + ---------- + spin_data : xr.Dataset + The L1A Spin dataset, with start_sec_spin and start_subsec_spin. + + Returns + ------- + offset : float + Spin angle [degrees] of Lo's spin start, in (-180, 180]. 0.0 if the spin + product has no spin start fields, or no spin start can be matched to the + spin table. + """ + spin_fields = ["start_sec_spin", "start_subsec_spin", "valid_phase_spin"] + missing = [field for field in spin_fields if field not in spin_data] + if missing: + logger.warning(f"Spin data lacks {missing}; using spin start offset 0.") + return 0.0 + + start_met = ( + spin_data["start_sec_spin"].values + + spin_data["start_subsec_spin"].values / c.SPIN_SUBSEC_PER_SEC + ).ravel() + valid = (spin_data["valid_phase_spin"].values.ravel() == 1) & (start_met > 0) + + # interpolate_spin_data raises on times outside the spin table, so drop them + spin_table = get_spin_data() + table_start = spin_table["spin_start_met"].values[0] + table_end = ( + spin_table["spin_start_met"].values[-1] + + spin_table["actual_spin_period"].values[-1] + ) + start_met = start_met[valid & (start_met >= table_start) & (start_met < table_end)] + if start_met.size == 0: + logger.warning("No Lo spin starts within the spin table; using offset 0.") + return 0.0 + + # sc_spin_phase is NaN where the spin table flags the phase as invalid + phase = interpolate_spin_data(start_met)["sc_spin_phase"].values + phase = phase[np.isfinite(phase)] + if phase.size == 0: + logger.warning("No valid spin-table phase at Lo spin starts; using offset 0.") + return 0.0 + + # Circular mean + offset = float(np.degrees(np.angle(np.mean(np.exp(2j * np.pi * phase))))) + logger.info( + f"Lo spin start offset from spin table: {offset:.4f} deg " + f"over {phase.size} spins" + ) + return offset + + def get_sampling_cadence_from_nhk(l1b_nhk: xr.Dataset) -> float: """ Extract ifb_data_interval from NHK dataset. @@ -2380,6 +2441,7 @@ def l1b_star( sc_to_inst_angle_offset = float( get_instrument_mounting_az_el(SpiceFrame.IMAP_LO_STAR_SENSOR, et)[0] ) + spin_start_offset = get_lo_spin_start_phase_offset(spin_data) end_bins_to_exclude = c.STAR_END_BINS_TO_EXCLUDE min_count_threshold = c.STAR_MIN_COUNT_THRESHOLD @@ -2403,7 +2465,7 @@ def l1b_star( sampling_cadence, spin_duration, group_size=group_size, - start_angle_offset=sc_to_inst_angle_offset, + start_angle_offset=sc_to_inst_angle_offset + spin_start_offset, end_bins_to_exclude=end_bins_to_exclude, min_count_threshold=min_count_threshold, bin_offset=bin_offset, @@ -2467,6 +2529,8 @@ def l1b_star( l1b_star_ds.attrs["start_doy"] = start_doy l1b_star_ds.attrs["end_doy"] = end_doy l1b_star_ds.attrs["pointing_mid_met"] = pointing_mid_met + l1b_star_ds.attrs["star_sensor_azimuth_deg"] = sc_to_inst_angle_offset + l1b_star_ds.attrs["spin_start_offset_deg"] = spin_start_offset l1b_star_ds.attrs["sampling_cadence_ms"] = sampling_cadence l1b_star_ds.attrs["spin_duration_sec"] = spin_duration l1b_star_ds.attrs["end_bins_excluded"] = end_bins_to_exclude diff --git a/imap_processing/tests/lo/test_lo_l1b.py b/imap_processing/tests/lo/test_lo_l1b.py index afe5067ef..7c719b237 100644 --- a/imap_processing/tests/lo/test_lo_l1b.py +++ b/imap_processing/tests/lo/test_lo_l1b.py @@ -1,5 +1,6 @@ import logging from collections import namedtuple +from contextlib import ExitStack from unittest.mock import patch import numpy as np @@ -24,6 +25,7 @@ create_datasets, filter_valid_star_records, get_avg_spin_durations_per_cycle, + get_lo_spin_start_phase_offset, get_median_pivot_angle, get_pivot_angle_from_nhk, get_sampling_cadence_from_nhk, @@ -2283,6 +2285,181 @@ def test_l1b_star_angle_offset_from_spice( np.testing.assert_allclose(offset, 60.0, atol=0.75) +# Tests for the Lo spin-start offset (spin table) in l1b_star. +# +# Everything happens on 2026-09-09, the day the lo_pivot_ck fixture covers. Star +# records, Lo's spins and the fake spin table all start at STAR_START_MET, one +# per 15 s spin, and the pointing mid-time is 6 hours later. +STAR_START_MET = 526_629_600.0 # ~2026-09-09T06:00 UTC +SPIN_PERIOD = 15.0 +POINTING_MID_MET = STAR_START_MET + 6 * 3600 + + +def make_lo_l1a_spin( + n_cycles: int = 2, lag_ticks: int = 0, first_spin_met: float = STAR_START_MET +) -> xr.Dataset: + """ + Build a Lo L1A spin product: `n_cycles` cycles of 28 spins each. + + Spin k starts at `first_spin_met` + 15 s * k, delayed by `lag_ticks` + sub-second ticks (1/65536 s) relative to the fake spin table's spin k. + """ + lag = lag_ticks / LoConstants.SPIN_SUBSEC_PER_SEC + spin_start = first_spin_met + SPIN_PERIOD * np.arange(n_cycles * 28) + lag + sec = np.floor(spin_start) + subsec = np.round((spin_start - sec) * LoConstants.SPIN_SUBSEC_PER_SEC) + cycle_start = first_spin_met + 28 * SPIN_PERIOD * np.arange(n_cycles) + per_spin = ("epoch", "spin") + return xr.Dataset( + { + # Per cycle: the acquisition window and how many spins completed + "acq_start_sec": ("epoch", cycle_start.astype(np.uint32)), + "acq_start_subsec": ("epoch", np.zeros(n_cycles, dtype=np.uint32)), + "acq_end_sec": ( + "epoch", + (cycle_start + 28 * SPIN_PERIOD).astype(np.uint32), + ), + "acq_end_subsec": ("epoch", np.zeros(n_cycles, dtype=np.uint32)), + "num_completed": ("epoch", np.full(n_cycles, 28)), + # Per spin: when Lo started each of the cycle's 28 spins + "start_sec_spin": (per_spin, sec.reshape(n_cycles, 28).astype(np.uint32)), + "start_subsec_spin": ( + per_spin, + subsec.reshape(n_cycles, 28).astype(np.uint32), + ), + "valid_phase_spin": (per_spin, np.ones((n_cycles, 28), dtype=np.uint32)), + }, + coords={"epoch": np.arange(n_cycles)}, + ) + + +def make_star_dependencies(n_records: int, lag_ticks: int = 0) -> dict: + """ + Build the l1b_star inputs. + + - imap_lo_l1a_star: `n_records` valid records (COUNT 720), one per spin + from STAR_START_MET, with every sample at 150 mV. + - imap_lo_l1b_nhk: a 21 ms star sampling interval and star sync "DS" + (bin-centre binning) at every star record. + - imap_lo_l1a_spin: two cycles from make_lo_l1a_spin, with Lo's spins + starting `lag_ticks` after the fake spin table's. + """ + met = STAR_START_MET + SPIN_PERIOD * np.arange(n_records) + epoch = met_to_ttj2000ns(met) + l1a_star = xr.Dataset( + { + "count": ("epoch", np.full(n_records, 720)), + "shcoarse": ("epoch", met), + "data": (("epoch", "samples"), np.full((n_records, 720), 150, np.uint16)), + }, + coords={"epoch": epoch, "samples": np.arange(720)}, + ) + l1b_nhk = xr.Dataset( + { + "ifb_data_interval": ("epoch", np.full(n_records, 21.0)), + "ifb_ctrl_star_sync": ("epoch", np.full(n_records, "DS")), + }, + coords={"epoch": epoch}, + ) + return { + "imap_lo_l1a_star": l1a_star, + "imap_lo_l1b_nhk": l1b_nhk, + "imap_lo_l1a_spin": make_lo_l1a_spin(lag_ticks=lag_ticks), + } + + +@pytest.mark.parametrize("lag_ticks", [0, 3277, -3277]) +def test_get_lo_spin_start_phase_offset(lag_ticks, use_fake_spin_data_for_time): + """The offset is how far Lo's spin starts lag the spin table's, as an angle.""" + use_fake_spin_data_for_time(STAR_START_MET) + # Start Lo one table spin in, so a negative lag stays inside the spin table + spin_data = make_lo_l1a_spin( + lag_ticks=lag_ticks, first_spin_met=STAR_START_MET + SPIN_PERIOD + ) + + offset = get_lo_spin_start_phase_offset(spin_data) + + lag_seconds = lag_ticks / LoConstants.SPIN_SUBSEC_PER_SEC + np.testing.assert_allclose(offset, 360.0 * lag_seconds / SPIN_PERIOD, atol=1e-6) + + +def test_get_lo_spin_start_phase_offset_without_spin_start_fields(caplog): + """A spin product without Lo's per-spin start times gives an offset of 0.""" + spin_data = make_lo_l1a_spin().drop_vars(["start_sec_spin", "start_subsec_spin"]) + + with caplog.at_level(logging.WARNING): + offset = get_lo_spin_start_phase_offset(spin_data) + + assert offset == 0.0 + assert "start_sec_spin" in caplog.text + + +class TestL1bStarSpinStartOffset: + """l1b_star adds Lo's spin-start offset to the star sensor azimuth.""" + + @pytest.fixture + def l1b_star_setup( + self, use_fake_spin_data_for_time, lo_pivot_ck, furnish_kernels, monkeypatch + ): + """ + Set up everything l1b_star reads apart from its inputs. + + Returns a function. Calling it with a pivot angle (default 90): + - writes a fake spin table starting at STAR_START_MET, + - loads the SPICE kernels, including a Lo pivot CK holding that angle, + - stubs the repoint table: no repoint in progress, and a pointing + mid-time of POINTING_MID_MET, inside the pivot CK. + The kernels stay loaded until the test ends. + """ + with ExitStack() as loaded_kernels: + + def setup(pivot_angle: float = 90.0) -> None: + use_fake_spin_data_for_time(STAR_START_MET) + kernels = ["naif0012.tls", "imap_sclk_0036.tsc", "imap_140.tf"] + loaded_kernels.enter_context( + furnish_kernels([*kernels, lo_pivot_ck(pivot_angle)]) + ) + monkeypatch.setattr( + "imap_processing.lo.l1b.lo_l1b.interpolate_repoint_data", + lambda met: pd.DataFrame( + {"repoint_in_progress": np.zeros(np.size(met), dtype=bool)} + ), + ) + monkeypatch.setattr( + "imap_processing.lo.l1b.lo_l1b.get_pointing_mid_time", + lambda met: POINTING_MID_MET, + ) + + yield setup + + @pytest.mark.parametrize("lag_ticks", [0, 3277]) + def test_spin_angles_include_spin_start_offset( + self, lag_ticks, l1b_star_setup, attr_mgr_l1b + ): + """The first star sample sits at azimuth + spin-start offset + half a bin.""" + l1b_star_setup() + sci_dependencies = make_star_dependencies(n_records=64, lag_ticks=lag_ticks) + + l1b_star_ds = l1b_star(sci_dependencies, attr_mgr_l1b) + + lag_seconds = lag_ticks / LoConstants.SPIN_SUBSEC_PER_SEC + expected_offset = 360.0 * lag_seconds / SPIN_PERIOD + assert l1b_star_ds.attrs["spin_start_offset_deg"] == pytest.approx( + expected_offset, abs=1e-6 + ) + # The pivot tilts the star sensor in elevation; azimuth stays near 60 deg + azimuth = l1b_star_ds.attrs["star_sensor_azimuth_deg"] + assert azimuth == pytest.approx(60.0, abs=0.75) + # Star sync "DS" puts each sample at its bin centre + deg_per_bin = 360.0 * 0.021 / SPIN_PERIOD + first_sample = l1b_star_ds["spin_angle"].where( + l1b_star_ds["spin_angle_bin"] == 0, drop=True + ) + np.testing.assert_allclose( + first_sample.values, [azimuth + expected_offset + 0.5 * deg_per_bin] + ) + + def test_get_pivot_angle_from_nhk(): """Test get_pivot_angle_from_nhk function.""" # Arrange - Create a mock NHK dataset with pivot angle information