Skip to content
Open
4 changes: 2 additions & 2 deletions imap_processing/_version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# These version placeholders will be replaced later during substitution.
__version__ = "0.0.0"
__version_tuple__ = (0, 0, 0)
__version__ = "1.0.35.post11.dev0+ed24bc01"
__version_tuple__ = (1, 0, 35, "post11", "dev0", "ed24bc01")
148 changes: 107 additions & 41 deletions imap_processing/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
# In code:
# call cdf.utils.write_cdf
from imap_processing.codice import codice_l1a, codice_l1b, codice_l2
from imap_processing.ena_maps.utils.naming import MapDescriptor
from imap_processing.glows.l1a.glows_l1a import glows_l1a
from imap_processing.glows.l1b.glows_l1b import glows_l1b, glows_l1b_de
from imap_processing.glows.l2.glows_l2 import glows_l2
Expand Down Expand Up @@ -1212,52 +1213,109 @@ def do_processing(
class Lo(ProcessInstrument):
"""Process IMAP-Lo."""

@staticmethod
def _pointings_at_pivot_angle(
dependencies: ProcessingInputCollection, map_pivot_angle: int
) -> set[int]:
"""
Find the pointings that were taken at the pivot angle of a map.

A pointing's pivot angle is recorded in its goodtimes product, which is
also the product the other inputs of that pointing are selected by.

Parameters
----------
dependencies : ProcessingInputCollection
Object containing dependencies to process.
map_pivot_angle : int
The pivot angle [degrees] of the map being made.

Returns
-------
set[int]
The repointings whose pivot angle is that of the map.
"""
at_pivot_angle = set()
for goodtimes_path in dependencies.get_file_paths(
source="lo", descriptor="goodtimes"
):
goodtimes = load_cdf(goodtimes_path)
repointing = imap_data_access.ScienceFilePath(
goodtimes_path.name
).repointing
if "pivot" not in goodtimes:
logger.info(f"Dropping {goodtimes_path.name} - no pivot angle.")
continue

pivot_angle = np.atleast_1d(goodtimes["pivot"].values)[0]
if (
abs(pivot_angle - map_pivot_angle)
< LoConstants.PSET_PIVOT_ANGLE_TOLERANCE
):
at_pivot_angle.add(repointing)
else:
logger.info(
f"Dropping repoint{repointing}, its pivot angle {pivot_angle} "
f"is not the {map_pivot_angle} degree pivot angle of the map."
)

return at_pivot_angle

def pre_processing(self) -> ProcessingInputCollection:
"""
Complete pre-processing.

Extends the base pre-processing by filtering Lo PSET science inputs to
only those whose ``pivot_angle`` is within
``LoConstants.PSET_PIVOT_ANGLE_TOLERANCE`` of
``LoConstants.PSET_PIVOT_ANGLE``. PSET files that fall outside this
range are dropped before processing begins.
Extends the base pre-processing by dropping, for map products, the Lo
science inputs of the pointings that were not taken at the pivot angle
of the map being made. A pointing is dropped whole: its goodtimes give
the pivot angle, and its other inputs go with them.

Filtering here, rather than during processing, keeps the `Parents`
attribute of the produced map limited to the files it was made from.

Returns
-------
dependencies : ProcessingInputCollection
Object containing dependencies to process.
"""
datasets = super().pre_processing()
new_datasets = ProcessingInputCollection()
dependencies = super().pre_processing()
if self.data_level != "l2":
return dependencies

try:
map_pivot_angle = MapDescriptor.from_string(self.descriptor).sensor
except ValueError:
# Not a map product, so there is no pivot angle to select inputs with
logger.info(
f"Not filtering inputs by pivot angle, {self.descriptor} is not a "
f"map descriptor."
)
return dependencies

for processing_input in datasets.get_processing_inputs():
if not isinstance(map_pivot_angle, int):
# A map of no particular pivot angle, e.g. "ilo-ena-h-sf-nsp-ram-..."
return dependencies

at_pivot_angle = self._pointings_at_pivot_angle(dependencies, map_pivot_angle)

filtered_dependencies = ProcessingInputCollection()
for processing_input in dependencies.get_processing_inputs():
if (
processing_input.source == "lo"
and processing_input.descriptor == "pset"
processing_input.input_type != ProcessingInputType.SCIENCE_FILE
or processing_input.source != "lo"
):
valid_filenames = []
for imap_file_path in processing_input.imap_file_paths:
pset = load_cdf(imap_file_path.construct_path())
if "pivot_angle" in pset:
if (
abs(
pset["pivot_angle"].item()
- LoConstants.PSET_PIVOT_ANGLE
)
< LoConstants.PSET_PIVOT_ANGLE_TOLERANCE
):
valid_filenames.append(str(imap_file_path.filename))
else:
logger.info(
f"Dropping pset {imap_file_path.filename} because "
f"pivot angle is not in range."
)
if valid_filenames:
new_datasets.add(type(processing_input)(*valid_filenames))
else:
new_datasets.add(processing_input)
filtered_dependencies.add(processing_input)
continue

return new_datasets
kept_filenames = [
str(imap_file_path.filename)
for imap_file_path in processing_input.imap_file_paths
if imap_file_path.repointing in at_pivot_angle
]
if kept_filenames:
filtered_dependencies.add(type(processing_input)(*kept_filenames))

return filtered_dependencies

def do_processing(
self, dependencies: ProcessingInputCollection
Expand Down Expand Up @@ -1323,18 +1381,26 @@ def do_processing(
datasets = lo_l1c.lo_l1c(data_dict, anc_dependencies)

elif self.data_level == "l2":
data_dict = {}
science_files = dependencies.get_file_paths(source="lo", descriptor="pset")
anc_dependencies = dependencies.get_file_paths(data_type="ancillary")

# Load all pset files into datasets
if not science_files:
logger.info("No valid psets found for L2 processing.")
return datasets
# Load every pointing of the map window, grouped into the products
# of each pointing.
sci_dependencies: dict[int, dict[str, xr.Dataset]] = {}
for descriptor in lo_l2.REQUIRED_PRODUCTS:
for file in dependencies.get_file_paths(
source="lo", data_type="l1b", descriptor=descriptor
):
repointing = imap_data_access.ScienceFilePath(file.name).repointing
if repointing is None:
logger.warning(
f"Dropping {file.name}, it covers no single pointing."
)
continue
sci_dependencies.setdefault(repointing, {})[descriptor] = load_cdf(
file
)

psets = [load_cdf(file) for file in science_files]
data_dict[psets[0].attrs["Logical_source"]] = psets
datasets = lo_l2.lo_l2(data_dict, anc_dependencies, self.descriptor)
datasets = lo_l2.lo_l2(sci_dependencies, anc_dependencies, self.descriptor)
return datasets


Expand Down
123 changes: 106 additions & 17 deletions imap_processing/lo/constants.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,52 @@
"""Constants for IMAP-Lo."""

from dataclasses import dataclass
from typing import ClassVar
from typing import ClassVar, NamedTuple


class PivotAngleSpec(NamedTuple):
"""
Pivot angle [degrees] and associated settings for a nominal pivot index.

Attributes
----------
pointing_index : int
The unique pointing "index" (matching technical documentation for Imap-Lo)
nominal : float
Nominal pivot angle.
min : float
Lower bound of the acceptable pivot angle range.
max : float
Upper bound of the acceptable pivot angle range.
bg_rate_ram : float, optional
RAM background-rate threshold [counts/s] for this pivot angle. ``None``
if no pivot-specific value is known, in which case
``LoConstants.THRESHOLD_BG_RATE_RAM_DEFAULT`` applies.
bg_rate_anti_ram : float, optional
Anti-RAM background-rate threshold [counts/s] for this pivot angle.
``None`` if no pivot-specific value is known, in which case
``LoConstants.THRESHOLD_BG_RATE_ANTI_RAM_DEFAULT`` applies.
"""

pointing_index: int
nominal: float
min: float
max: float
bg_rate_ram: float | None = None
bg_rate_anti_ram: float | None = None


@dataclass(frozen=True)
class LoConstants:
"""Constants for Lo which can be used across different levels."""

# Expected pivot angle [degrees] for pointing sets for generating map products.
PSET_PIVOT_ANGLE: float = 90.0
# Absolute tolerance [degrees] for accepting a pset's pivot angle
# as sufficiently close to the required value.
PSET_PIVOT_ANGLE_TOLERANCE: float = 45.0
# Absolute tolerance [degrees] for accepting an input's pivot angle as
# sufficiently close to the pivot angle of the map being made.
PSET_PIVOT_ANGLE_TOLERANCE: float = 5.0

# Empirical offset [degrees] added to the measured pivot angle when
# projecting a look direction onto the RAM direction.
PIVOT_RAM_OFFSET: float = 4.0

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.

This is a magic value that Nathan is using. If this looks like a dangerous hack, I can open up an issue on this so I can fix it in an upcoming iteration.


# Ion species tracked. "H" is mandatory (and should be the first element);
# any others for which we have histrates may be added here.
Expand Down Expand Up @@ -45,6 +79,55 @@ class LoConstants:
RAM_HISTOGRAM_BINS: tuple[slice, ...] = (slice(0, 20), slice(50, 60))
ANTI_RAM_HISTOGRAM_BINS: tuple[slice, ...] = (slice(20, 50),)

# The following are indexed by ESA level (0-indexed, ESA level = index + 1).
# The 8th entry is the virtual E8 channel, unused by the map.
ESA_ENERGY: ClassVar[list[float]] = [
0.016,
0.030,
0.056,
0.106,
0.200,
0.405,
0.787,
1.821,
]
GEO_FACTOR: ClassVar[list[float]] = [

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.

These used to be in ancillary files, but only the removed code was using them. I'll open an issue so that these values are obtained from (existing) ancillary files.

7.0e-5,
7.9e-5,
9.7e-5,
11.2e-5,
14.0e-5,
17.7e-5,
22.5e-5,
6.721e-5,
]
GEO_FACTOR_ERR: ClassVar[list[float]] = [
4.9e-5,
5.5e-5,
6.8e-5,
3.0e-5,
4.5e-5,
2.0e-5,
1.4e-5,
6.721e-5,
]

# GEO_FACTOR/GEO_FACTOR_ERR above are the raw, pre-recalibration values; the
# map multiplies them by GEO_FACTOR_SCALE, and derives the asymmetric
# upper/lower G-factor bounds using the two scale factors below.
GEO_FACTOR_SCALE: float = 0.63529412
GEO_FACTOR_SCALE_UPPER: float = 1.57407407
GEO_FACTOR_SCALE_LOWER: float = 0.36728395

# Half-widths [keV] of the ESA energy passbands, by ESA level, for the two
# ESA modes. NOTE: From an e-mail from Nathan on 2025-09-11 (converted to keV).
ESA_ENERGY_DELTA: ClassVar[dict[int, list[float]]] = {
# esa_mode 0, HiRes
0: [0.00543, 0.01002, 0.01861, 0.03331, 0.06498, 0.13164, 0.26235],
# esa_mode 1, HiThr
1: [0.00881, 0.01604, 0.02850, 0.05313, 0.10560, 0.21967, 0.41360],
}

# Nominal background rates [counts/s] for each species
BG_RATES: ClassVar[dict[str, float]] = {"H": 0.0014925, "O": 0.000136635}
# When no exposure is available, scale the nominal rate down as a conservative
Expand All @@ -53,19 +136,25 @@ class LoConstants:
# Minimum non-zero background rate floor = nominal / divisor
BG_RATE_FLOOR_DIVISOR: ClassVar[dict[str, float]] = {"H": 50.0, "O": 150.0}

# Background-rate thresholds [counts/s] by pivot-angle range (low, high) [deg].
# Each value is (ram_threshold, anti_ram_threshold).
# The first matching open interval (low < pivot < high) is used; if none matches,
# THRESHOLD_BG_RATE_RAM_DEFAULT / THRESHOLD_BG_RATE_ANTI_RAM_DEFAULT apply.
PIVOT_ANGLE_THRESHOLDS: ClassVar[dict[tuple[float, float], tuple[float, float]]] = {
(88.0, 92.0): (0.028, 0.014),
(73.0, 77.0): (0.035, 0.0175),
(103.0, 107.0): (0.0224, 0.0112),
# Pivot angle specs keyed by nominal pivot angle. A measured pivot angle is
# assigned to the first spec whose [min, max] range contains it; the ranges are
# disjoint, so the ordering here does not matter.
PIVOT_ANGLES: ClassVar[dict[int, PivotAngleSpec]] = {
60: PivotAngleSpec(1, 60.0, 55.0, 65.0, None, None),
75: PivotAngleSpec(2, 75.0, 70.0, 80.0, 0.035, 0.0175),
90: PivotAngleSpec(3, 90.0, 85.0, 95.0, 0.028, 0.014),
105: PivotAngleSpec(4, 105.0, 100.0, 110.0, 0.0224, 0.0112),
120: PivotAngleSpec(5, 120.0, 115.0, 125.0, None, None),
135: PivotAngleSpec(6, 135.0, 130.0, 140.0, None, None),
148: PivotAngleSpec(7, 148.0, 143.0, 153.0, None, None),
160: PivotAngleSpec(8, 160.0, 155.0, 165.0, None, None),
}

# Default background-rate thresholds [counts/s] when no pivot range matches.
THRESHOLD_BG_RATE_RAM_DEFAULT: float = 0.0175
THRESHOLD_BG_RATE_ANTI_RAM_DEFAULT: float = 0.00875
# Default background-rate thresholds [counts/s] when the pivot angle matches no
# spec in PIVOT_ANGLES, or the matching spec has no pivot-specific value.
# Currently set to nominal values for the 90-deg pivot angle.
THRESHOLD_BG_RATE_RAM_DEFAULT: float = 0.028

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.

Default background rate thresholds have changed to match those used at 90 deg pivot, as per the dropbox code.

THRESHOLD_BG_RATE_ANTI_RAM_DEFAULT: float = 0.014

# Maximum time gap [s] between consecutive histogram epochs before treating them as
# separate intervals.
Expand Down
10 changes: 6 additions & 4 deletions imap_processing/lo/l1b/lo_l1b.py
Original file line number Diff line number Diff line change
Expand Up @@ -2521,10 +2521,12 @@ def l1b_bgrates_and_goodtimes( # noqa: PLR0912
# Choose background rate thresholds based on pivot orientation.
bg_rate_ram_nominal = c.THRESHOLD_BG_RATE_RAM_DEFAULT
bg_rate_anti_ram_nominal = c.THRESHOLD_BG_RATE_ANTI_RAM_DEFAULT
for (low, high), (ram_thresh, anti_ram_thresh) in c.PIVOT_ANGLE_THRESHOLDS.items():
if low < pivot < high:
bg_rate_ram_nominal = ram_thresh
bg_rate_anti_ram_nominal = anti_ram_thresh
for pivot_spec in c.PIVOT_ANGLES.values():
if pivot_spec.min <= pivot <= pivot_spec.max:
if pivot_spec.bg_rate_ram is not None:
bg_rate_ram_nominal = pivot_spec.bg_rate_ram
if pivot_spec.bg_rate_anti_ram is not None:
bg_rate_anti_ram_nominal = pivot_spec.bg_rate_anti_ram
break

# Manual overrides of the anti-RAM threshold for anomalous days.
Expand Down
Loading
Loading