1use crate::error::IoError;
30use std::path::Path;
31
32#[derive(Debug, Clone)]
34pub struct RunLog {
35 pub times: Vec<f64>,
37 pub values: Vec<f64>,
39 pub duration_s: f64,
42 pub offset_iso: Option<String>,
48 pub n_dropped_corrupt: usize,
53}
54
55pub 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 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 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
153pub 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 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 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 Some(last) if t0 <= last.1 => last.1 = last.1.max(t1),
237 _ => out.push((t0, t1)),
238 }
239 }
240 Ok(out)
241}
242
243pub 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
273pub(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 #[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 let values = [0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0];
308 let live = intervals_where(×, &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); 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 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 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); }
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 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 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 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 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); 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 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); assert!(read_run_log(&path, "no_such_pv").is_err());
434 }
435
436 #[test]
437 fn final_pulse_survives_f32_duration_quantization() {
438 let duration_f32 = 10.883_51_f32 as f64; 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 assert_eq!(
465 intervals_intersect(&[(0.0, 5.0)], &[(5.0, 9.0)]).unwrap(),
466 vec![]
467 );
468 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 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 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 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}