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