From 0b7ce3e751a62e622735bf28100a9836bafd2ef5 Mon Sep 17 00:00:00 2001 From: Vineet Bansal Date: Fri, 9 Oct 2026 14:46:21 -0400 Subject: [PATCH 1/2] label histogram bins with their spin angle, including the spin-start offset --- imap_processing/lo/l1b/lo_l1b.py | 44 +++++++++++++++++++++++ imap_processing/lo/l2/lo_l2.py | 10 +++++- imap_processing/tests/lo/test_lo_l2.py | 48 ++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/imap_processing/lo/l1b/lo_l1b.py b/imap_processing/lo/l1b/lo_l1b.py index 0d0642a80..2b2f2c8fd 100644 --- a/imap_processing/lo/l1b/lo_l1b.py +++ b/imap_processing/lo/l1b/lo_l1b.py @@ -146,6 +146,7 @@ "esa_mode", "exposure_time_6deg", "spin_cycle", + "spin_angle", ] MONITOR_RATE_FIELDS = [ "tof0_tof1_rates", @@ -372,6 +373,7 @@ def l1b_allrates( avg_spin_durations_per_cycle, exposure_factor, ) + l1b_all_rates = set_spin_angle(l1b_all_rates, spin_data) l1b_hist_rates, l1b_monitor_rates = split_rate_dataset(l1b_all_rates, attr_mgr_l1b) datasets_to_return.extend([l1b_hist_rates, l1b_monitor_rates]) @@ -1669,6 +1671,48 @@ def calculate_histogram_rates( return l1b_histrates +def set_spin_angle(l1b_histrates: xr.Dataset, spin_data: xr.Dataset) -> xr.Dataset: + """ + Set the spin angle each 6-degree histogram bin looks in. + + The histogram bins are counted on board from Lo's own spin pulse, not from + the instrument look direction. The center of bin i looks at the IMAP_DPS + azimuth + + (i + 0.5) * 6 + Lo mounting azimuth + Lo spin-start offset + + where the spin-start offset is the spacecraft spin angle at which Lo starts + each spin (see ``get_lo_spin_start_phase_offset``). It is taken from the spin + table, so it includes any star-tracker delay correction the spin table + carries. + + Parameters + ---------- + l1b_histrates : xr.Dataset + The L1B histogram rates dataset. + spin_data : xr.Dataset + The L1A Spin dataset. + + Returns + ------- + l1b_histrates : xr.Dataset + The L1B histogram rates dataset with the spin angle [degrees] of each + 6-degree bin center, in [0, 360). + """ + n_bins = l1b_histrates.sizes["spin_bin_6"] + bin_centers = (np.arange(n_bins) + 0.5) * (360.0 / n_bins) + mounting_offset = 360.0 * get_spacecraft_to_instrument_spin_phase_offset( + SpiceFrame.IMAP_LO + ) + spin_start_offset = get_lo_spin_start_phase_offset(spin_data) + + l1b_histrates["spin_angle"] = xr.DataArray( + np.mod(bin_centers + mounting_offset + spin_start_offset, 360.0), + dims=["spin_bin_6"], + ) + return l1b_histrates + + def calculate_de_rates( sci_dependencies: dict, anc_dependencies: list, diff --git a/imap_processing/lo/l2/lo_l2.py b/imap_processing/lo/l2/lo_l2.py index 56b5459d9..9618841f2 100644 --- a/imap_processing/lo/l2/lo_l2.py +++ b/imap_processing/lo/l2/lo_l2.py @@ -894,7 +894,15 @@ def _accumulate_pointing( logger.warning("No histogram epochs fall within the good-time windows.") return - spin_angles = _dps_spin_angles() + if "spin_angle" in histrates: + spin_angles = np.asarray(histrates["spin_angle"].values, dtype=float) + else: + logger.warning( + "Histogram rates have no spin_angle; using the nominal spin angles, " + "without Lo's spin-start offset." + ) + spin_angles = _dps_spin_angles() + keep = _spin_phase_mask(spin_angles, pivot_angle, map_descriptor.spin_phase) if not keep.any(): return diff --git a/imap_processing/tests/lo/test_lo_l2.py b/imap_processing/tests/lo/test_lo_l2.py index e8068e37e..a03c20653 100644 --- a/imap_processing/tests/lo/test_lo_l2.py +++ b/imap_processing/tests/lo/test_lo_l2.py @@ -1749,3 +1749,51 @@ def test_healpix_not_supported(self, one_pointing, anc_dependencies): anc_dependencies, "l090-enansnbs-h-sf-nsp-full-hae-nside8-3mo", ) + + +class TestHistogramSpinAngle: + """The map reads the spin angle of each bin from the histogram rates.""" + + @staticmethod + def make_map(pointing, anc_dependencies): + """The full-spin map of one pointing, with the sky pointing mocked.""" + with patch( + "imap_processing.lo.l1c.lo_l1c.frame_transform_az_el", + side_effect=identity_pointing, + ): + (dataset,) = lo_l2( + as_dependencies(pointing), anc_dependencies, FULL_DESCRIPTOR + ) + return dataset + + @staticmethod + def with_spin_angle(pointing, spin_angle): + """The pointing, with its histogram rates carrying the given spin angles.""" + histrates = pointing["histrates"].copy() + histrates["spin_angle"] = ("spin_bin_6", spin_angle) + return {**pointing, "histrates": histrates} + + def test_nominal_spin_angle_matches_fallback(self, one_pointing, anc_dependencies): + """Spin angles equal to the nominal ones give the map made without them.""" + without = self.make_map(one_pointing, anc_dependencies) + nominal = self.make_map( + self.with_spin_angle(one_pointing, _dps_spin_angles()), anc_dependencies + ) + + np.testing.assert_array_equal( + nominal["ena_count"].values, without["ena_count"].values + ) + + def test_spin_angle_moves_the_counts(self, one_pointing, anc_dependencies): + """Spin angles one map pixel (6 deg) further on move the counts with them.""" + without = self.make_map(one_pointing, anc_dependencies) + shifted = self.make_map( + self.with_spin_angle(one_pointing, np.mod(_dps_spin_angles() + 6.0, 360.0)), + anc_dependencies, + ) + + # The mocked pointing makes the spin angle the map longitude + np.testing.assert_array_equal( + shifted["ena_count"].values, + np.roll(without["ena_count"].values, 1, axis=2), + ) From 43f536035db4e26097f49c9eb0a2781eb1ef0c2b Mon Sep 17 00:00:00 2001 From: Vineet Bansal Date: Wed, 14 Oct 2026 14:19:03 -0400 Subject: [PATCH 2/2] added missing import --- imap_processing/lo/l1b/lo_l1b.py | 1 + 1 file changed, 1 insertion(+) diff --git a/imap_processing/lo/l1b/lo_l1b.py b/imap_processing/lo/l1b/lo_l1b.py index 2b2f2c8fd..e3d400067 100644 --- a/imap_processing/lo/l1b/lo_l1b.py +++ b/imap_processing/lo/l1b/lo_l1b.py @@ -23,6 +23,7 @@ cartesian_to_latitudinal, frame_transform, get_instrument_mounting_az_el, + get_spacecraft_to_instrument_spin_phase_offset, instrument_pointing, ) from imap_processing.spice.repoint import (