Skip to content

Commit 5aa6e0b

Browse files
committed
MAG L1C: support cross-day inherited timeline (T017/T018) for IMAP-Science-Operations-Center#2925
When a MAG L1C processing window has no usable normal-mode data, build the L1C timeline by inheriting cadence and phase from the previous day's MAG normal mode - a real NM L1B (preferred) or an interpolated L1C - supplied as a neighbor dependency. This covers the "time inheritance" cases T017/T018 from the SDC Data Validation Document section 4.3.3 (issue IMAP-Science-Operations-Center#2925). Every path reachable without neighbor files is unchanged. - mag_l1c.py: add derive_inherited_timeline() and a phase-aligned timeline branch in process_mag_l1c(); mag_l1c() gains a keyword-only neighbor_datasets argument. The day-window computation is shared via _day_window_ns(). - cli.py: _collect_mag_l1c_inputs() partitions MAG L1C dependency files into current-day inputs and previous-day neighbor context, by sensor and date. - Synthetic stand-in validation under tests/mag/validation/L1c/T017-T019, generated by generate_synthetic_inherited_validation.py, plus unit tests in test_mag_l1c.py and a validation test in test_mag_validation.py. MAG-approved validation data for these cases has not been delivered and is still owed; the fabricated data is clearly marked. - T019 (no NM at all) keeps the existing day-aligned burst-only behavior. The validation-document rule (first BM timestamp + half the NM sampling period) conflicts with the behavior validated by T024 / fix IMAP-Science-Operations-Center#2274, so it is captured as an xfail test pending MAG clarification rather than changed here. Companion sds-data-manager change adds date_range: ["-1d", "0d"] to the MAG L1C norm-mode L1B dependency so the previous day is delivered.
1 parent b0fe77b commit 5aa6e0b

13 files changed

Lines changed: 1458 additions & 23 deletions

