Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions imap_processing/lo/l1b/lo_l1b.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -146,6 +147,7 @@
"esa_mode",
"exposure_time_6deg",
"spin_cycle",
"spin_angle",
]
MONITOR_RATE_FIELDS = [
"tof0_tof1_rates",
Expand Down Expand Up @@ -372,6 +374,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])
Expand Down Expand Up @@ -1669,6 +1672,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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be updated to SpiceFrame.IMAP_LO_INSTR right?

@vineetbansal vineetbansal Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right - in the process I discovered that dependencies need to be updated before I tweak this and it is merged. PR 1728 in sds-data-manager addresses this.

)
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,
Expand Down
10 changes: 9 additions & 1 deletion imap_processing/lo/l2/lo_l2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions imap_processing/tests/lo/test_lo_l2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
Loading