1use std::path::Path;
68
69use hdf5::types::VarLenUnicode;
70use ndarray::{Array3, s};
71
72use crate::error::IoError;
73
74fn tof_scale_to_us(units: Option<&str>) -> Result<f64, IoError> {
84 match units {
85 None => Ok(1e-3),
90 Some(raw) => {
91 let normalised = raw.trim().to_ascii_lowercase();
92 match normalised.as_str() {
93 "ns" | "nanosecond" | "nanoseconds" => Ok(1e-3),
94 "us" | "µs" | "\u{03bc}s" | "microsecond" | "microseconds" => Ok(1.0),
102 "ms" | "millisecond" | "milliseconds" => Ok(1e3),
103 "s" | "sec" | "second" | "seconds" => Ok(1e6),
104 _ => Err(IoError::InvalidParameter(format!(
105 "Unsupported NeXus TOF units attribute {raw:?}: expected one of \
106 'ns', 'us'/'µs', 'ms', 's' (case-insensitive); refusing to \
107 guess a scale factor (issue #554)"
108 ))),
109 }
110 }
111 }
112}
113
114pub(crate) fn read_string_attr(
130 loc: &hdf5::Location,
131 name: &str,
132) -> Result<Option<String>, IoError> {
133 let names = loc.attr_names().map_err(|e| {
139 IoError::InvalidParameter(format!(
140 "Failed to list attributes while looking for {name:?}: {e}"
141 ))
142 })?;
143 if !names.iter().any(|n| n == name) {
144 return Ok(None);
145 }
146 let attr = loc.attr(name).map_err(|e| {
147 IoError::InvalidParameter(format!(
148 "Failed to open attribute {name:?} (listed but unreadable): {e}"
149 ))
150 })?;
151 use hdf5::types::{FixedAscii, FixedUnicode, TypeDescriptor, VarLenAscii};
157 let td = attr.dtype().and_then(|d| d.to_descriptor()).map_err(|e| {
158 IoError::InvalidParameter(format!("Failed to inspect type of attribute {name:?}: {e}"))
159 })?;
160 let read_err = |e: hdf5::Error| {
161 IoError::InvalidParameter(format!(
162 "Failed to read string attribute {name:?}: {e} (stored as {td:?})"
163 ))
164 };
165 let value = match td {
166 TypeDescriptor::VarLenUnicode => attr
167 .read_scalar::<VarLenUnicode>()
168 .map_err(read_err)?
169 .as_str()
170 .to_string(),
171 TypeDescriptor::VarLenAscii => attr
172 .read_scalar::<VarLenAscii>()
173 .map_err(read_err)?
174 .as_str()
175 .to_string(),
176 TypeDescriptor::FixedAscii(n) | TypeDescriptor::FixedUnicode(n) if n <= 1024 => match td {
180 TypeDescriptor::FixedAscii(_) => attr
181 .read_scalar::<FixedAscii<1024>>()
182 .map_err(read_err)?
183 .as_str()
184 .to_string(),
185 _ => attr
186 .read_scalar::<FixedUnicode<1024>>()
187 .map_err(read_err)?
188 .as_str()
189 .to_string(),
190 },
191 TypeDescriptor::FixedAscii(n) | TypeDescriptor::FixedUnicode(n) => {
192 return Err(IoError::InvalidParameter(format!(
193 "String attribute {name:?} is {n} bytes, exceeding the supported \
194 fixed-string read buffer (1024)"
195 )));
196 }
197 other => {
198 return Err(IoError::InvalidParameter(format!(
199 "Attribute {name:?} is not a string (stored as {other:?})"
200 )));
201 }
202 };
203 let value = value.trim_end_matches(['\0', ' ']).to_string();
204 Ok(Some(value))
205}
206
207#[derive(Debug, Clone)]
209pub struct NexusMetadata {
210 pub has_histogram: bool,
212 pub has_events: bool,
214 pub histogram_shape: Option<[usize; 4]>,
216 pub n_events: Option<usize>,
218 pub flight_path_m: Option<f64>,
220 pub tof_offset_ns: Option<f64>,
222 pub tof_edges_us: Option<Vec<f64>>,
227}
228
229#[derive(Debug, Clone)]
231pub struct Hdf5TreeEntry {
232 pub path: String,
234 pub kind: Hdf5EntryKind,
236 pub shape: Option<Vec<usize>>,
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242pub enum Hdf5EntryKind {
243 Group,
244 Dataset,
245}
246
247#[derive(Debug, Clone)]
249pub struct NexusHistogramData {
250 pub counts: Array3<f64>,
252 pub tof_edges_us: Vec<f64>,
254 pub flight_path_m: Option<f64>,
256 pub dead_pixels: Option<ndarray::Array2<bool>>,
258 pub n_rotation_angles: usize,
260 pub event_stats: Option<EventRetentionStats>,
262}
263
264#[derive(Debug, Clone)]
266pub struct EventRetentionStats {
267 pub total: usize,
269 pub kept: usize,
271 pub dropped_non_finite: usize,
278 pub dropped_tof_range: usize,
280 pub dropped_spatial: usize,
282}
283
284pub fn probe_nexus(path: &Path) -> Result<NexusMetadata, IoError> {
289 let file = hdf5::File::open(path).map_err(|e| {
290 IoError::FileNotFound(
291 path.display().to_string(),
292 std::io::Error::other(e.to_string()),
293 )
294 })?;
295
296 let entry = file
297 .group("entry")
298 .map_err(|e| IoError::InvalidParameter(format!("Missing /entry group: {e}")))?;
299
300 let (has_histogram, histogram_shape, tof_edges_us) = probe_histogram_group(&entry);
302
303 let (has_events, n_events) = probe_event_group(&entry);
305
306 let flight_path_m = read_f64_attr(&entry, "flight_path_m");
308 let tof_offset_ns = read_f64_attr(&entry, "tof_offset_ns");
309
310 Ok(NexusMetadata {
311 has_histogram,
312 has_events,
313 histogram_shape,
314 n_events,
315 flight_path_m,
316 tof_offset_ns,
317 tof_edges_us,
318 })
319}
320
321#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
327pub enum MultiAngleMode {
328 #[default]
333 Error,
334 Sum,
340 SelectAngle(usize),
343}
344
345pub fn load_nexus_histogram(path: &Path) -> Result<NexusHistogramData, IoError> {
361 load_nexus_histogram_with_mode(path, MultiAngleMode::Error)
362}
363
364pub fn load_nexus_histogram_with_mode(
372 path: &Path,
373 mode: MultiAngleMode,
374) -> Result<NexusHistogramData, IoError> {
375 let file = hdf5::File::open(path).map_err(|e| {
376 IoError::FileNotFound(
377 path.display().to_string(),
378 std::io::Error::other(e.to_string()),
379 )
380 })?;
381
382 let entry = file
383 .group("entry")
384 .map_err(|e| IoError::InvalidParameter(format!("Missing /entry group: {e}")))?;
385
386 let hist_group = entry
387 .group("histogram")
388 .map_err(|e| IoError::InvalidParameter(format!("Missing /entry/histogram group: {e}")))?;
389
390 let counts_ds = hist_group.dataset("counts").map_err(|e| {
392 IoError::InvalidParameter(format!("Missing /entry/histogram/counts dataset: {e}"))
393 })?;
394
395 let shape = counts_ds.shape();
396 if shape.len() != 4 {
397 return Err(IoError::ShapeMismatch(format!(
398 "Expected 4D histogram counts, got {}D",
399 shape.len()
400 )));
401 }
402
403 let n_rot = shape[0];
410 if n_rot == 0 {
411 return Err(IoError::InvalidParameter(
415 "NeXus histogram has zero rotation angles; /entry/histogram/counts axis 0 must \
416 be >= 1"
417 .into(),
418 ));
419 }
420 for (axis, name) in [(1usize, "y"), (2, "x"), (3, "tof")] {
426 if shape[axis] == 0 {
427 return Err(IoError::InvalidParameter(format!(
428 "NeXus histogram has a zero-sized {name} axis; \
429 /entry/histogram/counts axis {axis} must be >= 1 (shape {shape:?})"
430 )));
431 }
432 }
433 match mode {
434 MultiAngleMode::Error if n_rot > 1 => {
435 return Err(IoError::InvalidParameter(format!(
436 "NeXus histogram has {n_rot} rotation angles — refusing to silently \
437 combine them (issue #430). Call load_nexus_histogram_with_mode with \
438 MultiAngleMode::Sum to preserve the legacy sum-over-angles behaviour, \
439 or MultiAngleMode::SelectAngle(i) to extract a single projection."
440 )));
441 }
442 MultiAngleMode::SelectAngle(idx) if idx >= n_rot => {
443 return Err(IoError::InvalidParameter(format!(
444 "MultiAngleMode::SelectAngle({idx}) out of range: file has {n_rot} \
445 rotation angle(s); valid indices are 0..{n_rot} (exclusive, i.e. \
446 last valid index is {last})",
447 last = n_rot - 1
448 )));
449 }
450 _ => {}
451 }
452
453 let combined_yxtof: ndarray::Array3<u64> = match mode {
469 MultiAngleMode::Error | MultiAngleMode::Sum if n_rot == 1 => {
470 counts_ds.read_slice(s![0, .., .., ..]).map_err(|e| {
471 IoError::InvalidParameter(format!("Failed to read single-angle slice: {e}"))
472 })?
473 }
474 MultiAngleMode::Sum => {
475 let full: ndarray::Array4<u64> = counts_ds.read().map_err(|e| {
476 IoError::InvalidParameter(format!("Failed to read histogram counts: {e}"))
477 })?;
478 full.sum_axis(ndarray::Axis(0))
479 }
480 MultiAngleMode::SelectAngle(idx) => {
481 counts_ds.read_slice(s![idx, .., .., ..]).map_err(|e| {
482 IoError::InvalidParameter(format!("Failed to read selected-angle slice: {e}"))
483 })?
484 }
485 MultiAngleMode::Error => {
486 unreachable!("Error mode reached with n_rot = {n_rot}")
489 }
490 };
491
492 let counts_f64: Array3<f64> = combined_yxtof
494 .mapv(|v| v as f64)
495 .permuted_axes([2, 0, 1])
496 .as_standard_layout()
497 .into_owned();
498 let n_tof = counts_f64.shape()[0];
499
500 let tof_edges_us = read_tof_axis(&hist_group)?;
502
503 if tof_edges_us.len() != n_tof + 1 && tof_edges_us.len() != n_tof {
505 return Err(IoError::InvalidParameter(format!(
506 "TOF axis length {} is incompatible with {} histogram bins (expected {} or {})",
507 tof_edges_us.len(),
508 n_tof,
509 n_tof,
510 n_tof + 1
511 )));
512 }
513
514 let flight_path_m = read_f64_attr(&hist_group, "flight_path_m")
516 .or_else(|| read_f64_attr(&entry, "flight_path_m"));
517
518 let dead_pixels = read_dead_pixel_mask(&entry, (counts_f64.shape()[1], counts_f64.shape()[2]))?;
521
522 Ok(NexusHistogramData {
523 counts: counts_f64,
524 tof_edges_us,
525 flight_path_m,
526 dead_pixels,
527 n_rotation_angles: n_rot,
528 event_stats: None, })
530}
531
532#[derive(Debug, Clone, PartialEq)]
534pub struct EventBinningParams {
535 pub n_bins: usize,
537 pub tof_min_us: f64,
539 pub tof_max_us: f64,
541 pub height: usize,
543 pub width: usize,
545}
546
547pub fn load_nexus_events(
576 path: &Path,
577 params: &EventBinningParams,
578) -> Result<NexusHistogramData, IoError> {
579 if params.n_bins == 0 {
580 return Err(IoError::InvalidParameter("n_bins must be positive".into()));
581 }
582 if params.height == 0 || params.width == 0 {
583 return Err(IoError::InvalidParameter(
584 "height and width must be positive".into(),
585 ));
586 }
587 if !params.tof_min_us.is_finite() || !params.tof_max_us.is_finite() {
588 return Err(IoError::InvalidParameter(
589 "TOF bounds must be finite".into(),
590 ));
591 }
592 if params.tof_max_us <= params.tof_min_us {
593 return Err(IoError::InvalidParameter(format!(
594 "tof_max_us ({}) must be greater than tof_min_us ({})",
595 params.tof_max_us, params.tof_min_us
596 )));
597 }
598
599 let file = hdf5::File::open(path).map_err(|e| {
600 IoError::FileNotFound(
601 path.display().to_string(),
602 std::io::Error::other(e.to_string()),
603 )
604 })?;
605
606 let entry = file
607 .group("entry")
608 .map_err(|e| IoError::InvalidParameter(format!("Missing /entry group: {e}")))?;
609
610 let neutrons = entry
611 .group("neutrons")
612 .map_err(|e| IoError::InvalidParameter(format!("Missing /entry/neutrons group: {e}")))?;
613
614 let tof_ds = neutrons.dataset("event_time_offset").map_err(|e| {
617 IoError::InvalidParameter(format!("Missing event_time_offset dataset: {e}"))
618 })?;
619 let tof_units = read_string_attr(&tof_ds, "units")?;
620 let tof_scale = tof_scale_to_us(tof_units.as_deref())?;
621 let tof_raw: Vec<u64> = tof_ds
622 .read_1d()
623 .map_err(|e| IoError::InvalidParameter(format!("Failed to read event_time_offset: {e}")))?
624 .to_vec();
625
626 let x_coords: Vec<f64> = neutrons
627 .dataset("x")
628 .map_err(|e| IoError::InvalidParameter(format!("Missing x dataset: {e}")))?
629 .read_1d()
630 .map_err(|e| IoError::InvalidParameter(format!("Failed to read x: {e}")))?
631 .to_vec();
632
633 let y_coords: Vec<f64> = neutrons
634 .dataset("y")
635 .map_err(|e| IoError::InvalidParameter(format!("Missing y dataset: {e}")))?
636 .read_1d()
637 .map_err(|e| IoError::InvalidParameter(format!("Failed to read y: {e}")))?
638 .to_vec();
639
640 if tof_raw.len() != x_coords.len() || tof_raw.len() != y_coords.len() {
641 return Err(IoError::ShapeMismatch(format!(
642 "Event arrays have mismatched lengths: tof={}, x={}, y={}",
643 tof_raw.len(),
644 x_coords.len(),
645 y_coords.len()
646 )));
647 }
648
649 let tof_edges_us =
651 crate::tof::linspace_tof_edges(params.tof_min_us, params.tof_max_us, params.n_bins)?;
652
653 let dt_us = (params.tof_max_us - params.tof_min_us) / params.n_bins as f64;
655 let mut counts = Array3::<f64>::zeros((params.n_bins, params.height, params.width));
656 let total = tof_raw.len();
657 let mut kept = 0usize;
658 let mut dropped_non_finite = 0usize;
659 let mut dropped_tof_range = 0usize;
660 let mut dropped_spatial = 0usize;
661
662 for i in 0..tof_raw.len() {
663 let tof_us = tof_raw[i] as f64 * tof_scale;
668 if !tof_us.is_finite() {
669 dropped_non_finite += 1;
670 continue;
671 }
672
673 if tof_us < params.tof_min_us || tof_us >= params.tof_max_us {
674 dropped_tof_range += 1;
675 continue;
676 }
677
678 let xf = x_coords[i];
679 let yf = y_coords[i];
680 if !xf.is_finite() || !yf.is_finite() {
681 dropped_non_finite += 1;
682 continue;
683 }
684 let px = xf.round() as isize;
685 let py = yf.round() as isize;
686
687 if px < 0 || py < 0 || px >= params.width as isize || py >= params.height as isize {
688 dropped_spatial += 1;
689 continue;
690 }
691
692 let tof_bin = ((tof_us - params.tof_min_us) / dt_us) as usize;
693 let tof_bin = tof_bin.min(params.n_bins - 1);
694 counts[[tof_bin, py as usize, px as usize]] += 1.0;
695 kept += 1;
696 }
697
698 let flight_path_m = read_f64_attr(&neutrons, "flight_path_m")
700 .or_else(|| read_f64_attr(&entry, "flight_path_m"));
701
702 let dead_pixels = read_dead_pixel_mask(&entry, (params.height, params.width))?;
704
705 debug_assert_eq!(
706 total,
707 kept + dropped_non_finite + dropped_tof_range + dropped_spatial,
708 "event retention accounting mismatch"
709 );
710
711 Ok(NexusHistogramData {
712 counts,
713 tof_edges_us,
714 flight_path_m,
715 dead_pixels,
716 n_rotation_angles: 1,
717 event_stats: Some(EventRetentionStats {
718 total,
719 kept,
720 dropped_non_finite,
721 dropped_tof_range,
722 dropped_spatial,
723 }),
724 })
725}
726
727fn probe_histogram_group(entry: &hdf5::Group) -> (bool, Option<[usize; 4]>, Option<Vec<f64>>) {
742 let hist = match entry.group("histogram") {
743 Ok(g) => g,
744 Err(_) => return (false, None, None),
745 };
746
747 let counts = match hist.dataset("counts") {
748 Ok(ds) => ds,
749 Err(_) => return (false, None, None),
750 };
751
752 let shape = counts.shape();
753 if shape.len() != 4 {
754 return (false, None, None);
755 }
756
757 let histogram_shape = Some([shape[0], shape[1], shape[2], shape[3]]);
758
759 let tof_edges_us = hist.dataset("time_of_flight").ok().and_then(|ds| {
764 let raw = ds.read_1d::<f64>().ok()?.to_vec();
765 let units = read_string_attr(&ds, "units").ok()?;
770 let scale = tof_scale_to_us(units.as_deref()).ok()?;
771 Some(raw.into_iter().map(|v| v * scale).collect())
772 });
773
774 (true, histogram_shape, tof_edges_us)
775}
776
777fn probe_event_group(entry: &hdf5::Group) -> (bool, Option<usize>) {
779 let neutrons = match entry.group("neutrons") {
780 Ok(g) => g,
781 Err(_) => return (false, None),
782 };
783
784 let n_events = neutrons
785 .dataset("event_time_offset")
786 .ok()
787 .map(|ds| ds.shape().first().copied().unwrap_or(0));
788
789 (n_events.is_some(), n_events)
790}
791
792fn read_tof_axis(hist_group: &hdf5::Group) -> Result<Vec<f64>, IoError> {
795 let tof_ds = hist_group.dataset("time_of_flight").map_err(|e| {
796 IoError::InvalidParameter(format!(
797 "Missing /entry/histogram/time_of_flight dataset: {e}"
798 ))
799 })?;
800
801 let raw: Vec<f64> = tof_ds
802 .read_1d::<f64>()
803 .map_err(|e| IoError::InvalidParameter(format!("Failed to read time_of_flight: {e}")))?
804 .to_vec();
805
806 let units = read_string_attr(&tof_ds, "units")?;
810 let scale = tof_scale_to_us(units.as_deref())?;
811
812 let edges: Vec<f64> = raw.iter().map(|&v| v * scale).collect();
813
814 for (i, &edge) in edges.iter().enumerate() {
828 if !edge.is_finite() || edge <= 0.0 {
829 return Err(IoError::InvalidParameter(format!(
830 "NeXus TOF axis edge {i} must be finite and positive, got {edge}"
831 )));
832 }
833 }
834 crate::spectrum::validate_monotonic(&edges)?;
835
836 Ok(edges)
837}
838
839fn read_f64_attr(group: &hdf5::Group, name: &str) -> Option<f64> {
841 group
842 .attr(name)
843 .ok()
844 .and_then(|a| a.read_scalar::<f64>().ok())
845}
846
847fn read_dead_pixel_mask(
865 entry: &hdf5::Group,
866 expected_hw: (usize, usize),
867) -> Result<Option<ndarray::Array2<bool>>, IoError> {
868 let entry_members = entry
870 .member_names()
871 .map_err(|e| IoError::InvalidParameter(format!("Failed to list /entry members: {e}")))?;
872 if !entry_members.iter().any(|n| n == "pixel_masks") {
873 return Ok(None);
874 }
875 let masks = entry.group("pixel_masks").map_err(|e| {
877 IoError::InvalidParameter(format!(
878 "/entry/pixel_masks is present but is not a readable group: {e}"
879 ))
880 })?;
881
882 let mask_members = masks.member_names().map_err(|e| {
884 IoError::InvalidParameter(format!("Failed to list /entry/pixel_masks members: {e}"))
885 })?;
886 if !mask_members.iter().any(|n| n == "dead") {
887 return Ok(None);
888 }
889 let dead_ds = masks.dataset("dead").map_err(|e| {
891 IoError::InvalidParameter(format!(
892 "/entry/pixel_masks/dead is present but is not a readable dataset: {e}"
893 ))
894 })?;
895 let dead_u8: ndarray::Array2<u8> = dead_ds.read().map_err(|e| {
896 IoError::InvalidParameter(format!("Failed to read /entry/pixel_masks/dead: {e}"))
897 })?;
898 let (eh, ew) = expected_hw;
899 if dead_u8.dim() != (eh, ew) {
900 return Err(IoError::ShapeMismatch(format!(
901 "dead-pixel mask shape {:?} != detector spatial dimensions ({eh}, {ew})",
902 dead_u8.dim(),
903 )));
904 }
905 Ok(Some(dead_u8.mapv(|v| v != 0)))
906}
907
908pub fn list_hdf5_tree(path: &Path, max_depth: usize) -> Result<Vec<Hdf5TreeEntry>, IoError> {
914 let file = hdf5::File::open(path)
915 .map_err(|e| IoError::Hdf5Error(format!("Cannot open HDF5 file: {e}")))?;
916 let mut entries = Vec::new();
917 walk_group(
918 &file
919 .as_group()
920 .map_err(|e| IoError::Hdf5Error(format!("Cannot read root group: {e}")))?,
921 "/",
922 0,
923 max_depth,
924 &mut entries,
925 );
926 Ok(entries)
927}
928
929fn walk_group(
931 group: &hdf5::Group,
932 prefix: &str,
933 depth: usize,
934 max_depth: usize,
935 entries: &mut Vec<Hdf5TreeEntry>,
936) {
937 let Ok(members) = group.member_names() else {
938 return;
939 };
940 let mut members = members;
941 members.sort();
942 for name in &members {
943 let child_path = if prefix == "/" {
944 format!("/{name}")
945 } else {
946 format!("{prefix}/{name}")
947 };
948
949 if let Ok(ds) = group.dataset(name) {
951 let shape = ds.shape();
952 entries.push(Hdf5TreeEntry {
953 path: child_path,
954 kind: Hdf5EntryKind::Dataset,
955 shape: Some(shape),
956 });
957 } else if let Ok(child_group) = group.group(name) {
958 entries.push(Hdf5TreeEntry {
960 path: child_path.clone(),
961 kind: Hdf5EntryKind::Group,
962 shape: None,
963 });
964 if depth < max_depth {
965 walk_group(&child_group, &child_path, depth + 1, max_depth, entries);
966 }
967 }
968 }
969}
970
971#[cfg(test)]
972mod tests {
973 use super::*;
974
975 fn create_test_histogram(
977 path: &Path,
978 counts: &[u64],
979 shape: [usize; 4],
980 tof_ns: &[f64],
981 flight_path_m: Option<f64>,
982 ) {
983 let file = hdf5::File::create(path).expect("create test file");
984 let entry = file.create_group("entry").expect("create entry");
985
986 if let Some(fp) = flight_path_m {
987 entry
988 .new_attr::<f64>()
989 .shape(())
990 .create("flight_path_m")
991 .expect("create attr")
992 .write_scalar(&fp)
993 .expect("write attr");
994 }
995
996 let hist = entry.create_group("histogram").expect("create histogram");
997 hist.new_dataset::<u64>()
998 .shape(shape)
999 .create("counts")
1000 .expect("create counts")
1001 .write_raw(counts)
1002 .expect("write counts");
1003
1004 hist.new_dataset::<f64>()
1005 .shape([tof_ns.len()])
1006 .create("time_of_flight")
1007 .expect("create tof")
1008 .write_raw(tof_ns)
1009 .expect("write tof");
1010 }
1011
1012 #[test]
1013 fn test_probe_nexus_histogram() {
1014 let dir = tempfile::tempdir().unwrap();
1015 let path = dir.path().join("test.h5");
1016
1017 let counts = vec![0u64; 24];
1019 let tof_ns = vec![1000.0, 2000.0, 3000.0, 4000.0, 5000.0]; create_test_histogram(&path, &counts, [1, 2, 3, 4], &tof_ns, Some(25.0));
1021
1022 let meta = probe_nexus(&path).unwrap();
1023 assert!(meta.has_histogram);
1024 assert!(!meta.has_events);
1025 assert_eq!(meta.histogram_shape, Some([1, 2, 3, 4]));
1026 assert_eq!(meta.flight_path_m, Some(25.0));
1027 let edges = meta.tof_edges_us.expect("probe should return TOF edges");
1031 assert_eq!(edges.len(), 5);
1032 for (i, &expected_us) in [1.0_f64, 2.0, 3.0, 4.0, 5.0].iter().enumerate() {
1033 assert!(
1034 (edges[i] - expected_us).abs() < 1e-12,
1035 "edge {i}: expected {expected_us} µs, got {} µs",
1036 edges[i]
1037 );
1038 }
1039 }
1040
1041 #[test]
1046 fn test_probe_nexus_histogram_units_us_no_rescale() {
1047 let dir = tempfile::tempdir().unwrap();
1048 let path = dir.path().join("probe_units_us.h5");
1049
1050 let counts = vec![0u64; 4];
1051 let tof_us = vec![1000.0, 2000.0, 3000.0, 4000.0, 5000.0];
1053 create_test_histogram_with_units(&path, &counts, [1, 1, 1, 4], &tof_us, Some("us"));
1054
1055 let meta = probe_nexus(&path).expect("probe with units=us");
1056 let edges = meta.tof_edges_us.expect("TOF axis should be present");
1057 assert_eq!(edges.len(), 5);
1058 for (i, &expected_us) in tof_us.iter().enumerate() {
1059 assert!(
1060 (edges[i] - expected_us).abs() < 1e-9,
1061 "probe edge {i}: expected {expected_us} µs (no rescale), got {} µs",
1062 edges[i]
1063 );
1064 }
1065 }
1066
1067 #[test]
1068 fn test_load_nexus_histogram_single_angle() {
1069 let dir = tempfile::tempdir().unwrap();
1070 let path = dir.path().join("test.h5");
1071
1072 let mut counts = vec![0u64; 2 * 3 * 2];
1074 counts[0] = 15; let tof_ns = vec![1000.0, 2000.0, 3000.0]; create_test_histogram(&path, &counts, [1, 2, 3, 2], &tof_ns, Some(25.0));
1078
1079 let data = load_nexus_histogram(&path).unwrap();
1080
1081 assert_eq!(data.counts.shape(), &[2, 2, 3]);
1083 assert_eq!(data.counts[[0, 0, 0]], 15.0);
1085
1086 assert_eq!(data.tof_edges_us.len(), 3);
1088 assert!((data.tof_edges_us[0] - 1.0).abs() < 1e-10);
1089 assert!((data.tof_edges_us[1] - 2.0).abs() < 1e-10);
1090 assert!((data.tof_edges_us[2] - 3.0).abs() < 1e-10);
1091 assert_eq!(data.flight_path_m, Some(25.0));
1092 assert_eq!(data.n_rotation_angles, 1);
1093 }
1094
1095 #[test]
1098 fn test_load_nexus_histogram_multi_angle_errors_by_default() {
1099 let dir = tempfile::tempdir().unwrap();
1100 let path = dir.path().join("multi_angle.h5");
1101
1102 let counts = vec![1u64; 2 * 2 * 3 * 2];
1103 let tof_ns = vec![1000.0, 2000.0, 3000.0];
1104 create_test_histogram(&path, &counts, [2, 2, 3, 2], &tof_ns, Some(25.0));
1105
1106 let err = load_nexus_histogram(&path)
1107 .expect_err("multi-angle file must be rejected by the default loader");
1108 let msg = err.to_string();
1109 assert!(
1110 msg.contains("2 rotation angles") && msg.contains("#430"),
1111 "error message should name the angle count and reference #430, got: {msg}"
1112 );
1113 assert!(
1114 msg.contains("MultiAngleMode::Sum") && msg.contains("MultiAngleMode::SelectAngle"),
1115 "error message should point at the explicit-opt-in APIs, got: {msg}"
1116 );
1117 }
1118
1119 #[test]
1122 fn test_load_nexus_histogram_multi_angle_sum_mode() {
1123 let dir = tempfile::tempdir().unwrap();
1124 let path = dir.path().join("multi_angle_sum.h5");
1125
1126 let mut counts = vec![0u64; 2 * 2 * 3 * 2];
1127 counts[0] = 10; counts[12] = 5; let tof_ns = vec![1000.0, 2000.0, 3000.0];
1130 create_test_histogram(&path, &counts, [2, 2, 3, 2], &tof_ns, Some(25.0));
1131
1132 let data = load_nexus_histogram_with_mode(&path, MultiAngleMode::Sum).unwrap();
1133 assert_eq!(data.counts.shape(), &[2, 2, 3]);
1134 assert_eq!(data.counts[[0, 0, 0]], 15.0);
1136 assert_eq!(data.n_rotation_angles, 2);
1137 }
1138
1139 #[test]
1142 fn test_load_nexus_histogram_multi_angle_select_mode() {
1143 let dir = tempfile::tempdir().unwrap();
1144 let path = dir.path().join("multi_angle_select.h5");
1145
1146 let mut counts = vec![0u64; 3 * 2 * 3 * 2];
1147 counts[0] = 100; counts[12] = 200; counts[24] = 300; let tof_ns = vec![1000.0, 2000.0, 3000.0];
1151 create_test_histogram(&path, &counts, [3, 2, 3, 2], &tof_ns, Some(25.0));
1152
1153 let data = load_nexus_histogram_with_mode(&path, MultiAngleMode::SelectAngle(1)).unwrap();
1155 assert_eq!(data.counts[[0, 0, 0]], 200.0);
1156 assert_eq!(data.n_rotation_angles, 3);
1157
1158 let err = load_nexus_histogram_with_mode(&path, MultiAngleMode::SelectAngle(3))
1160 .expect_err("out-of-range angle index must error");
1161 let msg = err.to_string();
1162 assert!(
1163 msg.contains("SelectAngle(3)") && msg.contains("3 rotation angle"),
1164 "error should name the bad index and the actual count, got: {msg}"
1165 );
1166 }
1167
1168 #[test]
1172 fn test_load_nexus_histogram_single_angle_mode_parity() {
1173 let dir = tempfile::tempdir().unwrap();
1174 let path = dir.path().join("single_parity.h5");
1175 let counts = vec![7u64; 2 * 3 * 2];
1176 let tof_ns = vec![1000.0, 2000.0, 3000.0];
1177 create_test_histogram(&path, &counts, [1, 2, 3, 2], &tof_ns, None);
1178
1179 let d_err = load_nexus_histogram_with_mode(&path, MultiAngleMode::Error).unwrap();
1180 let d_sum = load_nexus_histogram_with_mode(&path, MultiAngleMode::Sum).unwrap();
1181 let d_sel = load_nexus_histogram_with_mode(&path, MultiAngleMode::SelectAngle(0)).unwrap();
1182 assert_eq!(d_err.counts, d_sum.counts);
1184 assert_eq!(d_err.counts, d_sel.counts);
1185 assert_eq!(d_err.counts[[0, 0, 0]], 7.0);
1187 assert_eq!(d_err.n_rotation_angles, 1);
1188 }
1189
1190 #[test]
1196 fn test_load_nexus_histogram_zero_angles_rejected() {
1197 let dir = tempfile::tempdir().unwrap();
1198 let path = dir.path().join("zero_angles.h5");
1199 let counts: Vec<u64> = Vec::new();
1200 let tof_ns = vec![1000.0, 2000.0, 3000.0];
1201 create_test_histogram(&path, &counts, [0, 2, 3, 2], &tof_ns, None);
1202
1203 for mode in [
1204 MultiAngleMode::Error,
1205 MultiAngleMode::Sum,
1206 MultiAngleMode::SelectAngle(0),
1207 ] {
1208 let err = load_nexus_histogram_with_mode(&path, mode).unwrap_err();
1209 let msg = err.to_string();
1210 assert!(
1211 msg.contains("zero rotation angles"),
1212 "mode {mode:?} zero-angle rejection should name the axis, got: {msg}"
1213 );
1214 }
1215 }
1216
1217 #[test]
1221 fn test_load_nexus_histogram_zero_sibling_axes_rejected() {
1222 for (shape, axis_name) in [
1223 ([1usize, 0, 3, 2], "y"),
1224 ([1, 2, 0, 2], "x"),
1225 ([1, 2, 3, 0], "tof"),
1226 ] {
1227 let dir = tempfile::tempdir().unwrap();
1228 let path = dir.path().join(format!("zero_{axis_name}.h5"));
1229 let counts: Vec<u64> = Vec::new(); let tof_ns = vec![1000.0, 2000.0, 3000.0];
1231 create_test_histogram(&path, &counts, shape, &tof_ns, None);
1232
1233 let err = load_nexus_histogram(&path).unwrap_err();
1234 let msg = err.to_string();
1235 assert!(
1236 msg.contains(&format!("zero-sized {axis_name} axis")),
1237 "axis {axis_name} ({shape:?}) should be rejected by name, got: {msg}"
1238 );
1239 }
1240 }
1241
1242 #[test]
1246 fn test_load_nexus_histogram_rejects_non_monotonic_tof() {
1247 let dir = tempfile::tempdir().unwrap();
1248 let path = dir.path().join("nonmono_tof.h5");
1249 let counts = vec![1u64, 2u64];
1251 let tof_ns = vec![3000.0, 2000.0, 1000.0];
1252 create_test_histogram(&path, &counts, [1, 1, 1, 2], &tof_ns, None);
1253
1254 let err = load_nexus_histogram(&path).unwrap_err();
1255 assert!(
1256 err.to_string().contains("strictly increasing"),
1257 "non-monotonic TOF should be rejected, got: {err}"
1258 );
1259 }
1260
1261 #[test]
1262 fn test_load_nexus_histogram_rejects_non_positive_tof() {
1263 let dir = tempfile::tempdir().unwrap();
1264 let path = dir.path().join("nonpos_tof.h5");
1265 let counts = vec![1u64, 2u64];
1267 let tof_ns = vec![0.0, 1000.0, 2000.0];
1268 create_test_histogram(&path, &counts, [1, 1, 1, 2], &tof_ns, None);
1269
1270 let err = load_nexus_histogram(&path).unwrap_err();
1271 assert!(
1272 err.to_string().contains("finite and positive"),
1273 "non-positive TOF should be rejected, got: {err}"
1274 );
1275 }
1276
1277 #[test]
1281 fn test_load_nexus_histogram_rejects_trailing_infinite_tof() {
1282 let dir = tempfile::tempdir().unwrap();
1283 let path = dir.path().join("inf_tail_tof.h5");
1284 let counts = vec![1u64, 2u64];
1286 let tof_ns = vec![1000.0, 2000.0, f64::INFINITY];
1287 create_test_histogram(&path, &counts, [1, 1, 1, 2], &tof_ns, None);
1288
1289 let err = load_nexus_histogram(&path).unwrap_err();
1290 assert!(
1291 err.to_string().contains("finite and positive"),
1292 "trailing +inf TOF edge should be rejected, got: {err}"
1293 );
1294 }
1295
1296 #[test]
1304 fn test_read_tof_axis_rejects_single_nan_or_inf_edge() {
1305 let dir = tempfile::tempdir().unwrap();
1306
1307 for (name, edge) in [("nan", f64::NAN), ("inf", f64::INFINITY)] {
1308 let path = dir.path().join(format!("single_{name}_edge.h5"));
1309 let file = hdf5::File::create(&path).expect("create");
1310 let entry = file.create_group("entry").expect("entry");
1311 let hist = entry.create_group("histogram").expect("histogram");
1312 hist.new_dataset::<f64>()
1313 .shape([1])
1314 .create("time_of_flight")
1315 .expect("create tof")
1316 .write_raw(&[edge])
1317 .expect("write tof");
1318 drop(file);
1321
1322 let file = hdf5::File::open(&path).expect("reopen");
1323 let hist_group = file
1324 .group("entry")
1325 .expect("entry")
1326 .group("histogram")
1327 .expect("histogram");
1328 let err = read_tof_axis(&hist_group).expect_err("single bad edge must reject");
1329 assert!(
1330 err.to_string().contains("finite and positive"),
1331 "single {name} edge should be rejected, got: {err}"
1332 );
1333 }
1334 }
1335
1336 fn create_test_histogram_with_dead_mask(
1339 path: &Path,
1340 counts: &[u64],
1341 shape: [usize; 4],
1342 tof_ns: &[f64],
1343 dead: &[u8],
1344 dead_shape: [usize; 2],
1345 ) {
1346 create_test_histogram(path, counts, shape, tof_ns, None);
1347 let file = hdf5::File::append(path).expect("reopen test file");
1348 let entry = file.group("entry").expect("entry");
1349 let masks = entry.create_group("pixel_masks").expect("pixel_masks");
1350 masks
1351 .new_dataset::<u8>()
1352 .shape(dead_shape)
1353 .create("dead")
1354 .expect("create dead")
1355 .write_raw(dead)
1356 .expect("write dead");
1357 }
1358
1359 #[test]
1362 fn test_load_nexus_histogram_rejects_mismatched_dead_mask() {
1363 let dir = tempfile::tempdir().unwrap();
1364 let path = dir.path().join("bad_mask.h5");
1365 let counts = vec![1u64; 2 * 3 * 2];
1367 let tof_ns = vec![1000.0, 2000.0, 3000.0];
1368 let dead = vec![0u8; 25];
1369 create_test_histogram_with_dead_mask(&path, &counts, [1, 2, 3, 2], &tof_ns, &dead, [5, 5]);
1370
1371 let err = load_nexus_histogram(&path).unwrap_err();
1372 assert!(
1373 matches!(err, IoError::ShapeMismatch(_)),
1374 "expected ShapeMismatch, got {err:?}"
1375 );
1376 assert!(err.to_string().contains("dead-pixel mask shape"));
1377 }
1378
1379 #[test]
1381 fn test_load_nexus_histogram_accepts_matching_dead_mask() {
1382 let dir = tempfile::tempdir().unwrap();
1383 let path = dir.path().join("ok_mask.h5");
1384 let counts = vec![1u64; 2 * 3 * 2];
1385 let tof_ns = vec![1000.0, 2000.0, 3000.0];
1386 let dead = vec![0u8, 1, 0, 0, 0, 0];
1388 create_test_histogram_with_dead_mask(&path, &counts, [1, 2, 3, 2], &tof_ns, &dead, [2, 3]);
1389
1390 let data = load_nexus_histogram(&path).expect("matching mask should load");
1391 let mask = data.dead_pixels.expect("mask present");
1392 assert_eq!(mask.dim(), (2, 3));
1393 assert!(mask[[0, 1]]);
1394 }
1395
1396 #[test]
1399 fn test_load_nexus_histogram_absent_dead_mask_is_none() {
1400 let dir = tempfile::tempdir().unwrap();
1401 let path = dir.path().join("no_mask.h5");
1402 let counts = vec![1u64; 2 * 3 * 2];
1403 let tof_ns = vec![1000.0, 2000.0, 3000.0];
1404 create_test_histogram(&path, &counts, [1, 2, 3, 2], &tof_ns, None);
1405
1406 let data = load_nexus_histogram(&path).expect("absent mask should load");
1407 assert!(
1408 data.dead_pixels.is_none(),
1409 "absent dead mask must map to None"
1410 );
1411 }
1412
1413 #[test]
1418 fn test_load_nexus_histogram_rejects_present_but_invalid_dead_mask() {
1419 let dir = tempfile::tempdir().unwrap();
1420 let path = dir.path().join("invalid_mask.h5");
1421 let counts = vec![1u64; 2 * 3 * 2];
1422 let tof_ns = vec![1000.0, 2000.0, 3000.0];
1423 create_test_histogram(&path, &counts, [1, 2, 3, 2], &tof_ns, None);
1424
1425 let file = hdf5::File::append(&path).expect("reopen");
1427 let entry = file.group("entry").expect("entry");
1428 let masks = entry.create_group("pixel_masks").expect("pixel_masks");
1429 masks.create_group("dead").expect("dead-as-group");
1430 drop(file);
1431
1432 let err = load_nexus_histogram(&path).unwrap_err();
1433 assert!(
1434 matches!(err, IoError::InvalidParameter(_)),
1435 "present-but-malformed dead mask must be InvalidParameter, got {err:?}"
1436 );
1437 assert!(
1438 err.to_string().contains("dead") && err.to_string().contains("not a readable dataset"),
1439 "error should identify the malformed dead dataset, got: {err}"
1440 );
1441 }
1442
1443 #[test]
1455 fn test_multi_angle_rejection_happens_before_counts_read() {
1456 let dir = tempfile::tempdir().unwrap();
1457 let path = dir.path().join("big_shape.h5");
1458 let counts = vec![1u64; 4 * 2 * 3 * 2];
1461 let tof_ns = vec![1000.0, 2000.0, 3000.0];
1462 create_test_histogram(&path, &counts, [4, 2, 3, 2], &tof_ns, None);
1463
1464 let err = load_nexus_histogram_with_mode(&path, MultiAngleMode::Error).unwrap_err();
1465 let msg = err.to_string();
1466 assert!(
1467 msg.contains("4 rotation angles") && msg.contains("#430"),
1468 "error message should name angle count + reference the issue, got: {msg}"
1469 );
1470 }
1471
1472 #[test]
1473 fn test_ns_to_us_conversion() {
1474 let dir = tempfile::tempdir().unwrap();
1475 let path = dir.path().join("test.h5");
1476
1477 let counts = vec![0u64; 3];
1478 let tof_ns = vec![500_000.0, 1_000_000.0, 1_500_000.0, 2_000_000.0];
1479 create_test_histogram(&path, &counts, [1, 1, 1, 3], &tof_ns, None);
1480
1481 let data = load_nexus_histogram(&path).unwrap();
1482
1483 assert!((data.tof_edges_us[0] - 500.0).abs() < 1e-10);
1485 assert!((data.tof_edges_us[1] - 1000.0).abs() < 1e-10);
1486 assert!((data.tof_edges_us[2] - 1500.0).abs() < 1e-10);
1487 assert!((data.tof_edges_us[3] - 2000.0).abs() < 1e-10);
1488 }
1489
1490 #[test]
1491 fn test_probe_missing_dataset() {
1492 let dir = tempfile::tempdir().unwrap();
1493 let path = dir.path().join("empty.h5");
1494
1495 let file = hdf5::File::create(&path).expect("create");
1496 file.create_group("entry").expect("create entry");
1497 drop(file);
1498
1499 let meta = probe_nexus(&path).unwrap();
1500 assert!(!meta.has_histogram);
1501 assert!(!meta.has_events);
1502 assert!(meta.histogram_shape.is_none());
1503 assert!(meta.n_events.is_none());
1504 }
1505
1506 fn create_test_events(
1508 path: &Path,
1509 tof_ns: &[u64],
1510 x: &[f64],
1511 y: &[f64],
1512 flight_path_m: Option<f64>,
1513 ) {
1514 let file = hdf5::File::create(path).expect("create");
1515 let entry = file.create_group("entry").expect("create entry");
1516
1517 if let Some(fp) = flight_path_m {
1518 entry
1519 .new_attr::<f64>()
1520 .shape(())
1521 .create("flight_path_m")
1522 .expect("create attr")
1523 .write_scalar(&fp)
1524 .expect("write attr");
1525 }
1526
1527 let neutrons = entry.create_group("neutrons").expect("create neutrons");
1528 neutrons
1529 .new_dataset::<u64>()
1530 .shape([tof_ns.len()])
1531 .create("event_time_offset")
1532 .expect("create tof")
1533 .write_raw(tof_ns)
1534 .expect("write tof");
1535 neutrons
1536 .new_dataset::<f64>()
1537 .shape([x.len()])
1538 .create("x")
1539 .expect("create x")
1540 .write_raw(x)
1541 .expect("write x");
1542 neutrons
1543 .new_dataset::<f64>()
1544 .shape([y.len()])
1545 .create("y")
1546 .expect("create y")
1547 .write_raw(y)
1548 .expect("write y");
1549 }
1550
1551 #[test]
1552 fn test_histogram_known_events() {
1553 let dir = tempfile::tempdir().unwrap();
1554 let path = dir.path().join("events.h5");
1555
1556 let tof_ns = vec![1_500_000, 2_500_000, 1_800_000];
1558 let x = vec![1.0, 1.0, 1.0];
1559 let y = vec![0.0, 0.0, 0.0];
1560 create_test_events(&path, &tof_ns, &x, &y, Some(25.0));
1561
1562 let params = EventBinningParams {
1563 n_bins: 2,
1564 tof_min_us: 1000.0,
1565 tof_max_us: 3000.0,
1566 height: 2,
1567 width: 3,
1568 };
1569
1570 let data = load_nexus_events(&path, ¶ms).unwrap();
1571 assert_eq!(data.counts.shape(), &[2, 2, 3]);
1572
1573 assert_eq!(data.counts[[0, 0, 1]], 2.0);
1575 assert_eq!(data.counts[[1, 0, 1]], 1.0);
1577
1578 assert_eq!(data.flight_path_m, Some(25.0));
1579 assert_eq!(data.tof_edges_us.len(), 3); let stats = data
1583 .event_stats
1584 .as_ref()
1585 .expect("event_stats should be Some");
1586 assert_eq!(stats.total, 3);
1587 assert_eq!(stats.kept, 3);
1588 assert_eq!(stats.dropped_non_finite, 0);
1589 assert_eq!(stats.dropped_tof_range, 0);
1590 assert_eq!(stats.dropped_spatial, 0);
1591 }
1592
1593 #[test]
1594 fn test_filter_out_of_range_events() {
1595 let dir = tempfile::tempdir().unwrap();
1596 let path = dir.path().join("events_oob.h5");
1597
1598 let tof_ns = vec![
1600 1_500_000, 500_000, 1_500_000, ];
1604 let x = vec![0.0, 0.0, 5.0]; let y = vec![0.0, 0.0, 0.0];
1606 create_test_events(&path, &tof_ns, &x, &y, None);
1607
1608 let params = EventBinningParams {
1609 n_bins: 2,
1610 tof_min_us: 1000.0,
1611 tof_max_us: 3000.0,
1612 height: 2,
1613 width: 3,
1614 };
1615
1616 let data = load_nexus_events(&path, ¶ms).unwrap();
1617
1618 let total: f64 = data.counts.iter().sum();
1620 assert_eq!(total, 1.0);
1621 assert_eq!(data.counts[[0, 0, 0]], 1.0);
1622
1623 let stats = data
1625 .event_stats
1626 .as_ref()
1627 .expect("event_stats should be Some");
1628 assert_eq!(stats.total, 3);
1629 assert_eq!(stats.kept, 1);
1630 assert_eq!(stats.dropped_non_finite, 0);
1631 assert_eq!(stats.dropped_tof_range, 1);
1632 assert_eq!(stats.dropped_spatial, 1);
1633 }
1634
1635 #[test]
1636 fn test_empty_events() {
1637 let dir = tempfile::tempdir().unwrap();
1638 let path = dir.path().join("empty_events.h5");
1639
1640 create_test_events(&path, &[], &[], &[], None);
1641
1642 let params = EventBinningParams {
1643 n_bins: 10,
1644 tof_min_us: 1000.0,
1645 tof_max_us: 20000.0,
1646 height: 4,
1647 width: 4,
1648 };
1649
1650 let data = load_nexus_events(&path, ¶ms).unwrap();
1651 assert_eq!(data.counts.shape(), &[10, 4, 4]);
1652
1653 let total: f64 = data.counts.iter().sum();
1654 assert_eq!(total, 0.0);
1655
1656 let stats = data
1658 .event_stats
1659 .as_ref()
1660 .expect("event_stats should be Some");
1661 assert_eq!(stats.total, 0);
1662 assert_eq!(stats.kept, 0);
1663 assert_eq!(stats.dropped_non_finite, 0);
1664 assert_eq!(stats.dropped_tof_range, 0);
1665 assert_eq!(stats.dropped_spatial, 0);
1666 }
1667
1668 #[test]
1669 fn test_probe_with_events() {
1670 let dir = tempfile::tempdir().unwrap();
1671 let path = dir.path().join("with_events.h5");
1672
1673 create_test_events(
1674 &path,
1675 &[1000, 2000, 3000],
1676 &[0.0, 1.0, 2.0],
1677 &[0.0, 0.0, 1.0],
1678 None,
1679 );
1680
1681 let meta = probe_nexus(&path).unwrap();
1682 assert!(!meta.has_histogram);
1683 assert!(meta.has_events);
1684 assert_eq!(meta.n_events, Some(3));
1685 }
1686
1687 #[test]
1688 fn test_list_hdf5_tree() {
1689 let dir = tempfile::tempdir().unwrap();
1690 let path = dir.path().join("tree.h5");
1691
1692 {
1694 let file = hdf5::File::create(&path).expect("create file");
1695 let g1 = file.create_group("entry").expect("create entry");
1696 let g2 = g1.create_group("histogram").expect("create histogram");
1697 g2.new_dataset::<f64>()
1698 .shape([3])
1699 .create("data")
1700 .expect("create data")
1701 .write_raw(&[1.0, 2.0, 3.0])
1702 .expect("write data");
1703 }
1704
1705 let tree = list_hdf5_tree(&path, 10).unwrap();
1706 assert!(!tree.is_empty());
1707
1708 let paths: Vec<&str> = tree.iter().map(|e| e.path.as_str()).collect();
1710 assert!(paths.contains(&"/entry"));
1711 assert!(paths.contains(&"/entry/histogram"));
1712 assert!(paths.contains(&"/entry/histogram/data"));
1713
1714 let data_entry = tree
1716 .iter()
1717 .find(|e| e.path == "/entry/histogram/data")
1718 .unwrap();
1719 assert!(data_entry.shape.is_some());
1720 }
1721
1722 #[test]
1723 fn test_nan_xy_coords_dropped() {
1724 let dir = tempfile::tempdir().unwrap();
1725 let path = dir.path().join("nan_xy.h5");
1726
1727 let tof_ns = vec![1_500_000, 1_500_000, 1_500_000, 2_500_000];
1729 let x = vec![0.0, f64::NAN, 0.0, 1.0];
1730 let y = vec![0.0, 0.0, f64::INFINITY, 0.0];
1731 create_test_events(&path, &tof_ns, &x, &y, None);
1732
1733 let params = EventBinningParams {
1734 n_bins: 2,
1735 tof_min_us: 1000.0,
1736 tof_max_us: 3000.0,
1737 height: 2,
1738 width: 3,
1739 };
1740
1741 let data = load_nexus_events(&path, ¶ms).unwrap();
1742
1743 let total_counts: f64 = data.counts.iter().sum();
1745 assert_eq!(total_counts, 2.0);
1746
1747 let stats = data
1748 .event_stats
1749 .as_ref()
1750 .expect("event_stats should be Some");
1751 assert_eq!(stats.total, 4);
1752 assert_eq!(stats.kept, 2);
1753 assert_eq!(stats.dropped_non_finite, 2);
1754 assert_eq!(stats.dropped_tof_range, 0);
1755 assert_eq!(stats.dropped_spatial, 0);
1756 }
1757
1758 fn write_units_attr(ds: &hdf5::Dataset, units: &str) {
1768 let val: VarLenUnicode = units.parse().expect("parse units string");
1769 ds.new_attr::<VarLenUnicode>()
1770 .shape(())
1771 .create("units")
1772 .expect("create units attr")
1773 .write_scalar(&val)
1774 .expect("write units attr");
1775 }
1776
1777 fn create_test_histogram_with_units(
1780 path: &Path,
1781 counts: &[u64],
1782 shape: [usize; 4],
1783 tof_values: &[f64],
1784 units: Option<&str>,
1785 ) {
1786 let file = hdf5::File::create(path).expect("create test file");
1787 let entry = file.create_group("entry").expect("create entry");
1788 let hist = entry.create_group("histogram").expect("create histogram");
1789 hist.new_dataset::<u64>()
1790 .shape(shape)
1791 .create("counts")
1792 .expect("create counts")
1793 .write_raw(counts)
1794 .expect("write counts");
1795 let tof_ds = hist
1796 .new_dataset::<f64>()
1797 .shape([tof_values.len()])
1798 .create("time_of_flight")
1799 .expect("create tof");
1800 tof_ds.write_raw(tof_values).expect("write tof");
1801 if let Some(u) = units {
1802 write_units_attr(&tof_ds, u);
1803 }
1804 }
1805
1806 fn create_test_events_with_units(
1809 path: &Path,
1810 tof_values: &[u64],
1811 x: &[f64],
1812 y: &[f64],
1813 units: Option<&str>,
1814 ) {
1815 let file = hdf5::File::create(path).expect("create");
1816 let entry = file.create_group("entry").expect("create entry");
1817 let neutrons = entry.create_group("neutrons").expect("create neutrons");
1818 let tof_ds = neutrons
1819 .new_dataset::<u64>()
1820 .shape([tof_values.len()])
1821 .create("event_time_offset")
1822 .expect("create tof");
1823 tof_ds.write_raw(tof_values).expect("write tof");
1824 if let Some(u) = units {
1825 write_units_attr(&tof_ds, u);
1826 }
1827 neutrons
1828 .new_dataset::<f64>()
1829 .shape([x.len()])
1830 .create("x")
1831 .expect("create x")
1832 .write_raw(x)
1833 .expect("write x");
1834 neutrons
1835 .new_dataset::<f64>()
1836 .shape([y.len()])
1837 .create("y")
1838 .expect("create y")
1839 .write_raw(y)
1840 .expect("write y");
1841 }
1842
1843 #[test]
1847 fn test_tof_scale_to_us_table() {
1848 assert!((tof_scale_to_us(None).unwrap() - 1e-3).abs() < 1e-15);
1850 for (spelling, expected) in &[
1851 ("ns", 1e-3),
1852 ("Ns", 1e-3),
1853 ("NS", 1e-3),
1854 ("nanoseconds", 1e-3),
1855 ("us", 1.0),
1856 ("US", 1.0),
1857 ("microseconds", 1.0),
1858 ("µs", 1.0),
1859 ("ms", 1e3),
1860 ("milliseconds", 1e3),
1861 ("s", 1e6),
1862 ("seconds", 1e6),
1863 (" s ", 1e6),
1864 ] {
1865 let got = tof_scale_to_us(Some(*spelling))
1866 .unwrap_or_else(|e| panic!("spelling {spelling:?} unexpectedly errored: {e}"));
1867 assert!(
1868 (got - expected).abs() < 1e-15,
1869 "spelling {spelling:?}: expected scale {expected}, got {got}"
1870 );
1871 }
1872 for bad in &["picoseconds", "ticks", "us per channel", "", "garbage"] {
1874 let err = tof_scale_to_us(Some(*bad)).expect_err("unknown units must error");
1875 let msg = err.to_string();
1876 assert!(
1877 msg.contains("Unsupported NeXus TOF units"),
1878 "error for {bad:?} should mention 'Unsupported NeXus TOF units', got: {msg}"
1879 );
1880 }
1881 }
1882
1883 #[test]
1887 fn test_load_nexus_histogram_units_ns_explicit() {
1888 let dir = tempfile::tempdir().unwrap();
1889 let path = dir.path().join("hist_units_ns.h5");
1890 let counts = vec![0u64; 2];
1891 let tof_ns = vec![1.0, 2.0, 3.0];
1893 create_test_histogram_with_units(&path, &counts, [1, 1, 1, 2], &tof_ns, Some("ns"));
1894
1895 let data = load_nexus_histogram(&path).expect("load with units=ns");
1896 assert_eq!(data.tof_edges_us.len(), 3);
1897 assert!((data.tof_edges_us[0] - 0.001).abs() < 1e-12);
1898 assert!((data.tof_edges_us[1] - 0.002).abs() < 1e-12);
1899 assert!((data.tof_edges_us[2] - 0.003).abs() < 1e-12);
1900 }
1901
1902 #[test]
1907 fn test_load_nexus_histogram_units_us_no_rescale() {
1908 let dir = tempfile::tempdir().unwrap();
1909 let path = dir.path().join("hist_units_us.h5");
1910 let counts = vec![0u64; 4];
1911 let tof_us = vec![1000.0, 2000.0, 3000.0, 4000.0, 5000.0];
1913 create_test_histogram_with_units(&path, &counts, [1, 1, 1, 4], &tof_us, Some("us"));
1914
1915 let data = load_nexus_histogram(&path).expect("load with units=us");
1916 assert_eq!(data.tof_edges_us.len(), 5);
1917 for (i, &expected) in tof_us.iter().enumerate() {
1918 assert!(
1919 (data.tof_edges_us[i] - expected).abs() < 1e-9,
1920 "edge {i}: expected {expected} µs (no rescale), got {} µs",
1921 data.tof_edges_us[i]
1922 );
1923 }
1924 }
1925
1926 #[test]
1929 fn test_load_nexus_histogram_units_seconds() {
1930 let dir = tempfile::tempdir().unwrap();
1931 let path = dir.path().join("hist_units_s.h5");
1932 let counts = vec![0u64; 2];
1933 let tof_s = vec![0.001, 0.002, 0.003];
1935 create_test_histogram_with_units(&path, &counts, [1, 1, 1, 2], &tof_s, Some("s"));
1936
1937 let data = load_nexus_histogram(&path).expect("load with units=s");
1938 assert!((data.tof_edges_us[0] - 1000.0).abs() < 1e-9);
1939 assert!((data.tof_edges_us[1] - 2000.0).abs() < 1e-9);
1940 assert!((data.tof_edges_us[2] - 3000.0).abs() < 1e-9);
1941 }
1942
1943 #[test]
1949 fn test_load_nexus_histogram_units_unknown_rejected() {
1950 let dir = tempfile::tempdir().unwrap();
1951 let path = dir.path().join("hist_units_bad.h5");
1952 let counts = vec![0u64; 2];
1953 let tof = vec![1.0, 2.0, 3.0];
1954 create_test_histogram_with_units(&path, &counts, [1, 1, 1, 2], &tof, Some("picoseconds"));
1955
1956 let err = load_nexus_histogram(&path).expect_err("unknown units must error");
1957 let msg = err.to_string();
1958 assert!(
1959 msg.contains("Unsupported NeXus TOF units") && msg.contains("picoseconds"),
1960 "error should name the offending value, got: {msg}"
1961 );
1962 }
1963
1964 #[test]
1970 fn test_load_nexus_histogram_units_missing_legacy_ns() {
1971 let dir = tempfile::tempdir().unwrap();
1972 let path = dir.path().join("hist_units_missing.h5");
1973 let counts = vec![0u64; 2];
1974 let tof_ns = vec![1000.0, 2000.0, 3000.0];
1975 create_test_histogram_with_units(&path, &counts, [1, 1, 1, 2], &tof_ns, None);
1977 let data = load_nexus_histogram(&path).expect("load with no units attr");
1978 assert!((data.tof_edges_us[0] - 1.0).abs() < 1e-12);
1979 assert!((data.tof_edges_us[1] - 2.0).abs() < 1e-12);
1980 assert!((data.tof_edges_us[2] - 3.0).abs() < 1e-12);
1981 }
1982
1983 #[test]
1989 fn test_load_nexus_events_units_us_no_rescale() {
1990 let dir = tempfile::tempdir().unwrap();
1991 let path = dir.path().join("events_units_us.h5");
1992
1993 let tof_us = vec![1500u64, 2500u64, 1800u64];
1995 let x = vec![1.0, 1.0, 1.0];
1996 let y = vec![0.0, 0.0, 0.0];
1997 create_test_events_with_units(&path, &tof_us, &x, &y, Some("us"));
1998
1999 let params = EventBinningParams {
2000 n_bins: 2,
2001 tof_min_us: 1000.0,
2002 tof_max_us: 3000.0,
2003 height: 2,
2004 width: 3,
2005 };
2006 let data = load_nexus_events(&path, ¶ms).expect("load events with units=us");
2007
2008 assert_eq!(data.counts[[0, 0, 1]], 2.0);
2010 assert_eq!(data.counts[[1, 0, 1]], 1.0);
2012 let stats = data.event_stats.as_ref().expect("event stats");
2013 assert_eq!(stats.kept, 3);
2014 assert_eq!(stats.dropped_tof_range, 0);
2015 }
2016
2017 #[test]
2020 fn test_load_nexus_events_units_ns_explicit() {
2021 let dir = tempfile::tempdir().unwrap();
2022 let path = dir.path().join("events_units_ns.h5");
2023
2024 let tof_ns = vec![1_500_000u64, 2_500_000u64, 1_800_000u64];
2026 let x = vec![1.0, 1.0, 1.0];
2027 let y = vec![0.0, 0.0, 0.0];
2028 create_test_events_with_units(&path, &tof_ns, &x, &y, Some("ns"));
2029
2030 let params = EventBinningParams {
2031 n_bins: 2,
2032 tof_min_us: 1000.0,
2033 tof_max_us: 3000.0,
2034 height: 2,
2035 width: 3,
2036 };
2037 let data = load_nexus_events(&path, ¶ms).expect("load events with units=ns");
2038 assert_eq!(data.counts[[0, 0, 1]], 2.0);
2039 assert_eq!(data.counts[[1, 0, 1]], 1.0);
2040 }
2041
2042 #[test]
2044 fn test_load_nexus_events_units_unknown_rejected() {
2045 let dir = tempfile::tempdir().unwrap();
2046 let path = dir.path().join("events_units_bad.h5");
2047 let tof = vec![1_500_000u64];
2048 let x = vec![0.0];
2049 let y = vec![0.0];
2050 create_test_events_with_units(&path, &tof, &x, &y, Some("clock-ticks"));
2051
2052 let params = EventBinningParams {
2053 n_bins: 2,
2054 tof_min_us: 1000.0,
2055 tof_max_us: 3000.0,
2056 height: 2,
2057 width: 3,
2058 };
2059 let err = load_nexus_events(&path, ¶ms).expect_err("unknown units must error");
2060 let msg = err.to_string();
2061 assert!(
2062 msg.contains("Unsupported NeXus TOF units") && msg.contains("clock-ticks"),
2063 "error should name the offending value, got: {msg}"
2064 );
2065 }
2066}
2067
2068#[derive(Debug, Clone, Copy)]
2079pub struct BankBinningParams {
2080 pub n_bins: usize,
2082 pub tof_min_us: f64,
2084 pub tof_max_us: f64,
2086}
2087
2088#[derive(Debug, Clone)]
2096pub struct BankSpectrum {
2097 pub tof_edges_us: Vec<f64>,
2099 pub counts: Vec<u64>,
2101 pub pulses_total: usize,
2103 pub pulses_kept: usize,
2106 pub events_total: usize,
2108 pub events_kept: usize,
2110 pub dropped_tof_range: usize,
2112 pub dropped_non_finite: usize,
2114 pub pulse_time_offset_iso: Option<String>,
2120}
2121
2122pub fn load_nexus_bank_spectrum(
2153 path: &Path,
2154 bank: &str,
2155 params: &BankBinningParams,
2156 keep_intervals: Option<&[(f64, f64)]>,
2157) -> Result<BankSpectrum, IoError> {
2158 if params.n_bins == 0 {
2159 return Err(IoError::InvalidParameter("n_bins must be positive".into()));
2160 }
2161 if !params.tof_min_us.is_finite() || !params.tof_max_us.is_finite() {
2162 return Err(IoError::InvalidParameter(
2163 "TOF bounds must be finite".into(),
2164 ));
2165 }
2166 if params.tof_max_us <= params.tof_min_us {
2167 return Err(IoError::InvalidParameter(format!(
2168 "tof_max_us ({}) must be greater than tof_min_us ({})",
2169 params.tof_max_us, params.tof_min_us
2170 )));
2171 }
2172 let intervals: Option<Vec<(f64, f64)>> = match keep_intervals {
2174 None => None,
2175 Some(raw) => Some(crate::runlog::normalize_intervals(raw)?),
2176 };
2177
2178 let file = hdf5::File::open(path).map_err(|e| {
2179 IoError::FileNotFound(
2180 path.display().to_string(),
2181 std::io::Error::other(e.to_string()),
2182 )
2183 })?;
2184 let group = file
2185 .group(&format!("entry/{bank}"))
2186 .map_err(|e| IoError::InvalidParameter(format!("Missing /entry/{bank} group: {e}")))?;
2187
2188 let etz_ds = group.dataset("event_time_zero").map_err(|e| {
2189 IoError::InvalidParameter(format!("Missing /entry/{bank}/event_time_zero: {e}"))
2190 })?;
2191 let etz_to_s = match read_string_attr(&etz_ds, "units")? {
2196 None => {
2197 return Err(IoError::InvalidParameter(format!(
2198 "/entry/{bank}/event_time_zero has no units attribute; refusing to \
2199 guess a time scale (issues #554/#637)"
2200 )));
2201 }
2202 Some(u) => tof_scale_to_us(Some(&u))? * 1e-6,
2203 };
2204 let event_time_zero: Vec<f64> = etz_ds
2205 .read_1d::<f64>()
2206 .map_err(|e| IoError::Hdf5Error(format!("Failed to read {bank}/event_time_zero: {e}")))?
2207 .to_vec()
2208 .into_iter()
2209 .map(|t| t * etz_to_s)
2210 .collect();
2211 if let Some(i) = event_time_zero.iter().position(|t| !t.is_finite()) {
2216 return Err(IoError::InvalidParameter(format!(
2217 "{bank}/event_time_zero[{i}] is not finite ({}); corrupt pulse \
2218 times would silently exclude events from the accounting",
2219 event_time_zero[i]
2220 )));
2221 }
2222 let pulse_time_offset_iso = read_string_attr(&etz_ds, "offset")?;
2223
2224 let event_index: Vec<u64> = group
2225 .dataset("event_index")
2226 .map_err(|e| IoError::InvalidParameter(format!("Missing /entry/{bank}/event_index: {e}")))?
2227 .read_1d::<u64>()
2228 .map_err(|e| IoError::Hdf5Error(format!("Failed to read {bank}/event_index: {e}")))?
2229 .to_vec();
2230 if event_index.len() != event_time_zero.len() {
2231 return Err(IoError::ShapeMismatch(format!(
2232 "{bank}: event_index has {} entries but event_time_zero has {}",
2233 event_index.len(),
2234 event_time_zero.len()
2235 )));
2236 }
2237 if event_index.windows(2).any(|w| w[1] < w[0]) {
2238 return Err(IoError::InvalidParameter(format!(
2239 "{bank}/event_index must be non-decreasing (cumulative first-event index per pulse)"
2240 )));
2241 }
2242
2243 let eto_ds = group.dataset("event_time_offset").map_err(|e| {
2244 IoError::InvalidParameter(format!("Missing /entry/{bank}/event_time_offset: {e}"))
2245 })?;
2246 let tof_scale = match read_string_attr(&eto_ds, "units")? {
2247 Some(u) => tof_scale_to_us(Some(&u))?,
2248 None => {
2249 return Err(IoError::InvalidParameter(format!(
2250 "/entry/{bank}/event_time_offset has no units attribute; NXevent_data \
2251 producers declare TOF units explicitly and this loader refuses to \
2252 guess a scale factor (issues #554/#637)"
2253 )));
2254 }
2255 };
2256 let tof_raw: Vec<f64> = eto_ds
2257 .read_1d::<f64>()
2258 .map_err(|e| IoError::Hdf5Error(format!("Failed to read {bank}/event_time_offset: {e}")))?
2259 .to_vec();
2260 let events_total = tof_raw.len();
2261 if let Some(&last) = event_index.last()
2262 && last as usize > events_total
2263 {
2264 return Err(IoError::InvalidParameter(format!(
2265 "{bank}/event_index last entry ({last}) exceeds total event count ({events_total})"
2266 )));
2267 }
2268 match event_index.first() {
2273 Some(&first) if first != 0 => {
2274 return Err(IoError::InvalidParameter(format!(
2275 "{bank}/event_index first entry ({first}) must be 0: {first} event(s) \
2276 precede the first pulse and would be silently dropped"
2277 )));
2278 }
2279 None if events_total > 0 => {
2280 return Err(IoError::InvalidParameter(format!(
2281 "{bank} has {events_total} events but no pulses (empty event_index)"
2282 )));
2283 }
2284 _ => {}
2285 }
2286
2287 let pulses_total = event_time_zero.len();
2288 let bin_w = (params.tof_max_us - params.tof_min_us) / params.n_bins as f64;
2289 let keep_pulse = |t: f64| -> bool {
2290 let t = if t == 0.0 { 0.0 } else { t };
2293 match &intervals {
2294 None => true,
2295 Some(iv) => match iv.binary_search_by(|&(a, _)| a.total_cmp(&t)) {
2296 Ok(i) => t < iv[i].1,
2297 Err(0) => false,
2298 Err(i) => t < iv[i - 1].1,
2299 },
2300 }
2301 };
2302
2303 let mut counts = vec![0u64; params.n_bins];
2304 let mut pulses_kept = 0usize;
2305 let mut events_kept = 0usize;
2306 let mut dropped_tof_range = 0usize;
2307 let mut dropped_non_finite = 0usize;
2308 for p in 0..pulses_total {
2309 if !keep_pulse(event_time_zero[p]) {
2310 continue;
2311 }
2312 pulses_kept += 1;
2313 let e0 = event_index[p] as usize;
2314 let e1 = if p + 1 < pulses_total {
2315 event_index[p + 1] as usize
2316 } else {
2317 events_total
2318 };
2319 for &raw in &tof_raw[e0..e1] {
2320 let tof = raw * tof_scale;
2321 if !tof.is_finite() {
2322 dropped_non_finite += 1;
2323 continue;
2324 }
2325 if tof < params.tof_min_us || tof >= params.tof_max_us {
2326 dropped_tof_range += 1;
2327 continue;
2328 }
2329 let bin = (((tof - params.tof_min_us) / bin_w) as usize).min(params.n_bins - 1);
2332 counts[bin] += 1;
2333 events_kept += 1;
2334 }
2335 }
2336 let tof_edges_us = (0..=params.n_bins)
2337 .map(|i| params.tof_min_us + i as f64 * bin_w)
2338 .collect();
2339 Ok(BankSpectrum {
2340 tof_edges_us,
2341 counts,
2342 pulses_total,
2343 pulses_kept,
2344 events_total,
2345 events_kept,
2346 dropped_tof_range,
2347 dropped_non_finite,
2348 pulse_time_offset_iso,
2349 })
2350}
2351
2352#[cfg(test)]
2353mod bank_tests {
2354 use super::*;
2355
2356 fn create_test_bank(
2359 path: &Path,
2360 bank: &str,
2361 pulse_times_s: &[f64],
2362 events_per_pulse: &[Vec<f64>],
2363 tof_units: Option<&str>,
2364 tof_store_scale: f64,
2365 ) {
2366 assert_eq!(pulse_times_s.len(), events_per_pulse.len());
2367 let file = hdf5::File::create(path).expect("create test file");
2368 let entry = if let Ok(g) = file.group("entry") {
2369 g
2370 } else {
2371 file.create_group("entry").expect("create entry")
2372 };
2373 let g = entry.create_group(bank).expect("create bank");
2374 let mut index: Vec<u64> = Vec::new();
2375 let mut tofs: Vec<f64> = Vec::new();
2376 for evs in events_per_pulse {
2377 index.push(tofs.len() as u64);
2378 tofs.extend(evs.iter().map(|t| t * tof_store_scale));
2379 }
2380 let etz = g
2381 .new_dataset_builder()
2382 .with_data(pulse_times_s)
2383 .create("event_time_zero")
2384 .expect("etz");
2385 etz.new_attr::<hdf5::types::VarLenUnicode>()
2386 .create("units")
2387 .expect("attr")
2388 .write_scalar(&"second".parse::<hdf5::types::VarLenUnicode>().unwrap())
2389 .expect("write");
2390 etz.new_attr::<hdf5::types::VarLenUnicode>()
2391 .create("offset")
2392 .expect("attr")
2393 .write_scalar(
2394 &"2026-06-22T19:01:07.183368667-04:00"
2395 .parse::<hdf5::types::VarLenUnicode>()
2396 .unwrap(),
2397 )
2398 .expect("write");
2399 g.new_dataset_builder()
2400 .with_data(&index)
2401 .create("event_index")
2402 .expect("ei");
2403 let eto = g
2404 .new_dataset_builder()
2405 .with_data(&tofs)
2406 .create("event_time_offset")
2407 .expect("eto");
2408 if let Some(u) = tof_units {
2409 eto.new_attr::<hdf5::types::VarLenUnicode>()
2410 .create("units")
2411 .expect("attr")
2412 .write_scalar(&u.parse::<hdf5::types::VarLenUnicode>().unwrap())
2413 .expect("write");
2414 }
2415 }
2416
2417 fn params(n_bins: usize, lo: f64, hi: f64) -> BankBinningParams {
2418 BankBinningParams {
2419 n_bins,
2420 tof_min_us: lo,
2421 tof_max_us: hi,
2422 }
2423 }
2424
2425 #[test]
2426 fn unfiltered_spectrum_counts_all_events() {
2427 let dir = tempfile::tempdir().unwrap();
2428 let path = dir.path().join("bank.h5");
2429 create_test_bank(
2430 &path,
2431 "monitor1",
2432 &[0.0, 1.0, 2.0],
2433 &[vec![100.0, 900.0], vec![500.0], vec![100.0, 500.0, 900.0]],
2434 Some("microsecond"),
2435 1.0,
2436 );
2437 let s = load_nexus_bank_spectrum(&path, "monitor1", ¶ms(2, 0.0, 1000.0), None)
2438 .expect("load");
2439 assert_eq!(s.pulses_total, 3);
2440 assert_eq!(s.pulses_kept, 3);
2441 assert_eq!(s.events_total, 6);
2442 assert_eq!(s.events_kept, 6);
2443 assert_eq!(s.counts, vec![2, 4]); assert_eq!(s.tof_edges_us, vec![0.0, 500.0, 1000.0]);
2445 assert!(s.pulse_time_offset_iso.unwrap().starts_with("2026-06-22"));
2446 }
2447
2448 #[test]
2449 fn interval_filter_keeps_only_matching_pulses_with_boundary_semantics() {
2450 let dir = tempfile::tempdir().unwrap();
2451 let path = dir.path().join("bank.h5");
2452 create_test_bank(
2454 &path,
2455 "monitor1",
2456 &[0.0, 10.0, 20.0, 30.0],
2457 &[vec![50.0], vec![50.0; 2], vec![50.0; 4], vec![50.0; 8]],
2458 Some("microsecond"),
2459 1.0,
2460 );
2461 let s = load_nexus_bank_spectrum(
2463 &path,
2464 "monitor1",
2465 ¶ms(1, 0.0, 100.0),
2466 Some(&[(10.0, 30.0)]),
2467 )
2468 .expect("load");
2469 assert_eq!(s.pulses_kept, 2);
2470 assert_eq!(s.events_kept, 6);
2471 assert_eq!(s.counts, vec![6]);
2472 let s2 = load_nexus_bank_spectrum(
2474 &path,
2475 "monitor1",
2476 ¶ms(1, 0.0, 100.0),
2477 Some(&[(15.0, 30.0), (10.0, 20.0)]),
2478 )
2479 .expect("load");
2480 assert_eq!(s2.events_kept, 6);
2481 let s3 = load_nexus_bank_spectrum(&path, "monitor1", ¶ms(1, 0.0, 100.0), Some(&[]))
2483 .expect("load");
2484 assert_eq!((s3.pulses_kept, s3.events_kept), (0, 0));
2485 assert_eq!(s3.counts, vec![0]);
2486 }
2487
2488 #[test]
2489 fn empty_bank_loads_gracefully() {
2490 let dir = tempfile::tempdir().unwrap();
2491 let path = dir.path().join("bank.h5");
2492 create_test_bank(
2494 &path,
2495 "bank100_events",
2496 &[0.0, 1.0, 2.0],
2497 &[vec![], vec![], vec![]],
2498 Some("microsecond"),
2499 1.0,
2500 );
2501 let s = load_nexus_bank_spectrum(
2502 &path,
2503 "bank100_events",
2504 ¶ms(4, 0.0, 1000.0),
2505 Some(&[(0.5, 2.5)]),
2506 )
2507 .expect("empty bank must load");
2508 assert_eq!(s.pulses_total, 3);
2509 assert_eq!(s.pulses_kept, 2);
2510 assert_eq!(s.events_total, 0);
2511 assert_eq!(s.counts, vec![0, 0, 0, 0]);
2512 }
2513
2514 #[test]
2515 fn tof_units_are_scaled_and_required() {
2516 let dir = tempfile::tempdir().unwrap();
2517 let p_ns = dir.path().join("ns.h5");
2519 create_test_bank(&p_ns, "m", &[0.0], &[vec![250.0, 750.0]], Some("ns"), 1e3);
2520 let s = load_nexus_bank_spectrum(&p_ns, "m", ¶ms(2, 0.0, 1000.0), None).unwrap();
2521 assert_eq!(s.counts, vec![1, 1]);
2522 let p_none = dir.path().join("none.h5");
2524 create_test_bank(&p_none, "m", &[0.0], &[vec![250.0]], None, 1.0);
2525 let err = load_nexus_bank_spectrum(&p_none, "m", ¶ms(2, 0.0, 1000.0), None)
2526 .expect_err("must refuse to guess units");
2527 assert!(err.to_string().contains("units"), "{err}");
2528 }
2529
2530 #[test]
2531 fn fixed_length_ascii_attributes_read_correctly() {
2532 let dir = tempfile::tempdir().unwrap();
2535 let path = dir.path().join("fixed.h5");
2536 create_test_bank(&path, "m", &[0.0], &[vec![250.0, 750.0]], None, 1.0);
2537 {
2538 let file = hdf5::File::open_rw(&path).expect("reopen");
2539 let eto = file.dataset("entry/m/event_time_offset").expect("eto");
2540 let units = hdf5::types::FixedAscii::<16>::from_ascii(b"microsecond").unwrap();
2541 eto.new_attr::<hdf5::types::FixedAscii<16>>()
2542 .create("units")
2543 .expect("attr")
2544 .write_scalar(&units)
2545 .expect("write");
2546 }
2547 let s = load_nexus_bank_spectrum(&path, "m", ¶ms(2, 0.0, 1000.0), None)
2548 .expect("fixed-ascii units must parse");
2549 assert_eq!(s.counts, vec![1, 1]);
2550 }
2551
2552 #[test]
2553 fn non_finite_pulse_time_fails_loud() {
2554 let dir = tempfile::tempdir().unwrap();
2557 let path = dir.path().join("nanpulse.h5");
2558 create_test_bank(
2559 &path,
2560 "m",
2561 &[0.0, f64::NAN],
2562 &[vec![100.0], vec![200.0]],
2563 Some("us"),
2564 1.0,
2565 );
2566 let err = load_nexus_bank_spectrum(&path, "m", ¶ms(1, 0.0, 1000.0), None)
2567 .expect_err("non-finite pulse time must error");
2568 assert!(err.to_string().contains("not finite"), "{err}");
2569 }
2570
2571 #[test]
2572 fn orphan_head_events_fail_loud() {
2573 let dir = tempfile::tempdir().unwrap();
2576 let path = dir.path().join("orphan.h5");
2577 create_test_bank(&path, "m", &[0.0], &[vec![100.0, 200.0]], Some("us"), 1.0);
2578 {
2579 let file = hdf5::File::open_rw(&path).expect("reopen");
2580 let ei = file.dataset("entry/m/event_index").expect("ei");
2581 ei.write(&ndarray::arr1(&[1u64])).expect("overwrite");
2582 }
2583 let err = load_nexus_bank_spectrum(&path, "m", ¶ms(1, 0.0, 1000.0), None)
2584 .expect_err("orphan head events must error");
2585 assert!(err.to_string().contains("precede the first pulse"), "{err}");
2586 }
2587
2588 #[test]
2589 fn malformed_inputs_error() {
2590 let dir = tempfile::tempdir().unwrap();
2591 let path = dir.path().join("bank.h5");
2592 create_test_bank(
2593 &path,
2594 "m",
2595 &[0.0, 1.0],
2596 &[vec![1.0], vec![2.0]],
2597 Some("us"),
2598 1.0,
2599 );
2600 for bad in [(5.0, 5.0), (5.0, 1.0), (f64::NAN, 1.0)] {
2602 assert!(
2603 load_nexus_bank_spectrum(&path, "m", ¶ms(1, 0.0, 10.0), Some(&[bad])).is_err()
2604 );
2605 }
2606 assert!(load_nexus_bank_spectrum(&path, "m", ¶ms(0, 0.0, 10.0), None).is_err());
2608 assert!(load_nexus_bank_spectrum(&path, "m", ¶ms(1, 10.0, 10.0), None).is_err());
2609 assert!(load_nexus_bank_spectrum(&path, "nope", ¶ms(1, 0.0, 10.0), None).is_err());
2611 }
2612}