11"""MAG L1C processing module."""
22
33import logging
4+ from typing import NamedTuple
45
56import numpy as np
67import 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+
275435def 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