imap_processing/cli.py

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1273,6 +1273,46 @@ def do_processing(
12731273
return datasets
12741274

12751275

1276+
def _collect_mag_l1c_inputs(
1277+
dependencies: ProcessingInputCollection, sensor: str, start_date: str
1278+
) -> tuple[list[xr.Dataset], list[xr.Dataset]]:
1279+
"""
1280+
Partition MAG L1C dependency files into current-day inputs and neighbor context.
1281+
1282+
Current-day L1B norm/burst files for the given sensor are the primary L1C inputs.
1283+
L1B and L1C files from other dates are returned as neighbor context for inheriting
1284+
a timeline. A same-day L1C dependency is anomalous and is ignored.
1285+
1286+
Parameters
1287+
----------
1288+
dependencies : ProcessingInputCollection
1289+
Object containing dependencies to process.
1290+
sensor : str
1291+
The MAG sensor, "mago" or "magi".
1292+
start_date : str
1293+
The processing day in YYYYMMDD format.
1294+
1295+
Returns
1296+
-------
1297+
tuple[list[xr.Dataset], list[xr.Dataset]]
1298+
The current-day input datasets and the neighboring-day context datasets.
1299+
"""
1300+
current_day_inputs: list[xr.Dataset] = []
1301+
neighbor_datasets: list[xr.Dataset] = []
1302+
for data_type in ("l1b", "l1c"):
1303+
for path in dependencies.get_file_paths(source="mag", data_type=data_type):
1304+
science_file = imap_data_access.ScienceFilePath(path.name)
1305+
if sensor not in science_file.descriptor:
1306+
continue
1307+
if science_file.start_date != start_date:
1308+
neighbor_datasets.append(load_cdf(path))
1309+
elif data_type == "l1b":
1310+
current_day_inputs.append(load_cdf(path))
1311+
else:
1312+
logger.info("Ignoring anomalous same-day MAG L1C dependency %s", path)
1313+
return current_day_inputs, neighbor_datasets
1314+
1315+
12761316
class Mag(ProcessInstrument):
12771317
"""Process MAG."""
12781318

@@ -1345,18 +1385,37 @@ def do_processing( # noqa: PLR0912
13451385
]
13461386

13471387
if self.data_level == "l1c":
1348-
science_files = dependencies.get_file_paths(source="mag", data_type="l1b")
1349-
input_data = [load_cdf(dep) for dep in science_files]
1350-
# Input datasets can be in any order, and are validated within mag_l1c
1351-
if len(input_data) == 1:
1352-
datasets = [mag_l1c(input_data[0], current_day)]
1353-
elif len(input_data) == 2:
1354-
datasets = [mag_l1c(input_data[0], current_day, input_data[1])]
1388+
# MAG L1C is per-sensor; the descriptor is "norm-mago" or "norm-magi".
1389+
if self.descriptor.endswith("mago"):
1390+
sensor = "mago"
1391+
elif self.descriptor.endswith("magi"):
1392+
sensor = "magi"
13551393
else:
13561394
raise ValueError(
1357-
f"Invalid dependencies found for MAG L1C:"
1358-
f"{dependencies}. Expected one or two dependencies."
1395+
f"Unexpected MAG L1C descriptor '{self.descriptor}'; "
1396+
f"expected norm-mago or norm-magi."
1397+
)
1398+
# Today's L1B norm/burst files are the primary inputs. The previous day's
1399+
# norm L1B/L1C files - supplied as L1C dependencies via the date_range entry
1400+
# in imap_mag_dependencies.yaml - provide timeline context (T017/T018) for
1401+
# days with no usable normal mode data.
1402+
current_day_inputs, neighbor_datasets = _collect_mag_l1c_inputs(
1403+
dependencies, sensor, self.start_date
1404+
)
1405+
if not 1 <= len(current_day_inputs) <= 2:
1406+
raise ValueError(
1407+
f"Invalid current-day L1B dependencies found for MAG L1C: "
1408+
f"{dependency_list}. Expected one or two."
13591409
)
1410+
# Datasets can be in any order, and are validated within mag_l1c.
1411+
datasets = [
1412+
mag_l1c(
1413+
current_day_inputs[0],
1414+
current_day,
1415+
current_day_inputs[1] if len(current_day_inputs) == 2 else None,
1416+
neighbor_datasets=neighbor_datasets or None,
1417+
)
1418+
]
13601419
if self.data_level == "l1d":
13611420
science_files = dependencies.get_file_paths(source="mag", data_type="l1c")
13621421
science_files.extend(

imap_processing/mag/l1c/mag_l1c.py

Lines changed: 195 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""MAG L1C processing module."""
22

33
import logging
4+
from typing import NamedTuple
45

56
import numpy as np
67
import xarray as xr
@@ -22,6 +23,8 @@ def mag_l1c(
2223
first_input_dataset: xr.Dataset,
2324
day_to_process: np.datetime64,
2425
second_input_dataset: xr.Dataset = None,
26+
*,
27+
neighbor_datasets: list[xr.Dataset] | None = None,
2528
) -> xr.Dataset:
2629
"""
2730
Will process MAG L1C data from L1A data.
@@ -40,6 +43,12 @@ def mag_l1c(
4043
The second input dataset to process. This should be burst if first_input_dataset
4144
was norm, or norm if first_input_dataset was burst. It should match the
4245
instrument - both inputs should be mago or magi.
46+
neighbor_datasets : list[xr.Dataset], optional
47+
Previous-day MAG normal mode L1B/L1C datasets, supplied as L1C dependencies via
48+
the ``date_range`` entry in ``imap_mag_dependencies.yaml``. When the current day
49+
has no usable normal mode data in the processing window, the L1C timeline
50+
inherits its cadence and phase from a neighbor (the T017/T018 "time inheritance"
51+
cases in the SDC Data Validation Document). See ``derive_inherited_timeline``.
4352
4453
Returns
4554
-------
@@ -49,8 +58,8 @@ def mag_l1c(
4958
# TODO:
5059
# find missing sequences and output them
5160
# Missing burst file - just pass through norm file
52-
# Missing norm file - go back to previous L1C file to find timestamps, then
53-
# interpolate the entire day from burst
61+
# (Missing norm file across a day boundary is handled below via neighbor_datasets:
62+
# the timeline is inherited from the previous day's normal mode - T017/T018.)
5463

5564
input_logical_source_1 = first_input_dataset.attrs["Logical_source"]
5665
if isinstance(first_input_dataset.attrs["Logical_source"], list):
@@ -63,9 +72,31 @@ def mag_l1c(
6372
first_input_dataset, second_input_dataset
6473
)
6574

75+
# When no usable normal mode data falls in the processing window, the L1C timeline
76+
# is inherited from the previous day's MAG normal mode (T017/T018), supplied as a
77+
# neighbor dependency. With no neighbor available, the burst-only fallback applies.
78+
inherited_timeline = None
79+
if neighbor_datasets:
80+
day_start_ns, day_end_ns = _day_window_ns(day_to_process)
81+
if normal_mode_dataset is None or not has_usable_norm_in_window(
82+
normal_mode_dataset, day_start_ns, day_end_ns
83+
):
84+
inherited_timeline = derive_inherited_timeline(neighbor_datasets)
85+
6686
interp_function = InterpolationFunction[configuration.L1C_INTERPOLATION_METHOD]
67-
if burst_mode_dataset is not None:
87+
if inherited_timeline is not None and burst_mode_dataset is not None:
88+
logger.info(
89+
"MAG L1C inheriting timeline from %s neighbor", inherited_timeline.source
90+
)
6891
full_interpolated_timeline: np.ndarray = process_mag_l1c(
92+
None,
93+
burst_mode_dataset,
94+
interp_function,
95+
day_to_process,
96+
inherited_timeline=inherited_timeline,
97+
)
98+
elif burst_mode_dataset is not None:
99+
full_interpolated_timeline = process_mag_l1c(
69100
normal_mode_dataset, burst_mode_dataset, interp_function, day_to_process
70101
)
71102
elif normal_mode_dataset is not None:
@@ -272,11 +303,142 @@ def select_datasets(
272303
return normal_mode_dataset, burst_mode_dataset
273304

274305

306+
class InheritedTimeline(NamedTuple):
307+
"""Cadence and phase inherited from a neighboring MAG normal-mode product."""
308+
309+
rate: int
310+
anchor_ns: int
311+
source: str
312+
313+
314+
def _day_window_ns(day_to_process: np.datetime64) -> tuple[int, int]:
315+
"""
316+
Return the L1C processing window in TTJ2000 nanoseconds.
317+
318+
The window is the processing day extended by 30 minutes on each side.
319+
320+
Parameters
321+
----------
322+
day_to_process : numpy.datetime64
323+
The day to process, in np.datetime64[D] format.
324+
325+
Returns
326+
-------
327+
tuple[int, int]
328+
The (start, end) of the processing window in TTJ2000 nanoseconds.
329+
"""
330+
day_start = day_to_process.astype("datetime64[s]") - np.timedelta64(30, "m")
331+
day_end = (
332+
day_to_process.astype("datetime64[s]")
333+
+ np.timedelta64(1, "D")
334+
+ np.timedelta64(30, "m")
335+
)
336+
return (
337+
int(et_to_ttj2000ns(str_to_et(str(day_start)))),
338+
int(et_to_ttj2000ns(str_to_et(str(day_end)))),
339+
)
340+
341+
342+
def has_usable_norm_in_window(
343+
normal_mode_dataset: xr.Dataset, day_start_ns: int, day_end_ns: int
344+
) -> bool:
345+
"""
346+
Check whether the normal mode dataset has samples inside the processing window.
347+
348+
Parameters
349+
----------
350+
normal_mode_dataset : xarray.Dataset
351+
The normal mode dataset.
352+
day_start_ns : int
353+
Start of the processing window, in TTJ2000 nanoseconds.
354+
day_end_ns : int
355+
End of the processing window, in TTJ2000 nanoseconds.
356+
357+
Returns
358+
-------
359+
bool
360+
True if at least one normal mode epoch falls within the window.
361+
"""
362+
epoch = normal_mode_dataset["epoch"].data
363+
return bool(np.any((epoch >= day_start_ns) & (epoch <= day_end_ns)))
364+
365+
366+
def derive_inherited_timeline(
367+
neighbor_datasets: list[xr.Dataset],
368+
) -> InheritedTimeline | None:
369+
"""
370+
Derive a timeline to inherit from a neighboring day's MAG product.
371+
372+
When the current day has no usable normal mode data, L1C inherits the normal-mode
373+
cadence and phase from a neighboring day. A real normal mode L1B product is
374+
preferred over an interpolated L1C product. L1B carries its own
375+
``vectors_per_second`` attribute; L1C does not, so its cadence is derived from epoch
376+
spacing.
377+
378+
Parameters
379+
----------
380+
neighbor_datasets : list[xr.Dataset]
381+
Neighboring-day MAG datasets supplied as dependencies. Normal mode L1B and L1C
382+
datasets are usable; anything else is ignored.
383+
384+
Returns
385+
-------
386+
InheritedTimeline or None
387+
The inherited cadence and phase, or None if no usable neighbor is found.
388+
"""
389+
l1c: xr.Dataset | None = None
390+
for dataset in neighbor_datasets:
391+
epoch = dataset["epoch"].data
392+
if epoch.size == 0:
393+
continue
394+
logical_source = dataset.attrs.get("Logical_source", "")
395+
if isinstance(logical_source, list):
396+
logical_source = logical_source[0]
397+
398+
if "l1b" in logical_source and "norm" in logical_source:
399+
if "vectors_per_second" not in dataset.attrs:
400+
continue
401+
vecsec_dict = vectors_per_second_from_string(
402+
dataset.attrs["vectors_per_second"]
403+
)
404+
if not vecsec_dict:
405+
continue
406+
rate = vecsec_dict[max(vecsec_dict)]
407+
try:
408+
VecSec(rate)
409+
except ValueError:
410+
continue
411+
return InheritedTimeline(
412+
rate=rate, anchor_ns=int(epoch[-1]), source="real_l1b_norm"
413+
)
414+
if l1c is None and "l1c" in logical_source and epoch.size >= 2:
415+
l1c = dataset
416+
417+
if l1c is None:
418+
return None
419+
420+
epoch = l1c["epoch"].data
421+
median_spacing = float(np.median(np.diff(epoch)))
422+
if median_spacing <= 0:
423+
return None
424+
observed_rate = 1e9 / median_spacing
425+
rate = min(
426+
(vec_sec.value for vec_sec in VecSec),
427+
key=lambda candidate: abs(candidate - observed_rate),
428+
)
429+
if abs(rate - observed_rate) > rate * L1C_TIMESTAMP_GAP_TOLERANCE:
430+
return None
431+
432+
return InheritedTimeline(rate=rate, anchor_ns=int(epoch[-1]), source="l1c")
433+
434+
275435
def process_mag_l1c(
276436
normal_mode_dataset: xr.Dataset | None,
277437
burst_mode_dataset: xr.Dataset,
278438
interpolation_function: InterpolationFunction,
279439
day_to_process: np.datetime64 | None = None,
440+
*,
441+
inherited_timeline: InheritedTimeline | None = None,
280442
) -> np.ndarray:
281443
"""
282444
Create MAG L1C data from L1B datasets.
@@ -307,6 +469,10 @@ def process_mag_l1c(
307469
The day to process, in np.datetime64[D] format. This is used to fill
308470
gaps at the beginning or end of the day if needed. If not included, these
309471
gaps will not be filled.
472+
inherited_timeline : InheritedTimeline, optional
473+
Cadence and phase inherited from a neighboring day. When provided (and there is
474+
no normal mode dataset), the whole-window timeline is built at the inherited
475+
cadence and phase instead of the default 2 vectors-per-second day grid.
310476
311477
Returns
312478
-------
@@ -315,20 +481,19 @@ def process_mag_l1c(
315481
"""
316482
day_start_ns = None
317483
day_end_ns = None
318-
319484
if day_to_process is not None:
320-
day_start = day_to_process.astype("datetime64[s]") - np.timedelta64(30, "m")
321-
322-
# get the end of the day plus 30 minutes
323-
day_end = (
324-
day_to_process.astype("datetime64[s]")
325-
+ np.timedelta64(1, "D")
326-
+ np.timedelta64(30, "m")
485+
# Processing window: the day extended by 30 minutes on each side (see
486+
# ``_day_window_ns``, also used by ``mag_l1c`` to gate timeline inheritance).
487+
day_start_ns, day_end_ns = _day_window_ns(day_to_process)
488+
489+
if inherited_timeline is not None and day_start_ns is None:
490+
# The inherited timeline is built against the day window, so day_to_process is
491+
# required here. Fail clearly instead of erroring later on a None window bound.
492+
raise ValueError(
493+
"process_mag_l1c: inherited_timeline requires day_to_process to bound the "
494+
"synthetic timeline window."
327495
)
328496

329-
day_start_ns = et_to_ttj2000ns(str_to_et(str(day_start)))
330-
day_end_ns = et_to_ttj2000ns(str_to_et(str(day_end)))
331-
332497
if normal_mode_dataset:
333498
norm_epoch = normal_mode_dataset["epoch"].data
334499
if "vectors_per_second" in normal_mode_dataset.attrs:
@@ -339,6 +504,22 @@ def process_mag_l1c(
339504
normal_vecsec_dict = None
340505

341506
gaps = find_all_gaps(norm_epoch, normal_vecsec_dict, day_start_ns, day_end_ns)
507+
elif inherited_timeline is not None:
508+
# No usable normal mode data: build a synthetic timeline at the neighbor's
509+
# cadence, phase-aligned to its anchor. interpolate_gaps() filters gap
510+
# interiors strictly, so the gap must start one cadence before the first
511+
# aligned timestamp or that first sample would never be interpolated.
512+
period_ns = int(1e9 / inherited_timeline.rate)
513+
window_start = int(np.rint(day_start_ns))
514+
window_end = int(np.rint(day_end_ns))
515+
first_aligned_ns = window_start + (
516+
(inherited_timeline.anchor_ns - window_start) % period_ns
517+
)
518+
gap_start_ns = first_aligned_ns - period_ns
519+
norm_epoch = [gap_start_ns, window_end]
520+
gaps = np.array(
521+
[[gap_start_ns, window_end, inherited_timeline.rate]], dtype=np.int64
522+
)
342523
else:
343524
norm_epoch = [day_start_ns, day_end_ns]
344525
gaps = np.array(

0 commit comments

Comments
 (0)