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}