Skip to main content

nereids_io/
daslogs.rs

1//! DASlogs-based run-health summary for SNS NeXus files (`hdf5` feature).
2//!
3//! A VENUS acquisition can be paused mid-run or suffer accelerator beam
4//! dips; both silently reduce the effective exposure of the summed TIFF
5//! stack.  [`run_health`] reads the slow-control logs under
6//! `/entry/DASlogs/<pv>/{time,value}` and reports the time-weighted
7//! fraction of the run spent paused and the fraction spent with the beam
8//! power dipped below a threshold.
9//!
10//! ## Transition logs, not samples
11//!
12//! DASlogs PVs log *transitions*: an entry is written when the value
13//! changes, not on a regular clock.  Taking the plain mean of the logged
14//! entries is therefore wrong (a run paused for 90 % of its duration may
15//! contain just two pause entries).  All statistics here use
16//! **last-value-held** time-weighted integration instead: `value[0]` holds
17//! from `t = 0`, each `value[i]` holds until the next log entry, and the
18//! final value holds to the end of the run.  Intervals are clamped to
19//! `[0, duration]` (DASlogs timestamps can precede the run start).
20//!
21//! ## Run duration
22//!
23//! The window length comes from the `/entry/duration` scalar when present.
24//! Otherwise it falls back to the latest log timestamp across the PVs that
25//! were read — a *lower bound* on the true run duration (the run continued
26//! after the last logged transition), which makes the reported fractions
27//! upper bounds.  A non-positive or non-finite window is a hard error,
28//! never a NaN fraction.
29//!
30//! ## SNS PV names
31//!
32//! The defaults target SNS: `pause` (nonzero while the DAQ is paused) and
33//! `proton_charge` (per-pulse beam power proxy).  Other facilities pass
34//! their own PV names via [`RunHealthOptions`].
35
36use std::path::Path;
37
38use crate::error::IoError;
39
40/// Default beam-dip threshold as a fraction of the median power.
41///
42/// A dip is counted when the power PV drops below
43/// `power_dip_fraction × median(power)`.  0.5 sits between nominal
44/// source jitter (a few percent around the median) and true beam-off
45/// dips (power → 0), so it flags real outages without firing on noise.
46pub const DEFAULT_POWER_DIP_FRACTION: f64 = 0.5;
47
48/// Options for [`run_health`]: PV names and the beam-dip threshold.
49#[derive(Debug, Clone)]
50pub struct RunHealthOptions {
51    /// DASlogs PV that is nonzero while the DAQ is paused
52    /// (SNS: `"pause"`).
53    pub pause_pv: String,
54    /// DASlogs PV proxying beam power (SNS: `"proton_charge"`).
55    pub power_pv: String,
56    /// Beam-dip threshold as a fraction of the median power
57    /// (default [`DEFAULT_POWER_DIP_FRACTION`]).
58    pub power_dip_fraction: f64,
59}
60
61impl Default for RunHealthOptions {
62    fn default() -> Self {
63        Self {
64            pause_pv: "pause".into(),
65            power_pv: "proton_charge".into(),
66            power_dip_fraction: DEFAULT_POWER_DIP_FRACTION,
67        }
68    }
69}
70
71/// Run-health summary computed from DASlogs.
72///
73/// Fields are `None` when the corresponding quantity *cannot be
74/// computed*: the PV (or the whole `DASlogs` group) is absent from the
75/// file, the PV is present but logged zero entries, or — for
76/// [`beam_dip_fraction`](Self::beam_dip_fraction) only — the dip
77/// threshold is undefined.  None of these is an error; they simply mean
78/// the facility did not log that quantity (or logged nothing usable).
79#[derive(Debug, Clone, PartialEq)]
80pub struct RunHealth {
81    /// Time-weighted fraction of the run spent paused (pause PV nonzero).
82    pub pause_fraction: Option<f64>,
83    /// Time-weighted fraction of the run with power below
84    /// `power_dip_fraction × median(power)`.
85    ///
86    /// `None` when the power PV is absent or empty, **or when the dip
87    /// threshold is undefined because the sample median of the power
88    /// entries is non-positive** (e.g. the beam was off for at least half
89    /// the log entries → median = 0, so the strict `< threshold` predicate
90    /// could never fire and would misreport the worst runs as dip-free) —
91    /// check [`median_power`](Self::median_power), which is co-reported.
92    pub beam_dip_fraction: Option<f64>,
93    /// Sample median of the power PV entries (median of the logged
94    /// values, *not* time-weighted — documented deliberately: it is the
95    /// threshold anchor, not an exposure estimate).
96    pub median_power: Option<f64>,
97    /// Run duration in seconds: `/entry/duration` when present, else the
98    /// latest log timestamp across the PVs read (a lower bound).
99    pub duration_s: Option<f64>,
100    /// Number of pause-PV log entries read (0 when absent or empty).
101    pub n_pause_entries: usize,
102    /// Number of power-PV log entries read (0 when absent or empty).
103    pub n_power_entries: usize,
104}
105
106/// Compute a run-health summary from `/entry/DASlogs` of a NeXus file.
107///
108/// See the [module docs](self) for the last-value-held semantics, the
109/// duration fallback, and the SNS PV-name defaults.
110///
111/// # Errors
112/// * [`IoError::Hdf5Error`] when the file or `/entry` cannot be opened.
113/// * [`IoError::InvalidParameter`] when a PV is *present but malformed*
114///   (time/value length mismatch, non-finite entries, negative power
115///   values, decreasing timestamps), `/entry/duration` is present but
116///   non-positive or non-finite, the integration window is non-positive,
117///   or `power_dip_fraction` is not a positive finite number.
118///
119/// Absent PVs (or an absent `DASlogs` group) are *not* errors — the
120/// corresponding fields are `None`, as is a present PV with zero log
121/// entries ("no entries logged" carries no integrable information).
122/// Absence vs malformed is decided by link existence (`member_names`),
123/// the `read_dead_pixel_mask` idiom: collapsing "not there" and "there
124/// but unreadable" into one path would mask real file corruption as
125/// absence.
126///
127/// `beam_dip_fraction` is additionally `None` (with `median_power` still
128/// reported) when the sample median of the power entries is non-positive:
129/// the dip threshold `power_dip_fraction × median` is then undefined —
130/// see [`RunHealth::beam_dip_fraction`].
131pub fn run_health(path: &Path, options: &RunHealthOptions) -> Result<RunHealth, IoError> {
132    if !options.power_dip_fraction.is_finite() || options.power_dip_fraction <= 0.0 {
133        return Err(IoError::InvalidParameter(format!(
134            "power_dip_fraction must be a positive finite number, got {}",
135            options.power_dip_fraction,
136        )));
137    }
138
139    let file = hdf5::File::open(path)
140        .map_err(|e| IoError::Hdf5Error(format!("Cannot open HDF5 file: {e}")))?;
141    let entry = file
142        .group("entry")
143        .map_err(|e| IoError::Hdf5Error(format!("Cannot open /entry: {e}")))?;
144    let entry_members = entry
145        .member_names()
146        .map_err(|e| IoError::InvalidParameter(format!("Failed to list /entry members: {e}")))?;
147
148    // /entry/duration: absent → fall back to log timestamps below;
149    // present but malformed → hard error.
150    let file_duration = if entry_members.iter().any(|n| n == "duration") {
151        let ds = entry.dataset("duration").map_err(|e| {
152            IoError::InvalidParameter(format!(
153                "/entry/duration is present but is not a readable dataset: {e}"
154            ))
155        })?;
156        let d = read_scalar_f64(&ds, "/entry/duration")?;
157        if !d.is_finite() || d <= 0.0 {
158            return Err(IoError::InvalidParameter(format!(
159                "/entry/duration must be positive and finite, got {d}"
160            )));
161        }
162        Some(d)
163    } else {
164        None
165    };
166
167    // /entry/DASlogs: absent → all-None health (valid file without logs).
168    let daslogs = if entry_members.iter().any(|n| n == "DASlogs") {
169        Some(entry.group("DASlogs").map_err(|e| {
170            IoError::InvalidParameter(format!(
171                "/entry/DASlogs is present but is not a readable group: {e}"
172            ))
173        })?)
174    } else {
175        None
176    };
177
178    // The pause PV is a state flag (any nonzero value means "paused", sign
179    // included), but beam power is physically non-negative — a negative
180    // entry is malformed data, not a dip, so it takes the hard-error path.
181    let pause = match &daslogs {
182        Some(group) => read_pv_series(group, &options.pause_pv, false)?,
183        None => None,
184    };
185    let power = match &daslogs {
186        Some(group) => read_pv_series(group, &options.power_pv, true)?,
187        None => None,
188    };
189
190    let n_pause_entries = pause.as_ref().map_or(0, |(t, _)| t.len());
191    let n_power_entries = power.as_ref().map_or(0, |(t, _)| t.len());
192
193    // Duration: file value, else the latest timestamp across PVs read
194    // (a lower bound — see module docs).
195    let duration_s = file_duration.or_else(|| {
196        let last_times = [
197            pause.as_ref().and_then(|(t, _)| t.last().copied()),
198            power.as_ref().and_then(|(t, _)| t.last().copied()),
199        ];
200        last_times.into_iter().flatten().fold(None, |acc, t| {
201            Some(match acc {
202                None => t,
203                Some(a) if t > a => t,
204                Some(a) => a,
205            })
206        })
207    });
208
209    // Nothing to integrate → report what we know (all-None fractions).
210    if pause.is_none() && power.is_none() {
211        return Ok(RunHealth {
212            pause_fraction: None,
213            beam_dip_fraction: None,
214            median_power: None,
215            duration_s,
216            n_pause_entries: 0,
217            n_power_entries: 0,
218        });
219    }
220
221    // A PV was read, so integration needs a usable window.  Non-positive
222    // windows (e.g. a single log entry at t = 0 and no /entry/duration)
223    // are a hard error — dividing by them would fabricate NaN/∞ fractions.
224    let duration = duration_s.ok_or_else(|| {
225        IoError::InvalidParameter("Cannot determine run duration for DASlogs integration".into())
226    })?;
227    if !duration.is_finite() || duration <= 0.0 {
228        return Err(IoError::InvalidParameter(format!(
229            "Run duration for DASlogs integration must be positive, got {duration}"
230        )));
231    }
232
233    let pause_fraction = pause
234        .as_ref()
235        .map(|(time, value)| lvh_fraction(time, value, duration, |v| v != 0.0));
236
237    let (median_power, beam_dip_fraction) = match &power {
238        Some((time, value)) => {
239            let median = sample_median(value);
240            // The dip threshold `power_dip_fraction × median` is undefined
241            // when the median is non-positive (e.g. the beam was off for at
242            // least half the log entries → median = 0): the strict `<`
243            // predicate could never fire, misreporting exactly the worst
244            // runs as beam_dip_fraction = 0.0.  Report "cannot compute"
245            // (None) instead — the struct's established signal — with
246            // `median_power` co-reported so callers can see why.  Values
247            // are validated finite and non-negative on read, so the
248            // is_finite() arm is defense-in-depth only.
249            if !median.is_finite() || median <= 0.0 {
250                (Some(median), None)
251            } else {
252                let threshold = options.power_dip_fraction * median;
253                let dip = lvh_fraction(time, value, duration, |v| v < threshold);
254                (Some(median), Some(dip))
255            }
256        }
257        None => (None, None),
258    };
259
260    Ok(RunHealth {
261        pause_fraction,
262        beam_dip_fraction,
263        median_power,
264        duration_s: Some(duration),
265        n_pause_entries,
266        n_power_entries,
267    })
268}
269
270/// Read a scalar f64 from a dataset that may be stored 0-dimensional or as
271/// a 1-element vector (both encodings occur in the wild for
272/// `/entry/duration`).
273fn read_scalar_f64(ds: &hdf5::Dataset, what: &str) -> Result<f64, IoError> {
274    if ds.ndim() == 0 {
275        return ds
276            .read_scalar::<f64>()
277            .map_err(|e| IoError::InvalidParameter(format!("Failed to read {what}: {e}")));
278    }
279    let values: Vec<f64> = ds
280        .read_raw()
281        .map_err(|e| IoError::InvalidParameter(format!("Failed to read {what}: {e}")))?;
282    if values.len() != 1 {
283        return Err(IoError::InvalidParameter(format!(
284            "{what} must be a scalar, got {} values",
285            values.len()
286        )));
287    }
288    Ok(values[0])
289}
290
291/// Parallel `time`/`value` series of one DASlogs PV.
292type PvSeries = (Vec<f64>, Vec<f64>);
293
294/// Read `<group>/<pv>/{time,value}` as parallel 1D f64 series.
295///
296/// Returns `Ok(None)` when the PV link is absent (valid file without that
297/// log) **or** when the PV is present but both series are empty — "no
298/// entries logged" carries no integrable information, so it is treated
299/// like absence rather than corruption (a hard error here would discard
300/// the other PV's summary).  Returns `Err` when the PV is present but
301/// malformed: unreadable group/datasets, length-mismatched series,
302/// non-finite entries, negative values when `require_non_negative` is set
303/// (physically non-negative PVs such as beam power), or decreasing
304/// timestamps.  Duplicate (equal) timestamps are allowed — real SNS logs
305/// contain them; they contribute zero-width intervals.
306fn read_pv_series(
307    daslogs: &hdf5::Group,
308    pv: &str,
309    require_non_negative: bool,
310) -> Result<Option<PvSeries>, IoError> {
311    let members = daslogs.member_names().map_err(|e| {
312        IoError::InvalidParameter(format!("Failed to list /entry/DASlogs members: {e}"))
313    })?;
314    if !members.iter().any(|n| n == pv) {
315        return Ok(None);
316    }
317    let group = daslogs.group(pv).map_err(|e| {
318        IoError::InvalidParameter(format!(
319            "/entry/DASlogs/{pv} is present but is not a readable group: {e}"
320        ))
321    })?;
322
323    let read_series = |name: &str| -> Result<Vec<f64>, IoError> {
324        let ds = group.dataset(name).map_err(|e| {
325            IoError::InvalidParameter(format!(
326                "/entry/DASlogs/{pv}/{name} is missing or unreadable: {e}"
327            ))
328        })?;
329        ds.read_raw::<f64>().map_err(|e| {
330            IoError::InvalidParameter(format!("Failed to read /entry/DASlogs/{pv}/{name}: {e}"))
331        })
332    };
333    let time = read_series("time")?;
334    let value = read_series("value")?;
335
336    // Present-but-empty (zero entries in BOTH series) is "no entries
337    // logged", semantically closer to an absent PV than to corruption —
338    // map to Ok(None) so the other PV's summary survives.  An empty time
339    // with a non-empty value (or vice versa) still falls through to the
340    // length-mismatch error below: that IS corruption.
341    if time.is_empty() && value.is_empty() {
342        return Ok(None);
343    }
344    if time.len() != value.len() {
345        return Err(IoError::InvalidParameter(format!(
346            "/entry/DASlogs/{pv}: time has {} entries but value has {}",
347            time.len(),
348            value.len(),
349        )));
350    }
351
352    // Drop corrupt device-reconnect records (issue #652, B12).  Real SNS
353    // files — furnace channels BL10:SE:ND1:* on runs 19383/19386/19392 —
354    // contain reconnect entries with the exact signature `time == 0.0` AND
355    // a subnormal uninitialized-memory value (~6.9e-310).  A subnormal is
356    // `is_finite() == true`, so without this guard the leading such record
357    // would silently enter the power median and the time-weighted
358    // integration.  These are also the records that produce the backward
359    // `time = 0.0` jump mid-log, so dropping them ALSO removes the spurious
360    // non-ascending step — `run_health` then works on the very furnace PV
361    // a user asked about, instead of failing the ascending-time check
362    // below.  Both parts of the signature are required so a legitimate
363    // (if physically implausible) subnormal reading at a NORMAL timestamp
364    // is not silently discarded; and a genuine non-subnormal backward jump
365    // is still NOT dropped — it trips the ascending-time hard error, so
366    // real scrambled logs stay loud.  Matches `read_run_log`'s policy on
367    // the leading-record case.
368    let (time, value): (Vec<f64>, Vec<f64>) = time
369        .iter()
370        .zip(&value)
371        .filter(|&(&t, &v)| !(t == 0.0 && v != 0.0 && v.abs() < f64::MIN_POSITIVE))
372        .map(|(&t, &v)| (t, v))
373        .unzip();
374    // Every entry was a corrupt reconnect record → treat as "no usable log"
375    // (same as an absent/empty PV) so the other PV's summary survives.
376    if time.is_empty() {
377        return Ok(None);
378    }
379    for (i, &t) in time.iter().enumerate() {
380        if !t.is_finite() {
381            return Err(IoError::InvalidParameter(format!(
382                "/entry/DASlogs/{pv}/time[{i}] is non-finite ({t})"
383            )));
384        }
385    }
386    for (i, &v) in value.iter().enumerate() {
387        if !v.is_finite() {
388            return Err(IoError::InvalidParameter(format!(
389                "/entry/DASlogs/{pv}/value[{i}] is non-finite ({v})"
390            )));
391        }
392        if require_non_negative && v < 0.0 {
393            return Err(IoError::InvalidParameter(format!(
394                "/entry/DASlogs/{pv}/value[{i}] is negative ({v}); \
395                 this PV is physically non-negative (e.g. beam power), so \
396                 a negative entry is malformed data"
397            )));
398        }
399    }
400    for window in time.windows(2) {
401        if window[1] < window[0] {
402            return Err(IoError::InvalidParameter(format!(
403                "/entry/DASlogs/{pv}/time must be ascending, but found {} after {}",
404                window[1], window[0],
405            )));
406        }
407    }
408
409    Ok(Some((time, value)))
410}
411
412/// Last-value-held time-weighted fraction of `[0, duration]` where
413/// `pred(value)` holds.
414///
415/// `value[0]` holds from `t = 0` (the value before the first logged
416/// transition is taken to be the first logged value), `value[i]` holds
417/// over `[time[i], time[i+1])`, and the final value holds to `duration`.
418/// Interior interval bounds are clamped to `[0, duration]`, so the
419/// interval widths telescope to exactly `duration` and the result lies in
420/// `[0, 1]`.
421fn lvh_fraction(time: &[f64], value: &[f64], duration: f64, pred: impl Fn(f64) -> bool) -> f64 {
422    debug_assert!(!time.is_empty() && time.len() == value.len());
423    debug_assert!(duration.is_finite() && duration > 0.0);
424    let n = time.len();
425    let mut held = 0.0;
426    for i in 0..n {
427        let start = if i == 0 {
428            0.0
429        } else {
430            time[i].clamp(0.0, duration)
431        };
432        let end = if i + 1 < n {
433            time[i + 1].clamp(0.0, duration)
434        } else {
435            duration
436        };
437        if pred(value[i]) {
438            held += (end - start).max(0.0);
439        }
440    }
441    held / duration
442}
443
444/// Sample median of the logged values (mean of the two middle order
445/// statistics for even counts).  This is the median of the log *entries*,
446/// not a time-weighted median — it anchors the dip threshold and must not
447/// be read as an exposure-weighted average.
448fn sample_median(values: &[f64]) -> f64 {
449    debug_assert!(!values.is_empty());
450    let mut sorted = values.to_vec();
451    sorted.sort_by(|a, b| a.partial_cmp(b).expect("values validated finite"));
452    let n = sorted.len();
453    if n % 2 == 1 {
454        sorted[n / 2]
455    } else {
456        f64::midpoint(sorted[n / 2 - 1], sorted[n / 2])
457    }
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    /// Write a minimal NeXus file with an optional `/entry/duration` and
465    /// the given DASlogs PVs as parallel `time`/`value` series.
466    fn write_run(path: &Path, duration: Option<f64>, pvs: &[(&str, &[f64], &[f64])]) {
467        let file = hdf5::File::create(path).expect("create test file");
468        let entry = file.create_group("entry").expect("create entry");
469        if let Some(d) = duration {
470            entry
471                .new_dataset::<f64>()
472                .shape(())
473                .create("duration")
474                .expect("create duration")
475                .write_scalar(&d)
476                .expect("write duration");
477        }
478        let das = entry.create_group("DASlogs").expect("create DASlogs");
479        for (name, time, value) in pvs {
480            let group = das.create_group(name).expect("create pv group");
481            group
482                .new_dataset::<f64>()
483                .shape([time.len()])
484                .create("time")
485                .expect("create time")
486                .write_raw(time)
487                .expect("write time");
488            group
489                .new_dataset::<f64>()
490                .shape([value.len()])
491                .create("value")
492                .expect("create value")
493                .write_raw(value)
494                .expect("write value");
495        }
496    }
497
498    /// T32: pause 0@0 / 1@10 / 0@20 over a 100 s run → 10 % paused.
499    #[test]
500    fn test_pause_fraction_basic() {
501        let dir = tempfile::tempdir().unwrap();
502        let path = dir.path().join("run.h5");
503        write_run(
504            &path,
505            Some(100.0),
506            &[("pause", &[0.0, 10.0, 20.0], &[0.0, 1.0, 0.0])],
507        );
508
509        let health = run_health(&path, &RunHealthOptions::default()).unwrap();
510        assert_eq!(health.pause_fraction, Some(0.1));
511        assert_eq!(health.duration_s, Some(100.0));
512        assert_eq!(health.n_pause_entries, 3);
513        assert_eq!(health.beam_dip_fraction, None);
514        assert_eq!(health.median_power, None);
515        assert_eq!(health.n_power_entries, 0);
516    }
517
518    /// T33: absent pause PV → pause fields None, not an error.
519    #[test]
520    fn test_absent_pause_pv_is_none() {
521        let dir = tempfile::tempdir().unwrap();
522        let path = dir.path().join("run.h5");
523        write_run(
524            &path,
525            Some(100.0),
526            &[("proton_charge", &[0.0, 50.0], &[10.0, 10.0])],
527        );
528
529        let health = run_health(&path, &RunHealthOptions::default()).unwrap();
530        assert_eq!(health.pause_fraction, None);
531        assert_eq!(health.n_pause_entries, 0);
532        assert!(health.beam_dip_fraction.is_some());
533    }
534
535    /// T34: a single pause=1 entry holds over the whole window → 1.0.
536    #[test]
537    fn test_single_entry_holds_whole_window() {
538        let dir = tempfile::tempdir().unwrap();
539        let path = dir.path().join("run.h5");
540        write_run(&path, Some(100.0), &[("pause", &[10.0], &[1.0])]);
541
542        let health = run_health(&path, &RunHealthOptions::default()).unwrap();
543        assert_eq!(health.pause_fraction, Some(1.0));
544        assert_eq!(health.n_pause_entries, 1);
545    }
546
547    /// T35: power dips below half the median are counted, and the sample
548    /// median is reported.
549    #[test]
550    fn test_beam_dip_fraction_and_median() {
551        let dir = tempfile::tempdir().unwrap();
552        let path = dir.path().join("run.h5");
553        // 10 → dip to 1 over [10, 20) → back to 10; median of
554        // [10, 1, 10, 10] = 10 (middle two of the sorted values).
555        write_run(
556            &path,
557            Some(40.0),
558            &[(
559                "proton_charge",
560                &[0.0, 10.0, 20.0, 30.0],
561                &[10.0, 1.0, 10.0, 10.0],
562            )],
563        );
564
565        let health = run_health(&path, &RunHealthOptions::default()).unwrap();
566        assert_eq!(health.median_power, Some(10.0));
567        assert_eq!(health.beam_dip_fraction, Some(0.25));
568        assert_eq!(health.n_power_entries, 4);
569    }
570
571    /// T36: time/value length mismatch is malformed, not absent.
572    #[test]
573    fn test_length_mismatch_errors() {
574        let dir = tempfile::tempdir().unwrap();
575        let path = dir.path().join("run.h5");
576        write_run(
577            &path,
578            Some(100.0),
579            &[("pause", &[0.0, 10.0, 20.0], &[0.0, 1.0])],
580        );
581
582        let err = run_health(&path, &RunHealthOptions::default()).unwrap_err();
583        assert!(
584            matches!(err, IoError::InvalidParameter(_)),
585            "Expected InvalidParameter, got: {:?}",
586            err,
587        );
588        assert!(format!("{err}").contains("time has 3 entries but value has 2"));
589    }
590
591    /// T37: decreasing timestamps are malformed.
592    #[test]
593    fn test_non_ascending_time_errors() {
594        let dir = tempfile::tempdir().unwrap();
595        let path = dir.path().join("run.h5");
596        write_run(
597            &path,
598            Some(100.0),
599            &[("pause", &[0.0, 20.0, 10.0], &[0.0, 1.0, 0.0])],
600        );
601
602        let err = run_health(&path, &RunHealthOptions::default()).unwrap_err();
603        assert!(
604            format!("{err}").contains("ascending"),
605            "Expected ascending-time error, got: {err}",
606        );
607    }
608
609    /// Issue #652 (B12): corrupt device-reconnect records — `time = 0.0`
610    /// with a subnormal (~6.9e-310) value — are dropped, so `run_health`
611    /// works on furnace-style PVs instead of ingesting the subnormal into
612    /// its median or failing the ascending-time check.  Mirrors the real
613    /// VENUS run-19383 furnace channel, both leading and mid-log records.
614    #[test]
615    fn test_subnormal_reconnect_records_dropped() {
616        let dir = tempfile::tempdir().unwrap();
617        let path = dir.path().join("run.h5");
618        let sub = 6.9e-310_f64;
619        assert!(sub.is_finite() && sub != 0.0 && sub.abs() < f64::MIN_POSITIVE);
620        // Leading reconnect record (t=0, subnormal) + a mid-log one (the
621        // spurious backward jump to 0.0); real power values 20 MW elsewhere.
622        write_run(
623            &path,
624            Some(100.0),
625            &[(
626                "proton_charge",
627                &[0.0, 10.0, 30.0, 0.0, 60.0],
628                &[sub, 20.0, 20.0, sub, 20.0],
629            )],
630        );
631        let health = run_health(&path, &RunHealthOptions::default()).unwrap();
632        // Two records dropped → three clean entries, all 20 MW, ascending.
633        assert_eq!(health.n_power_entries, 3);
634        assert_eq!(health.median_power, Some(20.0));
635        // Median is 20 (not dragged toward 0 by the subnormal), so no dip.
636        assert_eq!(health.beam_dip_fraction, Some(0.0));
637    }
638
639    /// T38: without /entry/duration the window falls back to the latest
640    /// log timestamp; a non-positive fallback window is a hard error.
641    #[test]
642    fn test_duration_fallback_and_zero_window() {
643        let dir = tempfile::tempdir().unwrap();
644
645        // Fallback: last timestamp (20 s) becomes the window → pause
646        // fraction 10/20.
647        let path = dir.path().join("fallback.h5");
648        write_run(
649            &path,
650            None,
651            &[("pause", &[0.0, 10.0, 20.0], &[0.0, 1.0, 0.0])],
652        );
653        let health = run_health(&path, &RunHealthOptions::default()).unwrap();
654        assert_eq!(health.duration_s, Some(20.0));
655        assert_eq!(health.pause_fraction, Some(0.5));
656
657        // Zero window: single entry at t = 0 and no duration → error,
658        // never a NaN fraction.
659        let path = dir.path().join("zero.h5");
660        write_run(&path, None, &[("pause", &[0.0], &[1.0])]);
661        let err = run_health(&path, &RunHealthOptions::default()).unwrap_err();
662        assert!(
663            matches!(err, IoError::InvalidParameter(_)),
664            "Expected InvalidParameter, got: {:?}",
665            err,
666        );
667    }
668
669    /// T39: a file without a DASlogs group reports all-None health.
670    #[test]
671    fn test_missing_daslogs_group_all_none() {
672        let dir = tempfile::tempdir().unwrap();
673        let path = dir.path().join("run.h5");
674        let file = hdf5::File::create(&path).unwrap();
675        let entry = file.create_group("entry").unwrap();
676        entry
677            .new_dataset::<f64>()
678            .shape(())
679            .create("duration")
680            .unwrap()
681            .write_scalar(&100.0)
682            .unwrap();
683        drop(file);
684
685        let health = run_health(&path, &RunHealthOptions::default()).unwrap();
686        assert_eq!(health.pause_fraction, None);
687        assert_eq!(health.beam_dip_fraction, None);
688        assert_eq!(health.median_power, None);
689        assert_eq!(health.duration_s, Some(100.0));
690        assert_eq!(health.n_pause_entries, 0);
691        assert_eq!(health.n_power_entries, 0);
692    }
693
694    /// Malformed /entry/duration (non-positive) is a hard error.
695    #[test]
696    fn test_nonpositive_duration_errors() {
697        let dir = tempfile::tempdir().unwrap();
698        let path = dir.path().join("run.h5");
699        write_run(&path, Some(0.0), &[("pause", &[0.0], &[0.0])]);
700
701        let err = run_health(&path, &RunHealthOptions::default()).unwrap_err();
702        assert!(format!("{err}").contains("duration"));
703    }
704
705    /// T53: a mostly-zero power log (median 0 — beam off for at least
706    /// half the entries) makes the dip threshold undefined: the strict
707    /// `< 0` predicate could never fire, so beam_dip_fraction must be
708    /// None (not a false 0.0) with median_power still co-reported.
709    #[test]
710    fn test_zero_median_power_dip_is_none() {
711        let dir = tempfile::tempdir().unwrap();
712        let path = dir.path().join("run.h5");
713        // Beam off (0) for 3 of 4 entries → sample median 0.
714        write_run(
715            &path,
716            Some(40.0),
717            &[(
718                "proton_charge",
719                &[0.0, 10.0, 20.0, 30.0],
720                &[0.0, 0.0, 0.0, 10.0],
721            )],
722        );
723
724        let health = run_health(&path, &RunHealthOptions::default()).unwrap();
725        assert_eq!(health.median_power, Some(0.0));
726        assert_eq!(health.beam_dip_fraction, None);
727        assert_eq!(health.n_power_entries, 4);
728        assert_eq!(health.duration_s, Some(40.0));
729    }
730
731    /// T54: a negative power value is malformed (beam power is physically
732    /// non-negative) and hard-errors; a negative *pause* value is not an
733    /// error — the pause PV is a state flag where any nonzero value
734    /// (sign included) means "paused".
735    #[test]
736    fn test_negative_power_errors_negative_pause_allowed() {
737        let dir = tempfile::tempdir().unwrap();
738
739        let path = dir.path().join("neg_power.h5");
740        write_run(
741            &path,
742            Some(100.0),
743            &[("proton_charge", &[0.0, 10.0], &[10.0, -1.0])],
744        );
745        let err = run_health(&path, &RunHealthOptions::default()).unwrap_err();
746        assert!(
747            format!("{err}").contains("negative"),
748            "Expected negative-power error, got: {err}",
749        );
750
751        let path = dir.path().join("neg_pause.h5");
752        write_run(
753            &path,
754            Some(100.0),
755            &[("pause", &[0.0, 10.0, 20.0], &[0.0, -1.0, 0.0])],
756        );
757        let health = run_health(&path, &RunHealthOptions::default()).unwrap();
758        assert_eq!(health.pause_fraction, Some(0.1));
759    }
760
761    /// T55: a present-but-EMPTY PV (zero entries logged) is treated like
762    /// an absent PV — None fields, no error — and the other PV's summary
763    /// survives.
764    #[test]
765    fn test_empty_pv_is_none_other_pv_survives() {
766        let dir = tempfile::tempdir().unwrap();
767        let path = dir.path().join("run.h5");
768        write_run(
769            &path,
770            Some(100.0),
771            &[
772                ("pause", &[], &[]),
773                ("proton_charge", &[0.0, 50.0], &[10.0, 10.0]),
774            ],
775        );
776
777        let health = run_health(&path, &RunHealthOptions::default()).unwrap();
778        assert_eq!(health.pause_fraction, None);
779        assert_eq!(health.n_pause_entries, 0);
780        assert_eq!(health.median_power, Some(10.0));
781        assert_eq!(health.beam_dip_fraction, Some(0.0));
782        assert_eq!(health.n_power_entries, 2);
783    }
784
785    /// Invalid power_dip_fraction is rejected up-front.
786    #[test]
787    fn test_invalid_dip_fraction_rejected() {
788        let dir = tempfile::tempdir().unwrap();
789        let path = dir.path().join("run.h5");
790        write_run(&path, Some(100.0), &[("pause", &[0.0], &[0.0])]);
791
792        let options = RunHealthOptions {
793            power_dip_fraction: f64::NAN,
794            ..Default::default()
795        };
796        let err = run_health(&path, &options).unwrap_err();
797        assert!(format!("{err}").contains("power_dip_fraction"));
798    }
799}