Skip to main content

nereids_io/
runlog.rs

1//! DASlogs run-log reading and beam-state interval derivation (issue #637).
2//!
3//! SNS/HFIR NeXus files record slow-control PVs under
4//! `/entry/DASlogs/<pv>/{time, value}`.  These are **transition logs**, not
5//! uniformly-sampled time series: each `value[i]` takes effect at `time[i]`
6//! (seconds relative to run start) and persists until `time[i+1]` (the last
7//! value persists to the end of the run).  Averaging the `value` array
8//! directly is therefore wrong whenever entries are unevenly spaced — on a
9//! real VENUS run the entry-mean of the `pause` log read 0.43 while the
10//! time-weighted truth was 0.90.  [`intervals_where`] encodes the correct
11//! step-function semantics once, so callers never re-derive them.
12//!
13//! Real SNS logs occasionally contain **corrupt records**: device
14//! reconnects write entries with `time = 0.0` and an uninitialized-memory
15//! payload (a subnormal double, ~6.9e-310), observed both mid-log (where
16//! the time jumps backward) and as the leading entry (where it does not)
17//! on VENUS furnace channels `BL10:SE:ND1:*` in 3 of 59 IPTS-37432 runs.
18//! [`read_run_log`] drops entries whose time jumps backward or whose
19//! value is subnormal and reports the count in
20//! [`RunLog::n_dropped_corrupt`], so the step function is never fed
21//! garbage and the anomaly stays visible.
22//!
23//! The derived `(t_start, t_end)` interval lists feed
24//! [`crate::nexus::load_nexus_bank_spectrum`]'s `keep_intervals` event
25//! filter and compose across PVs via [`intervals_intersect`] (e.g.
26//! `pause == 0` ∩ `beam_power > 1.5 MW`).  Facility-specific PV names never
27//! appear in this crate — they belong to caller code and docstrings.
28
29use crate::error::IoError;
30use std::path::Path;
31
32/// One slow-control PV read from `/entry/DASlogs/<pv>` plus the run length.
33#[derive(Debug, Clone)]
34pub struct RunLog {
35    /// Transition times in seconds relative to run start (ascending).
36    pub times: Vec<f64>,
37    /// Value taking effect at the matching `times` entry.
38    pub values: Vec<f64>,
39    /// Total run duration in seconds (`/entry/duration`), the implicit end
40    /// of the last transition segment.
41    pub duration_s: f64,
42    /// ISO-8601 epoch of the `time` axis (the `start` or `offset` attribute),
43    /// when recorded.  Compare with
44    /// [`crate::nexus::BankSpectrum::pulse_time_offset_iso`] to confirm the
45    /// log clock and the pulse clock share a zero point (at SNS both are
46    /// seconds since run start and the attributes match exactly).
47    pub offset_iso: Option<String>,
48    /// Entries dropped as corrupt reconnect records — backward time jump
49    /// or subnormal value payload (see the module docs).  Non-zero is
50    /// worth a mention in run-health screens; retained entries are
51    /// unaffected.
52    pub n_dropped_corrupt: usize,
53}
54
55/// Read a DASlogs PV as a transition log (issue #637).
56///
57/// Reads `/entry/DASlogs/<pv>/time` and `/value` (numeric) and
58/// `/entry/duration`.  Times are seconds since run start (the same epoch
59/// the NXevent_data `event_time_zero` values are relative to, so derived
60/// intervals feed [`crate::nexus::load_nexus_bank_spectrum`] directly).
61/// Corrupt reconnect records — backward/NaN time or subnormal value
62/// payload — are dropped and counted in [`RunLog::n_dropped_corrupt`]
63/// (real SNS files contain both shapes; module docs).
64///
65/// # Errors
66/// [`IoError::FileNotFound`] when the file cannot be opened;
67/// [`IoError::InvalidParameter`] when the PV group or datasets are missing
68/// or non-numeric; [`IoError::ShapeMismatch`] when `time` and `value`
69/// lengths differ.
70pub fn read_run_log(path: &Path, pv: &str) -> Result<RunLog, IoError> {
71    let file = hdf5::File::open(path).map_err(|e| {
72        IoError::FileNotFound(
73            path.display().to_string(),
74            std::io::Error::other(e.to_string()),
75        )
76    })?;
77    let group = file.group(&format!("entry/DASlogs/{pv}")).map_err(|e| {
78        IoError::InvalidParameter(format!("Missing /entry/DASlogs/{pv} group: {e}"))
79    })?;
80    let time_ds = group
81        .dataset("time")
82        .map_err(|e| IoError::InvalidParameter(format!("Missing {pv}/time dataset: {e}")))?;
83    let times: Vec<f64> = time_ds
84        .read_1d()
85        .map_err(|e| IoError::InvalidParameter(format!("Failed to read {pv}/time: {e}")))?
86        .to_vec();
87    let offset_iso = match crate::nexus::read_string_attr(&time_ds, "start")? {
88        Some(v) => Some(v),
89        None => crate::nexus::read_string_attr(&time_ds, "offset")?,
90    };
91    let values: Vec<f64> = group
92        .dataset("value")
93        .map_err(|e| IoError::InvalidParameter(format!("Missing {pv}/value dataset: {e}")))?
94        .read_1d()
95        .map_err(|e| {
96            IoError::InvalidParameter(format!(
97                "Failed to read {pv}/value as a 1-D numeric array (string-valued and \
98                 multi-dimensional PVs are not supported): {e}"
99            ))
100        })?
101        .to_vec();
102    if times.len() != values.len() {
103        return Err(IoError::ShapeMismatch(format!(
104            "{pv}: time has {} entries but value has {}",
105            times.len(),
106            values.len()
107        )));
108    }
109    // Drop corrupt reconnect records — see module docs.  Two signatures:
110    // a backward-jumping (or NaN) time, and a subnormal value payload
111    // (0 < |v| < f64::MIN_POSITIVE — uninitialized memory, not a
112    // measurement; observed as the LEADING entry where no backward jump
113    // exists to catch it).  The count is reported, never hidden.
114    let mut clean_times = Vec::with_capacity(times.len());
115    let mut clean_values = Vec::with_capacity(values.len());
116    let mut running_max = f64::NEG_INFINITY;
117    for (&t, &v) in times.iter().zip(&values) {
118        let subnormal_payload = v != 0.0 && v.abs() < f64::MIN_POSITIVE;
119        if t.is_finite() && t >= running_max && !subnormal_payload {
120            running_max = t;
121            clean_times.push(t);
122            clean_values.push(v);
123        }
124    }
125    let n_dropped_corrupt = times.len() - clean_times.len();
126    let (times, values) = (clean_times, clean_values);
127    let duration_s: f64 = {
128        let ds = file
129            .dataset("entry/duration")
130            .map_err(|e| IoError::InvalidParameter(format!("Missing /entry/duration: {e}")))?;
131        // SNS writes duration as shape (1,); tolerate a scalar () too.
132        if ds.ndim() == 0 {
133            ds.read_scalar::<f64>()
134                .map_err(|e| IoError::InvalidParameter(format!("Failed to read duration: {e}")))?
135        } else {
136            let v: Vec<f64> = ds
137                .read_1d()
138                .map_err(|e| IoError::InvalidParameter(format!("Failed to read duration: {e}")))?
139                .to_vec();
140            *v.first()
141                .ok_or_else(|| IoError::InvalidParameter("/entry/duration is empty".to_string()))?
142        }
143    };
144    Ok(RunLog {
145        times,
146        values,
147        duration_s,
148        offset_iso,
149        n_dropped_corrupt,
150    })
151}
152
153/// Derive the run-time intervals on which a transition-log PV satisfies
154/// `min_value <= value <= max_value` (either bound optional), using the
155/// correct step-function semantics (issue #637): `values[i]` holds on
156/// `[times[i], times[i+1])`, the last value holds to `duration_s`.
157///
158/// Time before the first transition entry is treated as **not** matching
159/// (the state is unrecorded; excluding it is the conservative choice for a
160/// keep-filter).  `NaN` values never match.  Adjacent/overlapping matching
161/// segments are merged; empty segments (`t_end <= t_start`) are dropped.
162/// The final segment's end is padded by one f32 ULP above `duration_s`
163/// because SNS records `/entry/duration` in float32 while pulse times are
164/// float64 — without the pad, a keep-everything filter drops the final
165/// pulse of roughly half of real runs.
166///
167/// # Errors
168/// [`IoError::InvalidParameter`] on non-finite/negative `duration_s`,
169/// descending `times`, mismatched lengths, or a bound pair with
170/// `min_value > max_value`.
171pub fn intervals_where(
172    times: &[f64],
173    values: &[f64],
174    duration_s: f64,
175    min_value: Option<f64>,
176    max_value: Option<f64>,
177) -> Result<Vec<(f64, f64)>, IoError> {
178    if !duration_s.is_finite() || duration_s < 0.0 {
179        return Err(IoError::InvalidParameter(format!(
180            "duration_s must be finite and non-negative, got {duration_s}"
181        )));
182    }
183    if times.len() != values.len() {
184        return Err(IoError::ShapeMismatch(format!(
185            "times has {} entries but values has {}",
186            times.len(),
187            values.len()
188        )));
189    }
190    // Explicit finiteness check so a single-entry log gets the same
191    // validation as longer ones (a windows(2)-only check would let a lone
192    // NaN time through, to be silently clamped by max(0.0) below).
193    if let Some(i) = times.iter().position(|t| !t.is_finite()) {
194        return Err(IoError::InvalidParameter(format!(
195            "times[{i}] is not finite ({})",
196            times[i]
197        )));
198    }
199    if times.windows(2).any(|w| w[1] < w[0]) {
200        return Err(IoError::InvalidParameter(
201            "times must be ascending (transition log)".to_string(),
202        ));
203    }
204    if let (Some(lo), Some(hi)) = (min_value, max_value)
205        && lo > hi
206    {
207        return Err(IoError::InvalidParameter(format!(
208            "min_value ({lo}) must not exceed max_value ({hi})"
209        )));
210    }
211    let matches = |v: f64| -> bool {
212        v.is_finite() && min_value.is_none_or(|lo| v >= lo) && max_value.is_none_or(|hi| v <= hi)
213    };
214    // /entry/duration is float32-quantized in SNS files while pulse times
215    // (event_time_zero) are float64: the final pulse of ~half of surveyed
216    // real runs is time-stamped within one f32 ULP ABOVE the recorded
217    // duration.  Pad the run end by one f32 ULP so the final segment does
218    // not spuriously exclude it (issue #637).
219    let run_end = duration_s.max(((duration_s as f32).next_up()) as f64);
220    let mut out: Vec<(f64, f64)> = Vec::new();
221    for i in 0..times.len() {
222        if !matches(values[i]) {
223            continue;
224        }
225        let t0 = times[i].max(0.0);
226        let t1 = if i + 1 < times.len() {
227            times[i + 1].min(duration_s)
228        } else {
229            run_end
230        };
231        if t1 <= t0 {
232            continue;
233        }
234        match out.last_mut() {
235            // Merge segments that touch (shared transition point).
236            Some(last) if t0 <= last.1 => last.1 = last.1.max(t1),
237            _ => out.push((t0, t1)),
238        }
239    }
240    Ok(out)
241}
242
243/// Intersect two interval lists — e.g. `pause == 0` ∩ `beam_power > 1.5 MW`.
244///
245/// Inputs may be unsorted/overlapping (each side is normalised by
246/// sort-and-merge first, the same policy as
247/// [`crate::nexus::load_nexus_bank_spectrum`]'s `keep_intervals`); every
248/// pair must be finite with `t_end > t_start`.  Output is sorted,
249/// non-overlapping, and drops empty intersections.
250///
251/// # Errors
252/// [`IoError::InvalidParameter`] on a non-finite or empty/inverted pair.
253pub fn intervals_intersect(a: &[(f64, f64)], b: &[(f64, f64)]) -> Result<Vec<(f64, f64)>, IoError> {
254    let a = normalize_intervals(a)?;
255    let b = normalize_intervals(b)?;
256    let mut out = Vec::new();
257    let (mut i, mut j) = (0usize, 0usize);
258    while i < a.len() && j < b.len() {
259        let lo = a[i].0.max(b[j].0);
260        let hi = a[i].1.min(b[j].1);
261        if hi > lo {
262            out.push((lo, hi));
263        }
264        if a[i].1 <= b[j].1 {
265            i += 1;
266        } else {
267            j += 1;
268        }
269    }
270    Ok(out)
271}
272
273/// Validate interval pairs (finite, `t_end > t_start`) and normalise the
274/// list to sorted non-overlapping form by sort-and-merge.
275pub(crate) fn normalize_intervals(raw: &[(f64, f64)]) -> Result<Vec<(f64, f64)>, IoError> {
276    for &(a, b) in raw {
277        if !a.is_finite() || !b.is_finite() || b <= a {
278            return Err(IoError::InvalidParameter(format!(
279                "interval entries must be finite with t_end > t_start, got ({a}, {b})"
280            )));
281        }
282    }
283    let mut v = raw.to_vec();
284    v.sort_by(|x, y| x.0.total_cmp(&y.0));
285    let mut merged: Vec<(f64, f64)> = Vec::new();
286    for (a, b) in v {
287        match merged.last_mut() {
288            Some(last) if a <= last.1 => last.1 = last.1.max(b),
289            _ => merged.push((a, b)),
290        }
291    }
292    Ok(merged)
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    /// The motivating real-world case (issue #637): a `pause` transition log
300    /// [0, 1, 0, 1, 0, 1, 0] whose entry-mean (0.43) wildly understates the
301    /// time-weighted pause fraction (0.90).  `intervals_where(pause == 0)`
302    /// must return the four short LIVE segments, not 57 % of the run.
303    #[test]
304    fn step_semantics_pause_transition_log() {
305        let times = [0.0, 1000.0, 20000.0, 21500.0, 42589.0, 44100.0, 44338.0];
306        // value:    0     1        0        1        0        1        0
307        let values = [0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0];
308        let live = intervals_where(&times, &values, 44339.0, Some(-0.5), Some(0.5)).unwrap();
309        assert_eq!(live.len(), 4);
310        assert_eq!(live[0], (0.0, 1000.0));
311        assert_eq!(live[1], (20000.0, 21500.0));
312        assert_eq!(live[2], (42589.0, 44100.0));
313        assert_eq!(live[3].0, 44338.0);
314        assert!(live[3].1 >= 44339.0); // padded run end (f32 ULP)
315        let live_total: f64 = live.iter().map(|(a, b)| b - a).sum();
316        let entry_mean = values.iter().sum::<f64>() / values.len() as f64;
317        // Entry-mean says ~43 % paused; time-weighting says ~90 % paused.
318        assert!((entry_mean - 3.0 / 7.0).abs() < 1e-12);
319        assert!(live_total / 44339.0 < 0.11, "live fraction {live_total}");
320    }
321
322    #[test]
323    fn last_value_persists_to_duration_and_pre_log_time_excluded() {
324        // Log starts at t = 100 s: state before that is unrecorded -> excluded.
325        let iv = intervals_where(&[100.0], &[1.0], 500.0, Some(0.5), None).unwrap();
326        assert_eq!(iv.len(), 1);
327        assert_eq!(iv[0].0, 100.0);
328        assert!(iv[0].1 >= 500.0 && iv[0].1 < 500.001); // padded run end
329    }
330
331    #[test]
332    fn adjacent_matching_segments_merge() {
333        let iv =
334            intervals_where(&[0.0, 10.0, 20.0], &[1.0, 2.0, 0.0], 30.0, Some(0.5), None).unwrap();
335        assert_eq!(iv, vec![(0.0, 20.0)]);
336    }
337
338    #[test]
339    fn nan_values_never_match_and_bounds_validate() {
340        let iv = intervals_where(&[0.0, 10.0], &[f64::NAN, 1.0], 20.0, Some(0.0), None).unwrap();
341        assert_eq!(iv[0].0, 10.0);
342        assert!(iv[0].1 >= 20.0);
343        assert!(intervals_where(&[0.0], &[1.0], 10.0, Some(2.0), Some(1.0)).is_err());
344        assert!(intervals_where(&[0.0], &[1.0], f64::NAN, None, None).is_err());
345        assert!(intervals_where(&[10.0, 5.0], &[1.0, 1.0], 20.0, None, None).is_err());
346        // Single-entry logs get the same finiteness validation.
347        assert!(intervals_where(&[f64::NAN], &[1.0], 10.0, None, None).is_err());
348    }
349
350    fn create_test_daslogs(path: &std::path::Path, pv: &str, times: &[f64], values: &[f64]) {
351        let file = hdf5::File::create(path).expect("create test file");
352        let entry = file.create_group("entry").expect("entry");
353        entry
354            .new_dataset_builder()
355            .with_data(&[44339.29_f64])
356            .create("duration")
357            .expect("duration");
358        let g = entry
359            .create_group(&format!("DASlogs/{pv}"))
360            .expect("pv group");
361        let t = g
362            .new_dataset_builder()
363            .with_data(times)
364            .create("time")
365            .expect("time");
366        t.new_attr::<hdf5::types::VarLenUnicode>()
367            .create("start")
368            .expect("attr")
369            .write_scalar(
370                &"2026-06-22T19:01:07.183368667-04:00"
371                    .parse::<hdf5::types::VarLenUnicode>()
372                    .unwrap(),
373            )
374            .expect("write");
375        g.new_dataset_builder()
376            .with_data(values)
377            .create("value")
378            .expect("value");
379    }
380
381    #[test]
382    fn corrupt_reconnect_record_dropped_and_counted() {
383        // Mirror of VENUS run 19383 BL10:SE:ND1:CH1:PV — device reconnects
384        // write (time=0.0, value=subnormal garbage) records BOTH as the
385        // leading entry (no backward jump to catch it) and mid-log; the
386        // clock resumes on the run clock immediately after.
387        let dir = tempfile::tempdir().unwrap();
388        let path = dir.path().join("reconnect.h5");
389        create_test_daslogs(
390            &path,
391            "ch1",
392            &[0.0, 2.0, 1194.96, 1226.96, 0.0, 1228.99],
393            &[6.9e-310, 27.7, 27.75, 27.79, 6.9e-310, 27.78],
394        );
395        let log = read_run_log(&path, "ch1").expect("must read despite reconnect records");
396        assert_eq!(log.n_dropped_corrupt, 2);
397        assert_eq!(log.times.len(), 4);
398        assert!(!log.values.contains(&6.9e-310));
399        // The cleaned log is valid intervals_where input.
400        let iv = intervals_where(&log.times, &log.values, log.duration_s, Some(27.0), None)
401            .expect("cleaned log must be ascending");
402        assert_eq!(iv.len(), 1);
403        // The state on [0, 2) was recorded only by the garbage record, so
404        // it is treated as unrecorded — the interval starts at the first
405        // clean entry.
406        assert_eq!(iv[0].0, 2.0);
407    }
408
409    #[test]
410    fn read_run_log_round_trip_and_missing_pv() {
411        let dir = tempfile::tempdir().unwrap();
412        let path = dir.path().join("das.h5");
413        create_test_daslogs(&path, "pause", &[0.0, 3622.9, 43720.9], &[0.0, 1.0, 0.0]);
414        let log = read_run_log(&path, "pause").expect("read");
415        assert_eq!(log.times.len(), 3);
416        assert_eq!(log.n_dropped_corrupt, 0); // exact 0.0 values are NOT corrupt
417        assert_eq!(log.values, vec![0.0, 1.0, 0.0]);
418        assert!((log.duration_s - 44339.29).abs() < 1e-9);
419        assert!(log.offset_iso.unwrap().starts_with("2026-06-22"));
420        // End-to-end: transition log -> live intervals (pause == 0).
421        let live = intervals_where(
422            &log.times,
423            &log.values,
424            log.duration_s,
425            Some(-0.5),
426            Some(0.5),
427        )
428        .unwrap();
429        assert_eq!(live.len(), 2);
430        assert!((live[0].1 - 3622.9).abs() < 1e-9);
431        assert!(live[1].1 >= 44339.29); // padded run end
432        // Missing PV is a clear error.
433        assert!(read_run_log(&path, "no_such_pv").is_err());
434    }
435
436    #[test]
437    fn final_pulse_survives_f32_duration_quantization() {
438        // Real shape (VENUS run 19373): /entry/duration is f32
439        // (10.883509635925293 after promotion) while the last pulse's
440        // event_time_zero is f64 10.88351 — strictly above it.  The final
441        // segment must still contain that pulse.
442        let duration_f32 = 10.883_51_f32 as f64; // 10.883509635925293
443        let last_pulse = 10.883_51_f64;
444        assert!(last_pulse > duration_f32);
445        let iv = intervals_where(&[0.0], &[0.0], duration_f32, Some(-0.5), Some(0.5)).unwrap();
446        assert_eq!(iv.len(), 1);
447        assert!(
448            iv[0].1 > last_pulse,
449            "padded end {} <= pulse {last_pulse}",
450            iv[0].1
451        );
452    }
453
454    #[test]
455    fn intersect_basic_disjoint_nested_touching_and_unsorted() {
456        let a = [(0.0, 10.0), (20.0, 30.0)];
457        let b = [(5.0, 25.0)];
458        assert_eq!(
459            intervals_intersect(&a, &b).unwrap(),
460            vec![(5.0, 10.0), (20.0, 25.0)]
461        );
462        assert_eq!(intervals_intersect(&a, &[]).unwrap(), vec![]);
463        // Touching endpoints produce no (empty) interval.
464        assert_eq!(
465            intervals_intersect(&[(0.0, 5.0)], &[(5.0, 9.0)]).unwrap(),
466            vec![]
467        );
468        // Nested.
469        assert_eq!(
470            intervals_intersect(&[(0.0, 100.0)], &[(10.0, 20.0), (30.0, 40.0)]).unwrap(),
471            vec![(10.0, 20.0), (30.0, 40.0)]
472        );
473        // Unsorted input is normalised, not silently corrupted.
474        assert_eq!(
475            intervals_intersect(&[(20.0, 30.0), (0.0, 10.0)], &[(5.0, 25.0)]).unwrap(),
476            vec![(5.0, 10.0), (20.0, 25.0)]
477        );
478        // Sorted-but-overlapping input merges; postcondition holds.
479        assert_eq!(
480            intervals_intersect(&[(0.0, 10.0), (5.0, 20.0)], &[(0.0, 20.0)]).unwrap(),
481            vec![(0.0, 20.0)]
482        );
483        // Invalid pairs error.
484        assert!(intervals_intersect(&[(5.0, 5.0)], &[(0.0, 1.0)]).is_err());
485        assert!(intervals_intersect(&[(f64::NAN, 1.0)], &[(0.0, 1.0)]).is_err());
486    }
487}