1use std::path::Path;
67
68use hdf5::types::VarLenUnicode;
69use ndarray::{Array3, s};
70
71use crate::error::IoError;
72
73fn tof_scale_to_us(units: Option<&str>) -> Result<f64, IoError> {
83 match units {
84 None => Ok(1e-3),
89 Some(raw) => {
90 let normalised = raw.trim().to_ascii_lowercase();
91 match normalised.as_str() {
92 "ns" | "nanosecond" | "nanoseconds" => Ok(1e-3),
93 "us" | "µs" | "\u{03bc}s" | "microsecond" | "microseconds" => Ok(1.0),
101 "ms" | "millisecond" | "milliseconds" => Ok(1e3),
102 "s" | "sec" | "second" | "seconds" => Ok(1e6),
103 _ => Err(IoError::InvalidParameter(format!(
104 "Unsupported NeXus TOF units attribute {raw:?}: expected one of \
105 'ns', 'us'/'µs', 'ms', 's' (case-insensitive); refusing to \
106 guess a scale factor (issue #554)"
107 ))),
108 }
109 }
110 }
111}
112
113fn read_string_attr(loc: &hdf5::Location, name: &str) -> Result<Option<String>, IoError> {
126 let names = loc.attr_names().map_err(|e| {
132 IoError::InvalidParameter(format!(
133 "Failed to list attributes while looking for {name:?}: {e}"
134 ))
135 })?;
136 if !names.iter().any(|n| n == name) {
137 return Ok(None);
138 }
139 let attr = loc.attr(name).map_err(|e| {
140 IoError::InvalidParameter(format!(
141 "Failed to open attribute {name:?} (listed but unreadable): {e}"
142 ))
143 })?;
144 let value: VarLenUnicode = attr.read_scalar().map_err(|e| {
145 IoError::InvalidParameter(format!(
146 "Failed to read string attribute {name:?}: {e} (expected a UTF-8 string)"
147 ))
148 })?;
149 Ok(Some(value.as_str().to_string()))
150}
151
152#[derive(Debug, Clone)]
154pub struct NexusMetadata {
155 pub has_histogram: bool,
157 pub has_events: bool,
159 pub histogram_shape: Option<[usize; 4]>,
161 pub n_events: Option<usize>,
163 pub flight_path_m: Option<f64>,
165 pub tof_offset_ns: Option<f64>,
167 pub tof_edges_us: Option<Vec<f64>>,
172}
173
174#[derive(Debug, Clone)]
176pub struct Hdf5TreeEntry {
177 pub path: String,
179 pub kind: Hdf5EntryKind,
181 pub shape: Option<Vec<usize>>,
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum Hdf5EntryKind {
188 Group,
189 Dataset,
190}
191
192#[derive(Debug, Clone)]
194pub struct NexusHistogramData {
195 pub counts: Array3<f64>,
197 pub tof_edges_us: Vec<f64>,
199 pub flight_path_m: Option<f64>,
201 pub dead_pixels: Option<ndarray::Array2<bool>>,
203 pub n_rotation_angles: usize,
205 pub event_stats: Option<EventRetentionStats>,
207}
208
209#[derive(Debug, Clone)]
211pub struct EventRetentionStats {
212 pub total: usize,
214 pub kept: usize,
216 pub dropped_non_finite: usize,
223 pub dropped_tof_range: usize,
225 pub dropped_spatial: usize,
227}
228
229pub fn probe_nexus(path: &Path) -> Result<NexusMetadata, IoError> {
234 let file = hdf5::File::open(path).map_err(|e| {
235 IoError::FileNotFound(
236 path.display().to_string(),
237 std::io::Error::other(e.to_string()),
238 )
239 })?;
240
241 let entry = file
242 .group("entry")
243 .map_err(|e| IoError::InvalidParameter(format!("Missing /entry group: {e}")))?;
244
245 let (has_histogram, histogram_shape, tof_edges_us) = probe_histogram_group(&entry);
247
248 let (has_events, n_events) = probe_event_group(&entry);
250
251 let flight_path_m = read_f64_attr(&entry, "flight_path_m");
253 let tof_offset_ns = read_f64_attr(&entry, "tof_offset_ns");
254
255 Ok(NexusMetadata {
256 has_histogram,
257 has_events,
258 histogram_shape,
259 n_events,
260 flight_path_m,
261 tof_offset_ns,
262 tof_edges_us,
263 })
264}
265
266#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
272pub enum MultiAngleMode {
273 #[default]
278 Error,
279 Sum,
285 SelectAngle(usize),
288}
289
290pub fn load_nexus_histogram(path: &Path) -> Result<NexusHistogramData, IoError> {
306 load_nexus_histogram_with_mode(path, MultiAngleMode::Error)
307}
308
309pub fn load_nexus_histogram_with_mode(
317 path: &Path,
318 mode: MultiAngleMode,
319) -> Result<NexusHistogramData, IoError> {
320 let file = hdf5::File::open(path).map_err(|e| {
321 IoError::FileNotFound(
322 path.display().to_string(),
323 std::io::Error::other(e.to_string()),
324 )
325 })?;
326
327 let entry = file
328 .group("entry")
329 .map_err(|e| IoError::InvalidParameter(format!("Missing /entry group: {e}")))?;
330
331 let hist_group = entry
332 .group("histogram")
333 .map_err(|e| IoError::InvalidParameter(format!("Missing /entry/histogram group: {e}")))?;
334
335 let counts_ds = hist_group.dataset("counts").map_err(|e| {
337 IoError::InvalidParameter(format!("Missing /entry/histogram/counts dataset: {e}"))
338 })?;
339
340 let shape = counts_ds.shape();
341 if shape.len() != 4 {
342 return Err(IoError::ShapeMismatch(format!(
343 "Expected 4D histogram counts, got {}D",
344 shape.len()
345 )));
346 }
347
348 let n_rot = shape[0];
355 if n_rot == 0 {
356 return Err(IoError::InvalidParameter(
360 "NeXus histogram has zero rotation angles; /entry/histogram/counts axis 0 must \
361 be >= 1"
362 .into(),
363 ));
364 }
365 for (axis, name) in [(1usize, "y"), (2, "x"), (3, "tof")] {
371 if shape[axis] == 0 {
372 return Err(IoError::InvalidParameter(format!(
373 "NeXus histogram has a zero-sized {name} axis; \
374 /entry/histogram/counts axis {axis} must be >= 1 (shape {shape:?})"
375 )));
376 }
377 }
378 match mode {
379 MultiAngleMode::Error if n_rot > 1 => {
380 return Err(IoError::InvalidParameter(format!(
381 "NeXus histogram has {n_rot} rotation angles — refusing to silently \
382 combine them (issue #430). Call load_nexus_histogram_with_mode with \
383 MultiAngleMode::Sum to preserve the legacy sum-over-angles behaviour, \
384 or MultiAngleMode::SelectAngle(i) to extract a single projection."
385 )));
386 }
387 MultiAngleMode::SelectAngle(idx) if idx >= n_rot => {
388 return Err(IoError::InvalidParameter(format!(
389 "MultiAngleMode::SelectAngle({idx}) out of range: file has {n_rot} \
390 rotation angle(s); valid indices are 0..{n_rot} (exclusive, i.e. \
391 last valid index is {last})",
392 last = n_rot - 1
393 )));
394 }
395 _ => {}
396 }
397
398 let combined_yxtof: ndarray::Array3<u64> = match mode {
414 MultiAngleMode::Error | MultiAngleMode::Sum if n_rot == 1 => {
415 counts_ds.read_slice(s![0, .., .., ..]).map_err(|e| {
416 IoError::InvalidParameter(format!("Failed to read single-angle slice: {e}"))
417 })?
418 }
419 MultiAngleMode::Sum => {
420 let full: ndarray::Array4<u64> = counts_ds.read().map_err(|e| {
421 IoError::InvalidParameter(format!("Failed to read histogram counts: {e}"))
422 })?;
423 full.sum_axis(ndarray::Axis(0))
424 }
425 MultiAngleMode::SelectAngle(idx) => {
426 counts_ds.read_slice(s![idx, .., .., ..]).map_err(|e| {
427 IoError::InvalidParameter(format!("Failed to read selected-angle slice: {e}"))
428 })?
429 }
430 MultiAngleMode::Error => {
431 unreachable!("Error mode reached with n_rot = {n_rot}")
434 }
435 };
436
437 let counts_f64: Array3<f64> = combined_yxtof
439 .mapv(|v| v as f64)
440 .permuted_axes([2, 0, 1])
441 .as_standard_layout()
442 .into_owned();
443 let n_tof = counts_f64.shape()[0];
444
445 let tof_edges_us = read_tof_axis(&hist_group)?;
447
448 if tof_edges_us.len() != n_tof + 1 && tof_edges_us.len() != n_tof {
450 return Err(IoError::InvalidParameter(format!(
451 "TOF axis length {} is incompatible with {} histogram bins (expected {} or {})",
452 tof_edges_us.len(),
453 n_tof,
454 n_tof,
455 n_tof + 1
456 )));
457 }
458
459 let flight_path_m = read_f64_attr(&hist_group, "flight_path_m")
461 .or_else(|| read_f64_attr(&entry, "flight_path_m"));
462
463 let dead_pixels = read_dead_pixel_mask(&entry, (counts_f64.shape()[1], counts_f64.shape()[2]))?;
466
467 Ok(NexusHistogramData {
468 counts: counts_f64,
469 tof_edges_us,
470 flight_path_m,
471 dead_pixels,
472 n_rotation_angles: n_rot,
473 event_stats: None, })
475}
476
477#[derive(Debug, Clone)]
479pub struct EventBinningParams {
480 pub n_bins: usize,
482 pub tof_min_us: f64,
484 pub tof_max_us: f64,
486 pub height: usize,
488 pub width: usize,
490}
491
492pub fn load_nexus_events(
521 path: &Path,
522 params: &EventBinningParams,
523) -> Result<NexusHistogramData, IoError> {
524 if params.n_bins == 0 {
525 return Err(IoError::InvalidParameter("n_bins must be positive".into()));
526 }
527 if params.height == 0 || params.width == 0 {
528 return Err(IoError::InvalidParameter(
529 "height and width must be positive".into(),
530 ));
531 }
532 if !params.tof_min_us.is_finite() || !params.tof_max_us.is_finite() {
533 return Err(IoError::InvalidParameter(
534 "TOF bounds must be finite".into(),
535 ));
536 }
537 if params.tof_max_us <= params.tof_min_us {
538 return Err(IoError::InvalidParameter(format!(
539 "tof_max_us ({}) must be greater than tof_min_us ({})",
540 params.tof_max_us, params.tof_min_us
541 )));
542 }
543
544 let file = hdf5::File::open(path).map_err(|e| {
545 IoError::FileNotFound(
546 path.display().to_string(),
547 std::io::Error::other(e.to_string()),
548 )
549 })?;
550
551 let entry = file
552 .group("entry")
553 .map_err(|e| IoError::InvalidParameter(format!("Missing /entry group: {e}")))?;
554
555 let neutrons = entry
556 .group("neutrons")
557 .map_err(|e| IoError::InvalidParameter(format!("Missing /entry/neutrons group: {e}")))?;
558
559 let tof_ds = neutrons.dataset("event_time_offset").map_err(|e| {
562 IoError::InvalidParameter(format!("Missing event_time_offset dataset: {e}"))
563 })?;
564 let tof_units = read_string_attr(&tof_ds, "units")?;
565 let tof_scale = tof_scale_to_us(tof_units.as_deref())?;
566 let tof_raw: Vec<u64> = tof_ds
567 .read_1d()
568 .map_err(|e| IoError::InvalidParameter(format!("Failed to read event_time_offset: {e}")))?
569 .to_vec();
570
571 let x_coords: Vec<f64> = neutrons
572 .dataset("x")
573 .map_err(|e| IoError::InvalidParameter(format!("Missing x dataset: {e}")))?
574 .read_1d()
575 .map_err(|e| IoError::InvalidParameter(format!("Failed to read x: {e}")))?
576 .to_vec();
577
578 let y_coords: Vec<f64> = neutrons
579 .dataset("y")
580 .map_err(|e| IoError::InvalidParameter(format!("Missing y dataset: {e}")))?
581 .read_1d()
582 .map_err(|e| IoError::InvalidParameter(format!("Failed to read y: {e}")))?
583 .to_vec();
584
585 if tof_raw.len() != x_coords.len() || tof_raw.len() != y_coords.len() {
586 return Err(IoError::ShapeMismatch(format!(
587 "Event arrays have mismatched lengths: tof={}, x={}, y={}",
588 tof_raw.len(),
589 x_coords.len(),
590 y_coords.len()
591 )));
592 }
593
594 let tof_edges_us =
596 crate::tof::linspace_tof_edges(params.tof_min_us, params.tof_max_us, params.n_bins)?;
597
598 let dt_us = (params.tof_max_us - params.tof_min_us) / params.n_bins as f64;
600 let mut counts = Array3::<f64>::zeros((params.n_bins, params.height, params.width));
601 let total = tof_raw.len();
602 let mut kept = 0usize;
603 let mut dropped_non_finite = 0usize;
604 let mut dropped_tof_range = 0usize;
605 let mut dropped_spatial = 0usize;
606
607 for i in 0..tof_raw.len() {
608 let tof_us = tof_raw[i] as f64 * tof_scale;
613 if !tof_us.is_finite() {
614 dropped_non_finite += 1;
615 continue;
616 }
617
618 if tof_us < params.tof_min_us || tof_us >= params.tof_max_us {
619 dropped_tof_range += 1;
620 continue;
621 }
622
623 let xf = x_coords[i];
624 let yf = y_coords[i];
625 if !xf.is_finite() || !yf.is_finite() {
626 dropped_non_finite += 1;
627 continue;
628 }
629 let px = xf.round() as isize;
630 let py = yf.round() as isize;
631
632 if px < 0 || py < 0 || px >= params.width as isize || py >= params.height as isize {
633 dropped_spatial += 1;
634 continue;
635 }
636
637 let tof_bin = ((tof_us - params.tof_min_us) / dt_us) as usize;
638 let tof_bin = tof_bin.min(params.n_bins - 1);
639 counts[[tof_bin, py as usize, px as usize]] += 1.0;
640 kept += 1;
641 }
642
643 let flight_path_m = read_f64_attr(&neutrons, "flight_path_m")
645 .or_else(|| read_f64_attr(&entry, "flight_path_m"));
646
647 let dead_pixels = read_dead_pixel_mask(&entry, (params.height, params.width))?;
649
650 debug_assert_eq!(
651 total,
652 kept + dropped_non_finite + dropped_tof_range + dropped_spatial,
653 "event retention accounting mismatch"
654 );
655
656 Ok(NexusHistogramData {
657 counts,
658 tof_edges_us,
659 flight_path_m,
660 dead_pixels,
661 n_rotation_angles: 1,
662 event_stats: Some(EventRetentionStats {
663 total,
664 kept,
665 dropped_non_finite,
666 dropped_tof_range,
667 dropped_spatial,
668 }),
669 })
670}
671
672fn probe_histogram_group(entry: &hdf5::Group) -> (bool, Option<[usize; 4]>, Option<Vec<f64>>) {
687 let hist = match entry.group("histogram") {
688 Ok(g) => g,
689 Err(_) => return (false, None, None),
690 };
691
692 let counts = match hist.dataset("counts") {
693 Ok(ds) => ds,
694 Err(_) => return (false, None, None),
695 };
696
697 let shape = counts.shape();
698 if shape.len() != 4 {
699 return (false, None, None);
700 }
701
702 let histogram_shape = Some([shape[0], shape[1], shape[2], shape[3]]);
703
704 let tof_edges_us = hist.dataset("time_of_flight").ok().and_then(|ds| {
709 let raw = ds.read_1d::<f64>().ok()?.to_vec();
710 let units = read_string_attr(&ds, "units").ok()?;
715 let scale = tof_scale_to_us(units.as_deref()).ok()?;
716 Some(raw.into_iter().map(|v| v * scale).collect())
717 });
718
719 (true, histogram_shape, tof_edges_us)
720}
721
722fn probe_event_group(entry: &hdf5::Group) -> (bool, Option<usize>) {
724 let neutrons = match entry.group("neutrons") {
725 Ok(g) => g,
726 Err(_) => return (false, None),
727 };
728
729 let n_events = neutrons
730 .dataset("event_time_offset")
731 .ok()
732 .map(|ds| ds.shape().first().copied().unwrap_or(0));
733
734 (n_events.is_some(), n_events)
735}
736
737fn read_tof_axis(hist_group: &hdf5::Group) -> Result<Vec<f64>, IoError> {
740 let tof_ds = hist_group.dataset("time_of_flight").map_err(|e| {
741 IoError::InvalidParameter(format!(
742 "Missing /entry/histogram/time_of_flight dataset: {e}"
743 ))
744 })?;
745
746 let raw: Vec<f64> = tof_ds
747 .read_1d::<f64>()
748 .map_err(|e| IoError::InvalidParameter(format!("Failed to read time_of_flight: {e}")))?
749 .to_vec();
750
751 let units = read_string_attr(&tof_ds, "units")?;
755 let scale = tof_scale_to_us(units.as_deref())?;
756
757 let edges: Vec<f64> = raw.iter().map(|&v| v * scale).collect();
758
759 for (i, &edge) in edges.iter().enumerate() {
773 if !edge.is_finite() || edge <= 0.0 {
774 return Err(IoError::InvalidParameter(format!(
775 "NeXus TOF axis edge {i} must be finite and positive, got {edge}"
776 )));
777 }
778 }
779 crate::spectrum::validate_monotonic(&edges)?;
780
781 Ok(edges)
782}
783
784fn read_f64_attr(group: &hdf5::Group, name: &str) -> Option<f64> {
786 group
787 .attr(name)
788 .ok()
789 .and_then(|a| a.read_scalar::<f64>().ok())
790}
791
792fn read_dead_pixel_mask(
810 entry: &hdf5::Group,
811 expected_hw: (usize, usize),
812) -> Result<Option<ndarray::Array2<bool>>, IoError> {
813 let entry_members = entry
815 .member_names()
816 .map_err(|e| IoError::InvalidParameter(format!("Failed to list /entry members: {e}")))?;
817 if !entry_members.iter().any(|n| n == "pixel_masks") {
818 return Ok(None);
819 }
820 let masks = entry.group("pixel_masks").map_err(|e| {
822 IoError::InvalidParameter(format!(
823 "/entry/pixel_masks is present but is not a readable group: {e}"
824 ))
825 })?;
826
827 let mask_members = masks.member_names().map_err(|e| {
829 IoError::InvalidParameter(format!("Failed to list /entry/pixel_masks members: {e}"))
830 })?;
831 if !mask_members.iter().any(|n| n == "dead") {
832 return Ok(None);
833 }
834 let dead_ds = masks.dataset("dead").map_err(|e| {
836 IoError::InvalidParameter(format!(
837 "/entry/pixel_masks/dead is present but is not a readable dataset: {e}"
838 ))
839 })?;
840 let dead_u8: ndarray::Array2<u8> = dead_ds.read().map_err(|e| {
841 IoError::InvalidParameter(format!("Failed to read /entry/pixel_masks/dead: {e}"))
842 })?;
843 let (eh, ew) = expected_hw;
844 if dead_u8.dim() != (eh, ew) {
845 return Err(IoError::ShapeMismatch(format!(
846 "dead-pixel mask shape {:?} != detector spatial dimensions ({eh}, {ew})",
847 dead_u8.dim(),
848 )));
849 }
850 Ok(Some(dead_u8.mapv(|v| v != 0)))
851}
852
853pub fn list_hdf5_tree(path: &Path, max_depth: usize) -> Result<Vec<Hdf5TreeEntry>, IoError> {
859 let file = hdf5::File::open(path)
860 .map_err(|e| IoError::Hdf5Error(format!("Cannot open HDF5 file: {e}")))?;
861 let mut entries = Vec::new();
862 walk_group(
863 &file
864 .as_group()
865 .map_err(|e| IoError::Hdf5Error(format!("Cannot read root group: {e}")))?,
866 "/",
867 0,
868 max_depth,
869 &mut entries,
870 );
871 Ok(entries)
872}
873
874fn walk_group(
876 group: &hdf5::Group,
877 prefix: &str,
878 depth: usize,
879 max_depth: usize,
880 entries: &mut Vec<Hdf5TreeEntry>,
881) {
882 let Ok(members) = group.member_names() else {
883 return;
884 };
885 let mut members = members;
886 members.sort();
887 for name in &members {
888 let child_path = if prefix == "/" {
889 format!("/{name}")
890 } else {
891 format!("{prefix}/{name}")
892 };
893
894 if let Ok(ds) = group.dataset(name) {
896 let shape = ds.shape();
897 entries.push(Hdf5TreeEntry {
898 path: child_path,
899 kind: Hdf5EntryKind::Dataset,
900 shape: Some(shape),
901 });
902 } else if let Ok(child_group) = group.group(name) {
903 entries.push(Hdf5TreeEntry {
905 path: child_path.clone(),
906 kind: Hdf5EntryKind::Group,
907 shape: None,
908 });
909 if depth < max_depth {
910 walk_group(&child_group, &child_path, depth + 1, max_depth, entries);
911 }
912 }
913 }
914}
915
916#[cfg(test)]
917mod tests {
918 use super::*;
919
920 fn create_test_histogram(
922 path: &Path,
923 counts: &[u64],
924 shape: [usize; 4],
925 tof_ns: &[f64],
926 flight_path_m: Option<f64>,
927 ) {
928 let file = hdf5::File::create(path).expect("create test file");
929 let entry = file.create_group("entry").expect("create entry");
930
931 if let Some(fp) = flight_path_m {
932 entry
933 .new_attr::<f64>()
934 .shape(())
935 .create("flight_path_m")
936 .expect("create attr")
937 .write_scalar(&fp)
938 .expect("write attr");
939 }
940
941 let hist = entry.create_group("histogram").expect("create histogram");
942 hist.new_dataset::<u64>()
943 .shape(shape)
944 .create("counts")
945 .expect("create counts")
946 .write_raw(counts)
947 .expect("write counts");
948
949 hist.new_dataset::<f64>()
950 .shape([tof_ns.len()])
951 .create("time_of_flight")
952 .expect("create tof")
953 .write_raw(tof_ns)
954 .expect("write tof");
955 }
956
957 #[test]
958 fn test_probe_nexus_histogram() {
959 let dir = tempfile::tempdir().unwrap();
960 let path = dir.path().join("test.h5");
961
962 let counts = vec![0u64; 24];
964 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));
966
967 let meta = probe_nexus(&path).unwrap();
968 assert!(meta.has_histogram);
969 assert!(!meta.has_events);
970 assert_eq!(meta.histogram_shape, Some([1, 2, 3, 4]));
971 assert_eq!(meta.flight_path_m, Some(25.0));
972 let edges = meta.tof_edges_us.expect("probe should return TOF edges");
976 assert_eq!(edges.len(), 5);
977 for (i, &expected_us) in [1.0_f64, 2.0, 3.0, 4.0, 5.0].iter().enumerate() {
978 assert!(
979 (edges[i] - expected_us).abs() < 1e-12,
980 "edge {i}: expected {expected_us} µs, got {} µs",
981 edges[i]
982 );
983 }
984 }
985
986 #[test]
991 fn test_probe_nexus_histogram_units_us_no_rescale() {
992 let dir = tempfile::tempdir().unwrap();
993 let path = dir.path().join("probe_units_us.h5");
994
995 let counts = vec![0u64; 4];
996 let tof_us = vec![1000.0, 2000.0, 3000.0, 4000.0, 5000.0];
998 create_test_histogram_with_units(&path, &counts, [1, 1, 1, 4], &tof_us, Some("us"));
999
1000 let meta = probe_nexus(&path).expect("probe with units=us");
1001 let edges = meta.tof_edges_us.expect("TOF axis should be present");
1002 assert_eq!(edges.len(), 5);
1003 for (i, &expected_us) in tof_us.iter().enumerate() {
1004 assert!(
1005 (edges[i] - expected_us).abs() < 1e-9,
1006 "probe edge {i}: expected {expected_us} µs (no rescale), got {} µs",
1007 edges[i]
1008 );
1009 }
1010 }
1011
1012 #[test]
1013 fn test_load_nexus_histogram_single_angle() {
1014 let dir = tempfile::tempdir().unwrap();
1015 let path = dir.path().join("test.h5");
1016
1017 let mut counts = vec![0u64; 2 * 3 * 2];
1019 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));
1023
1024 let data = load_nexus_histogram(&path).unwrap();
1025
1026 assert_eq!(data.counts.shape(), &[2, 2, 3]);
1028 assert_eq!(data.counts[[0, 0, 0]], 15.0);
1030
1031 assert_eq!(data.tof_edges_us.len(), 3);
1033 assert!((data.tof_edges_us[0] - 1.0).abs() < 1e-10);
1034 assert!((data.tof_edges_us[1] - 2.0).abs() < 1e-10);
1035 assert!((data.tof_edges_us[2] - 3.0).abs() < 1e-10);
1036 assert_eq!(data.flight_path_m, Some(25.0));
1037 assert_eq!(data.n_rotation_angles, 1);
1038 }
1039
1040 #[test]
1043 fn test_load_nexus_histogram_multi_angle_errors_by_default() {
1044 let dir = tempfile::tempdir().unwrap();
1045 let path = dir.path().join("multi_angle.h5");
1046
1047 let counts = vec![1u64; 2 * 2 * 3 * 2];
1048 let tof_ns = vec![1000.0, 2000.0, 3000.0];
1049 create_test_histogram(&path, &counts, [2, 2, 3, 2], &tof_ns, Some(25.0));
1050
1051 let err = load_nexus_histogram(&path)
1052 .expect_err("multi-angle file must be rejected by the default loader");
1053 let msg = err.to_string();
1054 assert!(
1055 msg.contains("2 rotation angles") && msg.contains("#430"),
1056 "error message should name the angle count and reference #430, got: {msg}"
1057 );
1058 assert!(
1059 msg.contains("MultiAngleMode::Sum") && msg.contains("MultiAngleMode::SelectAngle"),
1060 "error message should point at the explicit-opt-in APIs, got: {msg}"
1061 );
1062 }
1063
1064 #[test]
1067 fn test_load_nexus_histogram_multi_angle_sum_mode() {
1068 let dir = tempfile::tempdir().unwrap();
1069 let path = dir.path().join("multi_angle_sum.h5");
1070
1071 let mut counts = vec![0u64; 2 * 2 * 3 * 2];
1072 counts[0] = 10; counts[12] = 5; let tof_ns = vec![1000.0, 2000.0, 3000.0];
1075 create_test_histogram(&path, &counts, [2, 2, 3, 2], &tof_ns, Some(25.0));
1076
1077 let data = load_nexus_histogram_with_mode(&path, MultiAngleMode::Sum).unwrap();
1078 assert_eq!(data.counts.shape(), &[2, 2, 3]);
1079 assert_eq!(data.counts[[0, 0, 0]], 15.0);
1081 assert_eq!(data.n_rotation_angles, 2);
1082 }
1083
1084 #[test]
1087 fn test_load_nexus_histogram_multi_angle_select_mode() {
1088 let dir = tempfile::tempdir().unwrap();
1089 let path = dir.path().join("multi_angle_select.h5");
1090
1091 let mut counts = vec![0u64; 3 * 2 * 3 * 2];
1092 counts[0] = 100; counts[12] = 200; counts[24] = 300; let tof_ns = vec![1000.0, 2000.0, 3000.0];
1096 create_test_histogram(&path, &counts, [3, 2, 3, 2], &tof_ns, Some(25.0));
1097
1098 let data = load_nexus_histogram_with_mode(&path, MultiAngleMode::SelectAngle(1)).unwrap();
1100 assert_eq!(data.counts[[0, 0, 0]], 200.0);
1101 assert_eq!(data.n_rotation_angles, 3);
1102
1103 let err = load_nexus_histogram_with_mode(&path, MultiAngleMode::SelectAngle(3))
1105 .expect_err("out-of-range angle index must error");
1106 let msg = err.to_string();
1107 assert!(
1108 msg.contains("SelectAngle(3)") && msg.contains("3 rotation angle"),
1109 "error should name the bad index and the actual count, got: {msg}"
1110 );
1111 }
1112
1113 #[test]
1117 fn test_load_nexus_histogram_single_angle_mode_parity() {
1118 let dir = tempfile::tempdir().unwrap();
1119 let path = dir.path().join("single_parity.h5");
1120 let counts = vec![7u64; 2 * 3 * 2];
1121 let tof_ns = vec![1000.0, 2000.0, 3000.0];
1122 create_test_histogram(&path, &counts, [1, 2, 3, 2], &tof_ns, None);
1123
1124 let d_err = load_nexus_histogram_with_mode(&path, MultiAngleMode::Error).unwrap();
1125 let d_sum = load_nexus_histogram_with_mode(&path, MultiAngleMode::Sum).unwrap();
1126 let d_sel = load_nexus_histogram_with_mode(&path, MultiAngleMode::SelectAngle(0)).unwrap();
1127 assert_eq!(d_err.counts, d_sum.counts);
1129 assert_eq!(d_err.counts, d_sel.counts);
1130 assert_eq!(d_err.counts[[0, 0, 0]], 7.0);
1132 assert_eq!(d_err.n_rotation_angles, 1);
1133 }
1134
1135 #[test]
1141 fn test_load_nexus_histogram_zero_angles_rejected() {
1142 let dir = tempfile::tempdir().unwrap();
1143 let path = dir.path().join("zero_angles.h5");
1144 let counts: Vec<u64> = Vec::new();
1145 let tof_ns = vec![1000.0, 2000.0, 3000.0];
1146 create_test_histogram(&path, &counts, [0, 2, 3, 2], &tof_ns, None);
1147
1148 for mode in [
1149 MultiAngleMode::Error,
1150 MultiAngleMode::Sum,
1151 MultiAngleMode::SelectAngle(0),
1152 ] {
1153 let err = load_nexus_histogram_with_mode(&path, mode).unwrap_err();
1154 let msg = err.to_string();
1155 assert!(
1156 msg.contains("zero rotation angles"),
1157 "mode {mode:?} zero-angle rejection should name the axis, got: {msg}"
1158 );
1159 }
1160 }
1161
1162 #[test]
1166 fn test_load_nexus_histogram_zero_sibling_axes_rejected() {
1167 for (shape, axis_name) in [
1168 ([1usize, 0, 3, 2], "y"),
1169 ([1, 2, 0, 2], "x"),
1170 ([1, 2, 3, 0], "tof"),
1171 ] {
1172 let dir = tempfile::tempdir().unwrap();
1173 let path = dir.path().join(format!("zero_{axis_name}.h5"));
1174 let counts: Vec<u64> = Vec::new(); let tof_ns = vec![1000.0, 2000.0, 3000.0];
1176 create_test_histogram(&path, &counts, shape, &tof_ns, None);
1177
1178 let err = load_nexus_histogram(&path).unwrap_err();
1179 let msg = err.to_string();
1180 assert!(
1181 msg.contains(&format!("zero-sized {axis_name} axis")),
1182 "axis {axis_name} ({shape:?}) should be rejected by name, got: {msg}"
1183 );
1184 }
1185 }
1186
1187 #[test]
1191 fn test_load_nexus_histogram_rejects_non_monotonic_tof() {
1192 let dir = tempfile::tempdir().unwrap();
1193 let path = dir.path().join("nonmono_tof.h5");
1194 let counts = vec![1u64, 2u64];
1196 let tof_ns = vec![3000.0, 2000.0, 1000.0];
1197 create_test_histogram(&path, &counts, [1, 1, 1, 2], &tof_ns, None);
1198
1199 let err = load_nexus_histogram(&path).unwrap_err();
1200 assert!(
1201 err.to_string().contains("strictly increasing"),
1202 "non-monotonic TOF should be rejected, got: {err}"
1203 );
1204 }
1205
1206 #[test]
1207 fn test_load_nexus_histogram_rejects_non_positive_tof() {
1208 let dir = tempfile::tempdir().unwrap();
1209 let path = dir.path().join("nonpos_tof.h5");
1210 let counts = vec![1u64, 2u64];
1212 let tof_ns = vec![0.0, 1000.0, 2000.0];
1213 create_test_histogram(&path, &counts, [1, 1, 1, 2], &tof_ns, None);
1214
1215 let err = load_nexus_histogram(&path).unwrap_err();
1216 assert!(
1217 err.to_string().contains("finite and positive"),
1218 "non-positive TOF should be rejected, got: {err}"
1219 );
1220 }
1221
1222 #[test]
1226 fn test_load_nexus_histogram_rejects_trailing_infinite_tof() {
1227 let dir = tempfile::tempdir().unwrap();
1228 let path = dir.path().join("inf_tail_tof.h5");
1229 let counts = vec![1u64, 2u64];
1231 let tof_ns = vec![1000.0, 2000.0, f64::INFINITY];
1232 create_test_histogram(&path, &counts, [1, 1, 1, 2], &tof_ns, None);
1233
1234 let err = load_nexus_histogram(&path).unwrap_err();
1235 assert!(
1236 err.to_string().contains("finite and positive"),
1237 "trailing +inf TOF edge should be rejected, got: {err}"
1238 );
1239 }
1240
1241 #[test]
1249 fn test_read_tof_axis_rejects_single_nan_or_inf_edge() {
1250 let dir = tempfile::tempdir().unwrap();
1251
1252 for (name, edge) in [("nan", f64::NAN), ("inf", f64::INFINITY)] {
1253 let path = dir.path().join(format!("single_{name}_edge.h5"));
1254 let file = hdf5::File::create(&path).expect("create");
1255 let entry = file.create_group("entry").expect("entry");
1256 let hist = entry.create_group("histogram").expect("histogram");
1257 hist.new_dataset::<f64>()
1258 .shape([1])
1259 .create("time_of_flight")
1260 .expect("create tof")
1261 .write_raw(&[edge])
1262 .expect("write tof");
1263 drop(file);
1266
1267 let file = hdf5::File::open(&path).expect("reopen");
1268 let hist_group = file
1269 .group("entry")
1270 .expect("entry")
1271 .group("histogram")
1272 .expect("histogram");
1273 let err = read_tof_axis(&hist_group).expect_err("single bad edge must reject");
1274 assert!(
1275 err.to_string().contains("finite and positive"),
1276 "single {name} edge should be rejected, got: {err}"
1277 );
1278 }
1279 }
1280
1281 fn create_test_histogram_with_dead_mask(
1284 path: &Path,
1285 counts: &[u64],
1286 shape: [usize; 4],
1287 tof_ns: &[f64],
1288 dead: &[u8],
1289 dead_shape: [usize; 2],
1290 ) {
1291 create_test_histogram(path, counts, shape, tof_ns, None);
1292 let file = hdf5::File::append(path).expect("reopen test file");
1293 let entry = file.group("entry").expect("entry");
1294 let masks = entry.create_group("pixel_masks").expect("pixel_masks");
1295 masks
1296 .new_dataset::<u8>()
1297 .shape(dead_shape)
1298 .create("dead")
1299 .expect("create dead")
1300 .write_raw(dead)
1301 .expect("write dead");
1302 }
1303
1304 #[test]
1307 fn test_load_nexus_histogram_rejects_mismatched_dead_mask() {
1308 let dir = tempfile::tempdir().unwrap();
1309 let path = dir.path().join("bad_mask.h5");
1310 let counts = vec![1u64; 2 * 3 * 2];
1312 let tof_ns = vec![1000.0, 2000.0, 3000.0];
1313 let dead = vec![0u8; 25];
1314 create_test_histogram_with_dead_mask(&path, &counts, [1, 2, 3, 2], &tof_ns, &dead, [5, 5]);
1315
1316 let err = load_nexus_histogram(&path).unwrap_err();
1317 assert!(
1318 matches!(err, IoError::ShapeMismatch(_)),
1319 "expected ShapeMismatch, got {err:?}"
1320 );
1321 assert!(err.to_string().contains("dead-pixel mask shape"));
1322 }
1323
1324 #[test]
1326 fn test_load_nexus_histogram_accepts_matching_dead_mask() {
1327 let dir = tempfile::tempdir().unwrap();
1328 let path = dir.path().join("ok_mask.h5");
1329 let counts = vec![1u64; 2 * 3 * 2];
1330 let tof_ns = vec![1000.0, 2000.0, 3000.0];
1331 let dead = vec![0u8, 1, 0, 0, 0, 0];
1333 create_test_histogram_with_dead_mask(&path, &counts, [1, 2, 3, 2], &tof_ns, &dead, [2, 3]);
1334
1335 let data = load_nexus_histogram(&path).expect("matching mask should load");
1336 let mask = data.dead_pixels.expect("mask present");
1337 assert_eq!(mask.dim(), (2, 3));
1338 assert!(mask[[0, 1]]);
1339 }
1340
1341 #[test]
1344 fn test_load_nexus_histogram_absent_dead_mask_is_none() {
1345 let dir = tempfile::tempdir().unwrap();
1346 let path = dir.path().join("no_mask.h5");
1347 let counts = vec![1u64; 2 * 3 * 2];
1348 let tof_ns = vec![1000.0, 2000.0, 3000.0];
1349 create_test_histogram(&path, &counts, [1, 2, 3, 2], &tof_ns, None);
1350
1351 let data = load_nexus_histogram(&path).expect("absent mask should load");
1352 assert!(
1353 data.dead_pixels.is_none(),
1354 "absent dead mask must map to None"
1355 );
1356 }
1357
1358 #[test]
1363 fn test_load_nexus_histogram_rejects_present_but_invalid_dead_mask() {
1364 let dir = tempfile::tempdir().unwrap();
1365 let path = dir.path().join("invalid_mask.h5");
1366 let counts = vec![1u64; 2 * 3 * 2];
1367 let tof_ns = vec![1000.0, 2000.0, 3000.0];
1368 create_test_histogram(&path, &counts, [1, 2, 3, 2], &tof_ns, None);
1369
1370 let file = hdf5::File::append(&path).expect("reopen");
1372 let entry = file.group("entry").expect("entry");
1373 let masks = entry.create_group("pixel_masks").expect("pixel_masks");
1374 masks.create_group("dead").expect("dead-as-group");
1375 drop(file);
1376
1377 let err = load_nexus_histogram(&path).unwrap_err();
1378 assert!(
1379 matches!(err, IoError::InvalidParameter(_)),
1380 "present-but-malformed dead mask must be InvalidParameter, got {err:?}"
1381 );
1382 assert!(
1383 err.to_string().contains("dead") && err.to_string().contains("not a readable dataset"),
1384 "error should identify the malformed dead dataset, got: {err}"
1385 );
1386 }
1387
1388 #[test]
1400 fn test_multi_angle_rejection_happens_before_counts_read() {
1401 let dir = tempfile::tempdir().unwrap();
1402 let path = dir.path().join("big_shape.h5");
1403 let counts = vec![1u64; 4 * 2 * 3 * 2];
1406 let tof_ns = vec![1000.0, 2000.0, 3000.0];
1407 create_test_histogram(&path, &counts, [4, 2, 3, 2], &tof_ns, None);
1408
1409 let err = load_nexus_histogram_with_mode(&path, MultiAngleMode::Error).unwrap_err();
1410 let msg = err.to_string();
1411 assert!(
1412 msg.contains("4 rotation angles") && msg.contains("#430"),
1413 "error message should name angle count + reference the issue, got: {msg}"
1414 );
1415 }
1416
1417 #[test]
1418 fn test_ns_to_us_conversion() {
1419 let dir = tempfile::tempdir().unwrap();
1420 let path = dir.path().join("test.h5");
1421
1422 let counts = vec![0u64; 3];
1423 let tof_ns = vec![500_000.0, 1_000_000.0, 1_500_000.0, 2_000_000.0];
1424 create_test_histogram(&path, &counts, [1, 1, 1, 3], &tof_ns, None);
1425
1426 let data = load_nexus_histogram(&path).unwrap();
1427
1428 assert!((data.tof_edges_us[0] - 500.0).abs() < 1e-10);
1430 assert!((data.tof_edges_us[1] - 1000.0).abs() < 1e-10);
1431 assert!((data.tof_edges_us[2] - 1500.0).abs() < 1e-10);
1432 assert!((data.tof_edges_us[3] - 2000.0).abs() < 1e-10);
1433 }
1434
1435 #[test]
1436 fn test_probe_missing_dataset() {
1437 let dir = tempfile::tempdir().unwrap();
1438 let path = dir.path().join("empty.h5");
1439
1440 let file = hdf5::File::create(&path).expect("create");
1441 file.create_group("entry").expect("create entry");
1442 drop(file);
1443
1444 let meta = probe_nexus(&path).unwrap();
1445 assert!(!meta.has_histogram);
1446 assert!(!meta.has_events);
1447 assert!(meta.histogram_shape.is_none());
1448 assert!(meta.n_events.is_none());
1449 }
1450
1451 fn create_test_events(
1453 path: &Path,
1454 tof_ns: &[u64],
1455 x: &[f64],
1456 y: &[f64],
1457 flight_path_m: Option<f64>,
1458 ) {
1459 let file = hdf5::File::create(path).expect("create");
1460 let entry = file.create_group("entry").expect("create entry");
1461
1462 if let Some(fp) = flight_path_m {
1463 entry
1464 .new_attr::<f64>()
1465 .shape(())
1466 .create("flight_path_m")
1467 .expect("create attr")
1468 .write_scalar(&fp)
1469 .expect("write attr");
1470 }
1471
1472 let neutrons = entry.create_group("neutrons").expect("create neutrons");
1473 neutrons
1474 .new_dataset::<u64>()
1475 .shape([tof_ns.len()])
1476 .create("event_time_offset")
1477 .expect("create tof")
1478 .write_raw(tof_ns)
1479 .expect("write tof");
1480 neutrons
1481 .new_dataset::<f64>()
1482 .shape([x.len()])
1483 .create("x")
1484 .expect("create x")
1485 .write_raw(x)
1486 .expect("write x");
1487 neutrons
1488 .new_dataset::<f64>()
1489 .shape([y.len()])
1490 .create("y")
1491 .expect("create y")
1492 .write_raw(y)
1493 .expect("write y");
1494 }
1495
1496 #[test]
1497 fn test_histogram_known_events() {
1498 let dir = tempfile::tempdir().unwrap();
1499 let path = dir.path().join("events.h5");
1500
1501 let tof_ns = vec![1_500_000, 2_500_000, 1_800_000];
1503 let x = vec![1.0, 1.0, 1.0];
1504 let y = vec![0.0, 0.0, 0.0];
1505 create_test_events(&path, &tof_ns, &x, &y, Some(25.0));
1506
1507 let params = EventBinningParams {
1508 n_bins: 2,
1509 tof_min_us: 1000.0,
1510 tof_max_us: 3000.0,
1511 height: 2,
1512 width: 3,
1513 };
1514
1515 let data = load_nexus_events(&path, ¶ms).unwrap();
1516 assert_eq!(data.counts.shape(), &[2, 2, 3]);
1517
1518 assert_eq!(data.counts[[0, 0, 1]], 2.0);
1520 assert_eq!(data.counts[[1, 0, 1]], 1.0);
1522
1523 assert_eq!(data.flight_path_m, Some(25.0));
1524 assert_eq!(data.tof_edges_us.len(), 3); let stats = data
1528 .event_stats
1529 .as_ref()
1530 .expect("event_stats should be Some");
1531 assert_eq!(stats.total, 3);
1532 assert_eq!(stats.kept, 3);
1533 assert_eq!(stats.dropped_non_finite, 0);
1534 assert_eq!(stats.dropped_tof_range, 0);
1535 assert_eq!(stats.dropped_spatial, 0);
1536 }
1537
1538 #[test]
1539 fn test_filter_out_of_range_events() {
1540 let dir = tempfile::tempdir().unwrap();
1541 let path = dir.path().join("events_oob.h5");
1542
1543 let tof_ns = vec![
1545 1_500_000, 500_000, 1_500_000, ];
1549 let x = vec![0.0, 0.0, 5.0]; let y = vec![0.0, 0.0, 0.0];
1551 create_test_events(&path, &tof_ns, &x, &y, None);
1552
1553 let params = EventBinningParams {
1554 n_bins: 2,
1555 tof_min_us: 1000.0,
1556 tof_max_us: 3000.0,
1557 height: 2,
1558 width: 3,
1559 };
1560
1561 let data = load_nexus_events(&path, ¶ms).unwrap();
1562
1563 let total: f64 = data.counts.iter().sum();
1565 assert_eq!(total, 1.0);
1566 assert_eq!(data.counts[[0, 0, 0]], 1.0);
1567
1568 let stats = data
1570 .event_stats
1571 .as_ref()
1572 .expect("event_stats should be Some");
1573 assert_eq!(stats.total, 3);
1574 assert_eq!(stats.kept, 1);
1575 assert_eq!(stats.dropped_non_finite, 0);
1576 assert_eq!(stats.dropped_tof_range, 1);
1577 assert_eq!(stats.dropped_spatial, 1);
1578 }
1579
1580 #[test]
1581 fn test_empty_events() {
1582 let dir = tempfile::tempdir().unwrap();
1583 let path = dir.path().join("empty_events.h5");
1584
1585 create_test_events(&path, &[], &[], &[], None);
1586
1587 let params = EventBinningParams {
1588 n_bins: 10,
1589 tof_min_us: 1000.0,
1590 tof_max_us: 20000.0,
1591 height: 4,
1592 width: 4,
1593 };
1594
1595 let data = load_nexus_events(&path, ¶ms).unwrap();
1596 assert_eq!(data.counts.shape(), &[10, 4, 4]);
1597
1598 let total: f64 = data.counts.iter().sum();
1599 assert_eq!(total, 0.0);
1600
1601 let stats = data
1603 .event_stats
1604 .as_ref()
1605 .expect("event_stats should be Some");
1606 assert_eq!(stats.total, 0);
1607 assert_eq!(stats.kept, 0);
1608 assert_eq!(stats.dropped_non_finite, 0);
1609 assert_eq!(stats.dropped_tof_range, 0);
1610 assert_eq!(stats.dropped_spatial, 0);
1611 }
1612
1613 #[test]
1614 fn test_probe_with_events() {
1615 let dir = tempfile::tempdir().unwrap();
1616 let path = dir.path().join("with_events.h5");
1617
1618 create_test_events(
1619 &path,
1620 &[1000, 2000, 3000],
1621 &[0.0, 1.0, 2.0],
1622 &[0.0, 0.0, 1.0],
1623 None,
1624 );
1625
1626 let meta = probe_nexus(&path).unwrap();
1627 assert!(!meta.has_histogram);
1628 assert!(meta.has_events);
1629 assert_eq!(meta.n_events, Some(3));
1630 }
1631
1632 #[test]
1633 fn test_list_hdf5_tree() {
1634 let dir = tempfile::tempdir().unwrap();
1635 let path = dir.path().join("tree.h5");
1636
1637 {
1639 let file = hdf5::File::create(&path).expect("create file");
1640 let g1 = file.create_group("entry").expect("create entry");
1641 let g2 = g1.create_group("histogram").expect("create histogram");
1642 g2.new_dataset::<f64>()
1643 .shape([3])
1644 .create("data")
1645 .expect("create data")
1646 .write_raw(&[1.0, 2.0, 3.0])
1647 .expect("write data");
1648 }
1649
1650 let tree = list_hdf5_tree(&path, 10).unwrap();
1651 assert!(!tree.is_empty());
1652
1653 let paths: Vec<&str> = tree.iter().map(|e| e.path.as_str()).collect();
1655 assert!(paths.contains(&"/entry"));
1656 assert!(paths.contains(&"/entry/histogram"));
1657 assert!(paths.contains(&"/entry/histogram/data"));
1658
1659 let data_entry = tree
1661 .iter()
1662 .find(|e| e.path == "/entry/histogram/data")
1663 .unwrap();
1664 assert!(data_entry.shape.is_some());
1665 }
1666
1667 #[test]
1668 fn test_nan_xy_coords_dropped() {
1669 let dir = tempfile::tempdir().unwrap();
1670 let path = dir.path().join("nan_xy.h5");
1671
1672 let tof_ns = vec![1_500_000, 1_500_000, 1_500_000, 2_500_000];
1674 let x = vec![0.0, f64::NAN, 0.0, 1.0];
1675 let y = vec![0.0, 0.0, f64::INFINITY, 0.0];
1676 create_test_events(&path, &tof_ns, &x, &y, None);
1677
1678 let params = EventBinningParams {
1679 n_bins: 2,
1680 tof_min_us: 1000.0,
1681 tof_max_us: 3000.0,
1682 height: 2,
1683 width: 3,
1684 };
1685
1686 let data = load_nexus_events(&path, ¶ms).unwrap();
1687
1688 let total_counts: f64 = data.counts.iter().sum();
1690 assert_eq!(total_counts, 2.0);
1691
1692 let stats = data
1693 .event_stats
1694 .as_ref()
1695 .expect("event_stats should be Some");
1696 assert_eq!(stats.total, 4);
1697 assert_eq!(stats.kept, 2);
1698 assert_eq!(stats.dropped_non_finite, 2);
1699 assert_eq!(stats.dropped_tof_range, 0);
1700 assert_eq!(stats.dropped_spatial, 0);
1701 }
1702
1703 fn write_units_attr(ds: &hdf5::Dataset, units: &str) {
1713 let val: VarLenUnicode = units.parse().expect("parse units string");
1714 ds.new_attr::<VarLenUnicode>()
1715 .shape(())
1716 .create("units")
1717 .expect("create units attr")
1718 .write_scalar(&val)
1719 .expect("write units attr");
1720 }
1721
1722 fn create_test_histogram_with_units(
1725 path: &Path,
1726 counts: &[u64],
1727 shape: [usize; 4],
1728 tof_values: &[f64],
1729 units: Option<&str>,
1730 ) {
1731 let file = hdf5::File::create(path).expect("create test file");
1732 let entry = file.create_group("entry").expect("create entry");
1733 let hist = entry.create_group("histogram").expect("create histogram");
1734 hist.new_dataset::<u64>()
1735 .shape(shape)
1736 .create("counts")
1737 .expect("create counts")
1738 .write_raw(counts)
1739 .expect("write counts");
1740 let tof_ds = hist
1741 .new_dataset::<f64>()
1742 .shape([tof_values.len()])
1743 .create("time_of_flight")
1744 .expect("create tof");
1745 tof_ds.write_raw(tof_values).expect("write tof");
1746 if let Some(u) = units {
1747 write_units_attr(&tof_ds, u);
1748 }
1749 }
1750
1751 fn create_test_events_with_units(
1754 path: &Path,
1755 tof_values: &[u64],
1756 x: &[f64],
1757 y: &[f64],
1758 units: Option<&str>,
1759 ) {
1760 let file = hdf5::File::create(path).expect("create");
1761 let entry = file.create_group("entry").expect("create entry");
1762 let neutrons = entry.create_group("neutrons").expect("create neutrons");
1763 let tof_ds = neutrons
1764 .new_dataset::<u64>()
1765 .shape([tof_values.len()])
1766 .create("event_time_offset")
1767 .expect("create tof");
1768 tof_ds.write_raw(tof_values).expect("write tof");
1769 if let Some(u) = units {
1770 write_units_attr(&tof_ds, u);
1771 }
1772 neutrons
1773 .new_dataset::<f64>()
1774 .shape([x.len()])
1775 .create("x")
1776 .expect("create x")
1777 .write_raw(x)
1778 .expect("write x");
1779 neutrons
1780 .new_dataset::<f64>()
1781 .shape([y.len()])
1782 .create("y")
1783 .expect("create y")
1784 .write_raw(y)
1785 .expect("write y");
1786 }
1787
1788 #[test]
1792 fn test_tof_scale_to_us_table() {
1793 assert!((tof_scale_to_us(None).unwrap() - 1e-3).abs() < 1e-15);
1795 for (spelling, expected) in &[
1796 ("ns", 1e-3),
1797 ("Ns", 1e-3),
1798 ("NS", 1e-3),
1799 ("nanoseconds", 1e-3),
1800 ("us", 1.0),
1801 ("US", 1.0),
1802 ("microseconds", 1.0),
1803 ("µs", 1.0),
1804 ("ms", 1e3),
1805 ("milliseconds", 1e3),
1806 ("s", 1e6),
1807 ("seconds", 1e6),
1808 (" s ", 1e6),
1809 ] {
1810 let got = tof_scale_to_us(Some(*spelling))
1811 .unwrap_or_else(|e| panic!("spelling {spelling:?} unexpectedly errored: {e}"));
1812 assert!(
1813 (got - expected).abs() < 1e-15,
1814 "spelling {spelling:?}: expected scale {expected}, got {got}"
1815 );
1816 }
1817 for bad in &["picoseconds", "ticks", "us per channel", "", "garbage"] {
1819 let err = tof_scale_to_us(Some(*bad)).expect_err("unknown units must error");
1820 let msg = err.to_string();
1821 assert!(
1822 msg.contains("Unsupported NeXus TOF units"),
1823 "error for {bad:?} should mention 'Unsupported NeXus TOF units', got: {msg}"
1824 );
1825 }
1826 }
1827
1828 #[test]
1832 fn test_load_nexus_histogram_units_ns_explicit() {
1833 let dir = tempfile::tempdir().unwrap();
1834 let path = dir.path().join("hist_units_ns.h5");
1835 let counts = vec![0u64; 2];
1836 let tof_ns = vec![1.0, 2.0, 3.0];
1838 create_test_histogram_with_units(&path, &counts, [1, 1, 1, 2], &tof_ns, Some("ns"));
1839
1840 let data = load_nexus_histogram(&path).expect("load with units=ns");
1841 assert_eq!(data.tof_edges_us.len(), 3);
1842 assert!((data.tof_edges_us[0] - 0.001).abs() < 1e-12);
1843 assert!((data.tof_edges_us[1] - 0.002).abs() < 1e-12);
1844 assert!((data.tof_edges_us[2] - 0.003).abs() < 1e-12);
1845 }
1846
1847 #[test]
1852 fn test_load_nexus_histogram_units_us_no_rescale() {
1853 let dir = tempfile::tempdir().unwrap();
1854 let path = dir.path().join("hist_units_us.h5");
1855 let counts = vec![0u64; 4];
1856 let tof_us = vec![1000.0, 2000.0, 3000.0, 4000.0, 5000.0];
1858 create_test_histogram_with_units(&path, &counts, [1, 1, 1, 4], &tof_us, Some("us"));
1859
1860 let data = load_nexus_histogram(&path).expect("load with units=us");
1861 assert_eq!(data.tof_edges_us.len(), 5);
1862 for (i, &expected) in tof_us.iter().enumerate() {
1863 assert!(
1864 (data.tof_edges_us[i] - expected).abs() < 1e-9,
1865 "edge {i}: expected {expected} µs (no rescale), got {} µs",
1866 data.tof_edges_us[i]
1867 );
1868 }
1869 }
1870
1871 #[test]
1874 fn test_load_nexus_histogram_units_seconds() {
1875 let dir = tempfile::tempdir().unwrap();
1876 let path = dir.path().join("hist_units_s.h5");
1877 let counts = vec![0u64; 2];
1878 let tof_s = vec![0.001, 0.002, 0.003];
1880 create_test_histogram_with_units(&path, &counts, [1, 1, 1, 2], &tof_s, Some("s"));
1881
1882 let data = load_nexus_histogram(&path).expect("load with units=s");
1883 assert!((data.tof_edges_us[0] - 1000.0).abs() < 1e-9);
1884 assert!((data.tof_edges_us[1] - 2000.0).abs() < 1e-9);
1885 assert!((data.tof_edges_us[2] - 3000.0).abs() < 1e-9);
1886 }
1887
1888 #[test]
1894 fn test_load_nexus_histogram_units_unknown_rejected() {
1895 let dir = tempfile::tempdir().unwrap();
1896 let path = dir.path().join("hist_units_bad.h5");
1897 let counts = vec![0u64; 2];
1898 let tof = vec![1.0, 2.0, 3.0];
1899 create_test_histogram_with_units(&path, &counts, [1, 1, 1, 2], &tof, Some("picoseconds"));
1900
1901 let err = load_nexus_histogram(&path).expect_err("unknown units must error");
1902 let msg = err.to_string();
1903 assert!(
1904 msg.contains("Unsupported NeXus TOF units") && msg.contains("picoseconds"),
1905 "error should name the offending value, got: {msg}"
1906 );
1907 }
1908
1909 #[test]
1915 fn test_load_nexus_histogram_units_missing_legacy_ns() {
1916 let dir = tempfile::tempdir().unwrap();
1917 let path = dir.path().join("hist_units_missing.h5");
1918 let counts = vec![0u64; 2];
1919 let tof_ns = vec![1000.0, 2000.0, 3000.0];
1920 create_test_histogram_with_units(&path, &counts, [1, 1, 1, 2], &tof_ns, None);
1922 let data = load_nexus_histogram(&path).expect("load with no units attr");
1923 assert!((data.tof_edges_us[0] - 1.0).abs() < 1e-12);
1924 assert!((data.tof_edges_us[1] - 2.0).abs() < 1e-12);
1925 assert!((data.tof_edges_us[2] - 3.0).abs() < 1e-12);
1926 }
1927
1928 #[test]
1934 fn test_load_nexus_events_units_us_no_rescale() {
1935 let dir = tempfile::tempdir().unwrap();
1936 let path = dir.path().join("events_units_us.h5");
1937
1938 let tof_us = vec![1500u64, 2500u64, 1800u64];
1940 let x = vec![1.0, 1.0, 1.0];
1941 let y = vec![0.0, 0.0, 0.0];
1942 create_test_events_with_units(&path, &tof_us, &x, &y, Some("us"));
1943
1944 let params = EventBinningParams {
1945 n_bins: 2,
1946 tof_min_us: 1000.0,
1947 tof_max_us: 3000.0,
1948 height: 2,
1949 width: 3,
1950 };
1951 let data = load_nexus_events(&path, ¶ms).expect("load events with units=us");
1952
1953 assert_eq!(data.counts[[0, 0, 1]], 2.0);
1955 assert_eq!(data.counts[[1, 0, 1]], 1.0);
1957 let stats = data.event_stats.as_ref().expect("event stats");
1958 assert_eq!(stats.kept, 3);
1959 assert_eq!(stats.dropped_tof_range, 0);
1960 }
1961
1962 #[test]
1965 fn test_load_nexus_events_units_ns_explicit() {
1966 let dir = tempfile::tempdir().unwrap();
1967 let path = dir.path().join("events_units_ns.h5");
1968
1969 let tof_ns = vec![1_500_000u64, 2_500_000u64, 1_800_000u64];
1971 let x = vec![1.0, 1.0, 1.0];
1972 let y = vec![0.0, 0.0, 0.0];
1973 create_test_events_with_units(&path, &tof_ns, &x, &y, Some("ns"));
1974
1975 let params = EventBinningParams {
1976 n_bins: 2,
1977 tof_min_us: 1000.0,
1978 tof_max_us: 3000.0,
1979 height: 2,
1980 width: 3,
1981 };
1982 let data = load_nexus_events(&path, ¶ms).expect("load events with units=ns");
1983 assert_eq!(data.counts[[0, 0, 1]], 2.0);
1984 assert_eq!(data.counts[[1, 0, 1]], 1.0);
1985 }
1986
1987 #[test]
1989 fn test_load_nexus_events_units_unknown_rejected() {
1990 let dir = tempfile::tempdir().unwrap();
1991 let path = dir.path().join("events_units_bad.h5");
1992 let tof = vec![1_500_000u64];
1993 let x = vec![0.0];
1994 let y = vec![0.0];
1995 create_test_events_with_units(&path, &tof, &x, &y, Some("clock-ticks"));
1996
1997 let params = EventBinningParams {
1998 n_bins: 2,
1999 tof_min_us: 1000.0,
2000 tof_max_us: 3000.0,
2001 height: 2,
2002 width: 3,
2003 };
2004 let err = load_nexus_events(&path, ¶ms).expect_err("unknown units must error");
2005 let msg = err.to_string();
2006 assert!(
2007 msg.contains("Unsupported NeXus TOF units") && msg.contains("clock-ticks"),
2008 "error should name the offending value, got: {msg}"
2009 );
2010 }
2011}