Skip to main content

nereids_io/
nexus.rs

1//! NeXus/HDF5 reading for rustpix-processed neutron imaging data.
2//!
3//! Supports two data modalities from rustpix output files:
4//! - **Histogram**: 4D counts array `(rot_angle, y, x, tof)` summed over
5//!   rotation angles and transposed to NEREIDS convention `(tof, y, x)`.
6//! - **Events**: per-neutron `(event_time_offset, x, y)` histogrammed into
7//!   a `(tof, y, x)` grid with user-specified binning parameters.
8//!
9//! ## HDF5 Schema (rustpix convention)
10//!
11//! ```text
12//! /entry/histogram/counts          — u64 4D [rot_angle, y, x, tof]
13//! /entry/histogram/time_of_flight  — f64 1D, nanoseconds
14//! /entry/neutrons/event_time_offset — u64 1D, nanoseconds
15//! /entry/neutrons/x                — f64 1D, pixel coordinate
16//! /entry/neutrons/y                — f64 1D, pixel coordinate
17//! /entry/pixel_masks/dead          — u8  2D [y, x]
18//! ```
19//!
20//! Metadata attributes on `/entry` or group level:
21//! - `flight_path_m` (f64)
22//! - `tof_offset_ns` (f64)
23
24use std::path::Path;
25
26use ndarray::Array3;
27
28use crate::error::IoError;
29
30/// Metadata probed from a NeXus/HDF5 file without loading full data.
31#[derive(Debug, Clone)]
32pub struct NexusMetadata {
33    /// Whether `/entry/histogram/counts` exists.
34    pub has_histogram: bool,
35    /// Whether `/entry/neutrons` group exists with event data.
36    pub has_events: bool,
37    /// Shape of the histogram `(rot_angle, y, x, tof)`, if present.
38    pub histogram_shape: Option<[usize; 4]>,
39    /// Number of events in `/entry/neutrons/event_time_offset`, if present.
40    pub n_events: Option<usize>,
41    /// Flight path in meters (from attributes), if present.
42    pub flight_path_m: Option<f64>,
43    /// TOF offset in nanoseconds (from attributes), if present.
44    pub tof_offset_ns: Option<f64>,
45    /// TOF bin edges or centers in nanoseconds, if present.
46    pub tof_edges_ns: Option<Vec<f64>>,
47}
48
49/// An entry in the HDF5 group/dataset tree hierarchy.
50#[derive(Debug, Clone)]
51pub struct Hdf5TreeEntry {
52    /// Full path within the HDF5 file (e.g., `/entry/histogram/counts`).
53    pub path: String,
54    /// Whether this entry is a group or dataset.
55    pub kind: Hdf5EntryKind,
56    /// Dataset shape, if this entry is a dataset.
57    pub shape: Option<Vec<usize>>,
58}
59
60/// Kind of HDF5 tree entry.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Hdf5EntryKind {
63    Group,
64    Dataset,
65}
66
67/// Histogram data loaded from a NeXus file, ready for NEREIDS processing.
68#[derive(Debug, Clone)]
69pub struct NexusHistogramData {
70    /// Counts array in NEREIDS convention: `(n_tof, height, width)`.
71    pub counts: Array3<f64>,
72    /// TOF bin edges in microseconds.
73    pub tof_edges_us: Vec<f64>,
74    /// Flight path in meters, if available from the file.
75    pub flight_path_m: Option<f64>,
76    /// Dead pixel mask from `/entry/pixel_masks/dead`, if present.
77    pub dead_pixels: Option<ndarray::Array2<bool>>,
78    /// Number of rotation angles summed (D-5). 1 means no collapse occurred.
79    pub n_rotation_angles: usize,
80    /// Event retention statistics (only populated for event-mode loading).
81    pub event_stats: Option<EventRetentionStats>,
82}
83
84/// Statistics on how many events were kept vs dropped during histogramming.
85#[derive(Debug, Clone)]
86pub struct EventRetentionStats {
87    /// Total events read from the file.
88    pub total: usize,
89    /// Events successfully histogrammed.
90    pub kept: usize,
91    /// Events dropped due to non-finite values in TOF or spatial coordinates.
92    ///
93    /// For u64 TOF input (`event_time_offset`), the TOF channel is always
94    /// finite, so the TOF path contributes zero to this counter. Non-finite
95    /// values arise from the f64 x/y pixel coordinates (NaN or Inf from
96    /// upstream processing or detector artifacts).
97    pub dropped_non_finite: usize,
98    /// Events dropped due to TOF outside `[tof_min, tof_max)`.
99    pub dropped_tof_range: usize,
100    /// Events dropped due to pixel coordinates outside detector bounds.
101    pub dropped_spatial: usize,
102}
103
104/// Probe a NeXus/HDF5 file for available data modalities and metadata.
105///
106/// Opens the file read-only and checks for histogram and event groups
107/// without loading any large datasets.
108pub fn probe_nexus(path: &Path) -> Result<NexusMetadata, IoError> {
109    let file = hdf5::File::open(path).map_err(|e| {
110        IoError::FileNotFound(
111            path.display().to_string(),
112            std::io::Error::other(e.to_string()),
113        )
114    })?;
115
116    let entry = file
117        .group("entry")
118        .map_err(|e| IoError::InvalidParameter(format!("Missing /entry group: {e}")))?;
119
120    // Probe histogram
121    let (has_histogram, histogram_shape, tof_edges_ns) = probe_histogram_group(&entry);
122
123    // Probe events
124    let (has_events, n_events) = probe_event_group(&entry);
125
126    // Read metadata attributes from the /entry group
127    let flight_path_m = read_f64_attr(&entry, "flight_path_m");
128    let tof_offset_ns = read_f64_attr(&entry, "tof_offset_ns");
129
130    Ok(NexusMetadata {
131        has_histogram,
132        has_events,
133        histogram_shape,
134        n_events,
135        flight_path_m,
136        tof_offset_ns,
137        tof_edges_ns,
138    })
139}
140
141/// Load histogram data from a NeXus file.
142///
143/// Reads `/entry/histogram/counts` (u64 4D), sums over the rotation angle
144/// axis (axis 0), converts to f64, and transposes to NEREIDS convention
145/// `(tof, y, x)`. TOF values are converted from nanoseconds to microseconds.
146pub fn load_nexus_histogram(path: &Path) -> Result<NexusHistogramData, IoError> {
147    let file = hdf5::File::open(path).map_err(|e| {
148        IoError::FileNotFound(
149            path.display().to_string(),
150            std::io::Error::other(e.to_string()),
151        )
152    })?;
153
154    let entry = file
155        .group("entry")
156        .map_err(|e| IoError::InvalidParameter(format!("Missing /entry group: {e}")))?;
157
158    let hist_group = entry
159        .group("histogram")
160        .map_err(|e| IoError::InvalidParameter(format!("Missing /entry/histogram group: {e}")))?;
161
162    // Read counts: u64 4D [rot_angle, y, x, tof]
163    let counts_ds = hist_group.dataset("counts").map_err(|e| {
164        IoError::InvalidParameter(format!("Missing /entry/histogram/counts dataset: {e}"))
165    })?;
166
167    let shape = counts_ds.shape();
168    if shape.len() != 4 {
169        return Err(IoError::ShapeMismatch(format!(
170            "Expected 4D histogram counts, got {}D",
171            shape.len()
172        )));
173    }
174
175    let counts_u64: ndarray::Array4<u64> = counts_ds
176        .read()
177        .map_err(|e| IoError::InvalidParameter(format!("Failed to read histogram counts: {e}")))?;
178
179    // D-5: Sum over rotation angle axis (axis 0): [rot, y, x, tof] → [y, x, tof]
180    // When n_rot > 1, this silently collapses multi-angle acquisitions.
181    // Log a warning so the user knows angles were combined.
182    let n_rot = shape[0];
183    if n_rot > 1 {
184        eprintln!(
185            "Warning: NeXus histogram has {n_rot} rotation angles — summing into a single \
186             volume. Multi-angle analysis is not yet supported."
187        );
188    }
189    let summed = counts_u64.sum_axis(ndarray::Axis(0));
190
191    // Convert to f64 and transpose [y, x, tof] → NEREIDS convention [tof, y, x]
192    let counts_f64: Array3<f64> = summed
193        .mapv(|v| v as f64)
194        .permuted_axes([2, 0, 1])
195        .as_standard_layout()
196        .into_owned();
197    let n_tof = counts_f64.shape()[0];
198
199    // Read TOF axis (nanoseconds → microseconds)
200    let tof_edges_us = read_tof_axis(&hist_group)?;
201
202    // Validate TOF edges count against histogram TOF dimension
203    if tof_edges_us.len() != n_tof + 1 && tof_edges_us.len() != n_tof {
204        return Err(IoError::InvalidParameter(format!(
205            "TOF axis length {} is incompatible with {} histogram bins (expected {} or {})",
206            tof_edges_us.len(),
207            n_tof,
208            n_tof,
209            n_tof + 1
210        )));
211    }
212
213    // Read flight path
214    let flight_path_m = read_f64_attr(&hist_group, "flight_path_m")
215        .or_else(|| read_f64_attr(&entry, "flight_path_m"));
216
217    // Read dead pixel mask
218    let dead_pixels = read_dead_pixel_mask(&entry);
219
220    Ok(NexusHistogramData {
221        counts: counts_f64,
222        tof_edges_us,
223        flight_path_m,
224        dead_pixels,
225        n_rotation_angles: n_rot,
226        event_stats: None, // histogram mode, not events
227    })
228}
229
230/// Parameters for histogramming neutron event data into a 3D grid.
231#[derive(Debug, Clone)]
232pub struct EventBinningParams {
233    /// Number of TOF bins.
234    pub n_bins: usize,
235    /// Minimum TOF in microseconds.
236    pub tof_min_us: f64,
237    /// Maximum TOF in microseconds.
238    pub tof_max_us: f64,
239    /// Detector height in pixels.
240    pub height: usize,
241    /// Detector width in pixels.
242    pub width: usize,
243}
244
245/// Load neutron event data from a NeXus file and histogram into a 3D grid.
246///
247/// Reads `/entry/neutrons/event_time_offset` (u64 ns), `x` (f64), `y` (f64),
248/// converts TOF from nanoseconds to microseconds, then bins events into a
249/// `(n_bins, height, width)` histogram grid.
250///
251/// # Binning behaviour (D-8)
252///
253/// - **Out-of-range events are dropped and counted**: events with TOF outside
254///   `[tof_min_us, tof_max_us)`, pixel coordinates outside `[0, width)` /
255///   `[0, height)`, or non-finite spatial coordinates are excluded. Per-category
256///   drop counts are returned in [`EventRetentionStats`] via
257///   [`NexusHistogramData::event_stats`].
258/// - **Pixel coordinates are rounded to the nearest integer** (`f64::round()`
259///   then cast to `isize`), snapping sub-pixel positions to a discrete grid.
260///   Fractional coordinates exactly at 0.5 round up.
261pub fn load_nexus_events(
262    path: &Path,
263    params: &EventBinningParams,
264) -> Result<NexusHistogramData, IoError> {
265    if params.n_bins == 0 {
266        return Err(IoError::InvalidParameter("n_bins must be positive".into()));
267    }
268    if params.height == 0 || params.width == 0 {
269        return Err(IoError::InvalidParameter(
270            "height and width must be positive".into(),
271        ));
272    }
273    if !params.tof_min_us.is_finite() || !params.tof_max_us.is_finite() {
274        return Err(IoError::InvalidParameter(
275            "TOF bounds must be finite".into(),
276        ));
277    }
278    if params.tof_max_us <= params.tof_min_us {
279        return Err(IoError::InvalidParameter(format!(
280            "tof_max_us ({}) must be greater than tof_min_us ({})",
281            params.tof_max_us, params.tof_min_us
282        )));
283    }
284
285    let file = hdf5::File::open(path).map_err(|e| {
286        IoError::FileNotFound(
287            path.display().to_string(),
288            std::io::Error::other(e.to_string()),
289        )
290    })?;
291
292    let entry = file
293        .group("entry")
294        .map_err(|e| IoError::InvalidParameter(format!("Missing /entry group: {e}")))?;
295
296    let neutrons = entry
297        .group("neutrons")
298        .map_err(|e| IoError::InvalidParameter(format!("Missing /entry/neutrons group: {e}")))?;
299
300    // Read event arrays
301    let tof_ns: Vec<u64> = neutrons
302        .dataset("event_time_offset")
303        .map_err(|e| IoError::InvalidParameter(format!("Missing event_time_offset dataset: {e}")))?
304        .read_1d()
305        .map_err(|e| IoError::InvalidParameter(format!("Failed to read event_time_offset: {e}")))?
306        .to_vec();
307
308    let x_coords: Vec<f64> = neutrons
309        .dataset("x")
310        .map_err(|e| IoError::InvalidParameter(format!("Missing x dataset: {e}")))?
311        .read_1d()
312        .map_err(|e| IoError::InvalidParameter(format!("Failed to read x: {e}")))?
313        .to_vec();
314
315    let y_coords: Vec<f64> = neutrons
316        .dataset("y")
317        .map_err(|e| IoError::InvalidParameter(format!("Missing y dataset: {e}")))?
318        .read_1d()
319        .map_err(|e| IoError::InvalidParameter(format!("Failed to read y: {e}")))?
320        .to_vec();
321
322    if tof_ns.len() != x_coords.len() || tof_ns.len() != y_coords.len() {
323        return Err(IoError::ShapeMismatch(format!(
324            "Event arrays have mismatched lengths: tof={}, x={}, y={}",
325            tof_ns.len(),
326            x_coords.len(),
327            y_coords.len()
328        )));
329    }
330
331    // Generate linear TOF bin edges
332    let tof_edges_us =
333        crate::tof::linspace_tof_edges(params.tof_min_us, params.tof_max_us, params.n_bins)?;
334
335    // Histogram events with retention tracking.
336    let dt_us = (params.tof_max_us - params.tof_min_us) / params.n_bins as f64;
337    let mut counts = Array3::<f64>::zeros((params.n_bins, params.height, params.width));
338    let total = tof_ns.len();
339    let mut kept = 0usize;
340    let mut dropped_non_finite = 0usize;
341    let mut dropped_tof_range = 0usize;
342    let mut dropped_spatial = 0usize;
343
344    for i in 0..tof_ns.len() {
345        let tof_us = tof_ns[i] as f64 / 1000.0; // ns → µs
346        if !tof_us.is_finite() {
347            dropped_non_finite += 1;
348            continue;
349        }
350
351        if tof_us < params.tof_min_us || tof_us >= params.tof_max_us {
352            dropped_tof_range += 1;
353            continue;
354        }
355
356        let xf = x_coords[i];
357        let yf = y_coords[i];
358        if !xf.is_finite() || !yf.is_finite() {
359            dropped_non_finite += 1;
360            continue;
361        }
362        let px = xf.round() as isize;
363        let py = yf.round() as isize;
364
365        if px < 0 || py < 0 || px >= params.width as isize || py >= params.height as isize {
366            dropped_spatial += 1;
367            continue;
368        }
369
370        let tof_bin = ((tof_us - params.tof_min_us) / dt_us) as usize;
371        let tof_bin = tof_bin.min(params.n_bins - 1);
372        counts[[tof_bin, py as usize, px as usize]] += 1.0;
373        kept += 1;
374    }
375
376    // Read flight path
377    let flight_path_m = read_f64_attr(&neutrons, "flight_path_m")
378        .or_else(|| read_f64_attr(&entry, "flight_path_m"));
379
380    // Read dead pixel mask
381    let dead_pixels = read_dead_pixel_mask(&entry);
382
383    debug_assert_eq!(
384        total,
385        kept + dropped_non_finite + dropped_tof_range + dropped_spatial,
386        "event retention accounting mismatch"
387    );
388
389    Ok(NexusHistogramData {
390        counts,
391        tof_edges_us,
392        flight_path_m,
393        dead_pixels,
394        n_rotation_angles: 1,
395        event_stats: Some(EventRetentionStats {
396            total,
397            kept,
398            dropped_non_finite,
399            dropped_tof_range,
400            dropped_spatial,
401        }),
402    })
403}
404
405// ---- Internal helpers ----
406
407/// Probe the histogram group for shape and TOF axis without loading counts.
408fn probe_histogram_group(entry: &hdf5::Group) -> (bool, Option<[usize; 4]>, Option<Vec<f64>>) {
409    let hist = match entry.group("histogram") {
410        Ok(g) => g,
411        Err(_) => return (false, None, None),
412    };
413
414    let counts = match hist.dataset("counts") {
415        Ok(ds) => ds,
416        Err(_) => return (false, None, None),
417    };
418
419    let shape = counts.shape();
420    if shape.len() != 4 {
421        return (false, None, None);
422    }
423
424    let histogram_shape = Some([shape[0], shape[1], shape[2], shape[3]]);
425
426    // Try reading TOF axis
427    let tof_edges = hist
428        .dataset("time_of_flight")
429        .ok()
430        .and_then(|ds| ds.read_1d::<f64>().ok())
431        .map(|a| a.to_vec());
432
433    (true, histogram_shape, tof_edges)
434}
435
436/// Probe the neutron event group for event count.
437fn probe_event_group(entry: &hdf5::Group) -> (bool, Option<usize>) {
438    let neutrons = match entry.group("neutrons") {
439        Ok(g) => g,
440        Err(_) => return (false, None),
441    };
442
443    let n_events = neutrons
444        .dataset("event_time_offset")
445        .ok()
446        .map(|ds| ds.shape().first().copied().unwrap_or(0));
447
448    (n_events.is_some(), n_events)
449}
450
451/// Read TOF axis from the histogram group, converting ns → µs.
452fn read_tof_axis(hist_group: &hdf5::Group) -> Result<Vec<f64>, IoError> {
453    let tof_ds = hist_group.dataset("time_of_flight").map_err(|e| {
454        IoError::InvalidParameter(format!(
455            "Missing /entry/histogram/time_of_flight dataset: {e}"
456        ))
457    })?;
458
459    let tof_ns: Vec<f64> = tof_ds
460        .read_1d::<f64>()
461        .map_err(|e| IoError::InvalidParameter(format!("Failed to read time_of_flight: {e}")))?
462        .to_vec();
463
464    // Convert nanoseconds → microseconds
465    Ok(tof_ns.iter().map(|&ns| ns / 1000.0).collect())
466}
467
468/// Read a scalar f64 attribute from a group.
469fn read_f64_attr(group: &hdf5::Group, name: &str) -> Option<f64> {
470    group
471        .attr(name)
472        .ok()
473        .and_then(|a| a.read_scalar::<f64>().ok())
474}
475
476/// Read dead pixel mask from `/entry/pixel_masks/dead`.
477fn read_dead_pixel_mask(entry: &hdf5::Group) -> Option<ndarray::Array2<bool>> {
478    let masks = entry.group("pixel_masks").ok()?;
479    let dead_ds = masks.dataset("dead").ok()?;
480    let dead_u8: ndarray::Array2<u8> = dead_ds.read().ok()?;
481    Some(dead_u8.mapv(|v| v != 0))
482}
483
484/// List the group/dataset tree structure of an HDF5 file.
485///
486/// Walks the file hierarchy recursively up to `max_depth` levels deep,
487/// returning entries with their path, kind (group vs dataset), and shape
488/// (for datasets).  Useful for displaying file structure in a GUI browser.
489pub fn list_hdf5_tree(path: &Path, max_depth: usize) -> Result<Vec<Hdf5TreeEntry>, IoError> {
490    let file = hdf5::File::open(path)
491        .map_err(|e| IoError::Hdf5Error(format!("Cannot open HDF5 file: {e}")))?;
492    let mut entries = Vec::new();
493    walk_group(
494        &file
495            .as_group()
496            .map_err(|e| IoError::Hdf5Error(format!("Cannot read root group: {e}")))?,
497        "/",
498        0,
499        max_depth,
500        &mut entries,
501    );
502    Ok(entries)
503}
504
505/// Recursively walk an HDF5 group, collecting tree entries.
506fn walk_group(
507    group: &hdf5::Group,
508    prefix: &str,
509    depth: usize,
510    max_depth: usize,
511    entries: &mut Vec<Hdf5TreeEntry>,
512) {
513    let Ok(members) = group.member_names() else {
514        return;
515    };
516    let mut members = members;
517    members.sort();
518    for name in &members {
519        let child_path = if prefix == "/" {
520            format!("/{name}")
521        } else {
522            format!("{prefix}/{name}")
523        };
524
525        // Try dataset first (leaf nodes)
526        if let Ok(ds) = group.dataset(name) {
527            let shape = ds.shape();
528            entries.push(Hdf5TreeEntry {
529                path: child_path,
530                kind: Hdf5EntryKind::Dataset,
531                shape: Some(shape),
532            });
533        } else if let Ok(child_group) = group.group(name) {
534            // It's a group — record it and recurse if within depth
535            entries.push(Hdf5TreeEntry {
536                path: child_path.clone(),
537                kind: Hdf5EntryKind::Group,
538                shape: None,
539            });
540            if depth < max_depth {
541                walk_group(&child_group, &child_path, depth + 1, max_depth, entries);
542            }
543        }
544    }
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550
551    /// Create a minimal NeXus HDF5 file with histogram data for testing.
552    fn create_test_histogram(
553        path: &Path,
554        counts: &[u64],
555        shape: [usize; 4],
556        tof_ns: &[f64],
557        flight_path_m: Option<f64>,
558    ) {
559        let file = hdf5::File::create(path).expect("create test file");
560        let entry = file.create_group("entry").expect("create entry");
561
562        if let Some(fp) = flight_path_m {
563            entry
564                .new_attr::<f64>()
565                .shape(())
566                .create("flight_path_m")
567                .expect("create attr")
568                .write_scalar(&fp)
569                .expect("write attr");
570        }
571
572        let hist = entry.create_group("histogram").expect("create histogram");
573        hist.new_dataset::<u64>()
574            .shape(shape)
575            .create("counts")
576            .expect("create counts")
577            .write_raw(counts)
578            .expect("write counts");
579
580        hist.new_dataset::<f64>()
581            .shape([tof_ns.len()])
582            .create("time_of_flight")
583            .expect("create tof")
584            .write_raw(tof_ns)
585            .expect("write tof");
586    }
587
588    #[test]
589    fn test_probe_nexus_histogram() {
590        let dir = tempfile::tempdir().unwrap();
591        let path = dir.path().join("test.h5");
592
593        // 1 rot angle, 2x3 spatial, 4 TOF bins → shape [1, 2, 3, 4]
594        let counts = vec![0u64; 24];
595        let tof_ns = vec![1000.0, 2000.0, 3000.0, 4000.0, 5000.0]; // 5 edges for 4 bins
596        create_test_histogram(&path, &counts, [1, 2, 3, 4], &tof_ns, Some(25.0));
597
598        let meta = probe_nexus(&path).unwrap();
599        assert!(meta.has_histogram);
600        assert!(!meta.has_events);
601        assert_eq!(meta.histogram_shape, Some([1, 2, 3, 4]));
602        assert_eq!(meta.flight_path_m, Some(25.0));
603        assert!(meta.tof_edges_ns.is_some());
604        assert_eq!(meta.tof_edges_ns.unwrap().len(), 5);
605    }
606
607    #[test]
608    fn test_load_nexus_histogram() {
609        let dir = tempfile::tempdir().unwrap();
610        let path = dir.path().join("test.h5");
611
612        // 2 rot angles, 2x3 spatial, 2 TOF bins
613        // Each element set to known value for verification
614        let mut counts = vec![0u64; 2 * 2 * 3 * 2];
615        // counts[rot, y, x, tof] — linearized in row-major order
616        // Set some values: rot=0, y=0, x=0, tof=0 → index 0
617        counts[0] = 10;
618        // rot=1, y=0, x=0, tof=0 → index 1*2*3*2 = 12
619        counts[12] = 5;
620
621        let tof_ns = vec![1000.0, 2000.0, 3000.0]; // 3 edges for 2 bins
622        create_test_histogram(&path, &counts, [2, 2, 3, 2], &tof_ns, Some(25.0));
623
624        let data = load_nexus_histogram(&path).unwrap();
625
626        // Shape should be (n_tof=2, n_y=2, n_x=3) after summing rot and transposing
627        assert_eq!(data.counts.shape(), &[2, 2, 3]);
628
629        // Summed over rot: counts[tof=0, y=0, x=0] = 10 + 5 = 15
630        assert_eq!(data.counts[[0, 0, 0]], 15.0);
631
632        // TOF edges converted ns → µs
633        assert_eq!(data.tof_edges_us.len(), 3);
634        assert!((data.tof_edges_us[0] - 1.0).abs() < 1e-10); // 1000 ns → 1 µs
635        assert!((data.tof_edges_us[1] - 2.0).abs() < 1e-10);
636        assert!((data.tof_edges_us[2] - 3.0).abs() < 1e-10);
637
638        assert_eq!(data.flight_path_m, Some(25.0));
639
640        // D-5: rotation angle count is carried through
641        assert_eq!(data.n_rotation_angles, 2);
642    }
643
644    #[test]
645    fn test_ns_to_us_conversion() {
646        let dir = tempfile::tempdir().unwrap();
647        let path = dir.path().join("test.h5");
648
649        let counts = vec![0u64; 3];
650        let tof_ns = vec![500_000.0, 1_000_000.0, 1_500_000.0, 2_000_000.0];
651        create_test_histogram(&path, &counts, [1, 1, 1, 3], &tof_ns, None);
652
653        let data = load_nexus_histogram(&path).unwrap();
654
655        // 500_000 ns = 500 µs, etc.
656        assert!((data.tof_edges_us[0] - 500.0).abs() < 1e-10);
657        assert!((data.tof_edges_us[1] - 1000.0).abs() < 1e-10);
658        assert!((data.tof_edges_us[2] - 1500.0).abs() < 1e-10);
659        assert!((data.tof_edges_us[3] - 2000.0).abs() < 1e-10);
660    }
661
662    #[test]
663    fn test_probe_missing_dataset() {
664        let dir = tempfile::tempdir().unwrap();
665        let path = dir.path().join("empty.h5");
666
667        let file = hdf5::File::create(&path).expect("create");
668        file.create_group("entry").expect("create entry");
669        drop(file);
670
671        let meta = probe_nexus(&path).unwrap();
672        assert!(!meta.has_histogram);
673        assert!(!meta.has_events);
674        assert!(meta.histogram_shape.is_none());
675        assert!(meta.n_events.is_none());
676    }
677
678    /// Create a minimal NeXus file with neutron event data.
679    fn create_test_events(
680        path: &Path,
681        tof_ns: &[u64],
682        x: &[f64],
683        y: &[f64],
684        flight_path_m: Option<f64>,
685    ) {
686        let file = hdf5::File::create(path).expect("create");
687        let entry = file.create_group("entry").expect("create entry");
688
689        if let Some(fp) = flight_path_m {
690            entry
691                .new_attr::<f64>()
692                .shape(())
693                .create("flight_path_m")
694                .expect("create attr")
695                .write_scalar(&fp)
696                .expect("write attr");
697        }
698
699        let neutrons = entry.create_group("neutrons").expect("create neutrons");
700        neutrons
701            .new_dataset::<u64>()
702            .shape([tof_ns.len()])
703            .create("event_time_offset")
704            .expect("create tof")
705            .write_raw(tof_ns)
706            .expect("write tof");
707        neutrons
708            .new_dataset::<f64>()
709            .shape([x.len()])
710            .create("x")
711            .expect("create x")
712            .write_raw(x)
713            .expect("write x");
714        neutrons
715            .new_dataset::<f64>()
716            .shape([y.len()])
717            .create("y")
718            .expect("create y")
719            .write_raw(y)
720            .expect("write y");
721    }
722
723    #[test]
724    fn test_histogram_known_events() {
725        let dir = tempfile::tempdir().unwrap();
726        let path = dir.path().join("events.h5");
727
728        // 3 events: all at pixel (1, 0), TOFs at 1500 µs, 2500 µs, 1800 µs (in ns)
729        let tof_ns = vec![1_500_000, 2_500_000, 1_800_000];
730        let x = vec![1.0, 1.0, 1.0];
731        let y = vec![0.0, 0.0, 0.0];
732        create_test_events(&path, &tof_ns, &x, &y, Some(25.0));
733
734        let params = EventBinningParams {
735            n_bins: 2,
736            tof_min_us: 1000.0,
737            tof_max_us: 3000.0,
738            height: 2,
739            width: 3,
740        };
741
742        let data = load_nexus_events(&path, &params).unwrap();
743        assert_eq!(data.counts.shape(), &[2, 2, 3]);
744
745        // Bin 0: TOF [1000, 2000) µs → events at 1500 and 1800 µs → 2 counts
746        assert_eq!(data.counts[[0, 0, 1]], 2.0);
747        // Bin 1: TOF [2000, 3000) µs → event at 2500 µs → 1 count
748        assert_eq!(data.counts[[1, 0, 1]], 1.0);
749
750        assert_eq!(data.flight_path_m, Some(25.0));
751        assert_eq!(data.tof_edges_us.len(), 3); // n_bins + 1 edges
752
753        // All 3 events kept, none dropped
754        let stats = data
755            .event_stats
756            .as_ref()
757            .expect("event_stats should be Some");
758        assert_eq!(stats.total, 3);
759        assert_eq!(stats.kept, 3);
760        assert_eq!(stats.dropped_non_finite, 0);
761        assert_eq!(stats.dropped_tof_range, 0);
762        assert_eq!(stats.dropped_spatial, 0);
763    }
764
765    #[test]
766    fn test_filter_out_of_range_events() {
767        let dir = tempfile::tempdir().unwrap();
768        let path = dir.path().join("events_oob.h5");
769
770        // Events: one in range, one out of TOF range, one out of spatial range
771        let tof_ns = vec![
772            1_500_000, // in range
773            500_000,   // below tof_min
774            1_500_000, // in range but x out of bounds
775        ];
776        let x = vec![0.0, 0.0, 5.0]; // 5.0 is out of width=3
777        let y = vec![0.0, 0.0, 0.0];
778        create_test_events(&path, &tof_ns, &x, &y, None);
779
780        let params = EventBinningParams {
781            n_bins: 2,
782            tof_min_us: 1000.0,
783            tof_max_us: 3000.0,
784            height: 2,
785            width: 3,
786        };
787
788        let data = load_nexus_events(&path, &params).unwrap();
789
790        // Only 1 event should be counted (the first one)
791        let total: f64 = data.counts.iter().sum();
792        assert_eq!(total, 1.0);
793        assert_eq!(data.counts[[0, 0, 0]], 1.0);
794
795        // 1 kept, 1 dropped by TOF range, 1 dropped by spatial bounds
796        let stats = data
797            .event_stats
798            .as_ref()
799            .expect("event_stats should be Some");
800        assert_eq!(stats.total, 3);
801        assert_eq!(stats.kept, 1);
802        assert_eq!(stats.dropped_non_finite, 0);
803        assert_eq!(stats.dropped_tof_range, 1);
804        assert_eq!(stats.dropped_spatial, 1);
805    }
806
807    #[test]
808    fn test_empty_events() {
809        let dir = tempfile::tempdir().unwrap();
810        let path = dir.path().join("empty_events.h5");
811
812        create_test_events(&path, &[], &[], &[], None);
813
814        let params = EventBinningParams {
815            n_bins: 10,
816            tof_min_us: 1000.0,
817            tof_max_us: 20000.0,
818            height: 4,
819            width: 4,
820        };
821
822        let data = load_nexus_events(&path, &params).unwrap();
823        assert_eq!(data.counts.shape(), &[10, 4, 4]);
824
825        let total: f64 = data.counts.iter().sum();
826        assert_eq!(total, 0.0);
827
828        // Zero events in, zero events out
829        let stats = data
830            .event_stats
831            .as_ref()
832            .expect("event_stats should be Some");
833        assert_eq!(stats.total, 0);
834        assert_eq!(stats.kept, 0);
835        assert_eq!(stats.dropped_non_finite, 0);
836        assert_eq!(stats.dropped_tof_range, 0);
837        assert_eq!(stats.dropped_spatial, 0);
838    }
839
840    #[test]
841    fn test_probe_with_events() {
842        let dir = tempfile::tempdir().unwrap();
843        let path = dir.path().join("with_events.h5");
844
845        create_test_events(
846            &path,
847            &[1000, 2000, 3000],
848            &[0.0, 1.0, 2.0],
849            &[0.0, 0.0, 1.0],
850            None,
851        );
852
853        let meta = probe_nexus(&path).unwrap();
854        assert!(!meta.has_histogram);
855        assert!(meta.has_events);
856        assert_eq!(meta.n_events, Some(3));
857    }
858
859    #[test]
860    fn test_list_hdf5_tree() {
861        let dir = tempfile::tempdir().unwrap();
862        let path = dir.path().join("tree.h5");
863
864        // Create a file with nested groups and a dataset
865        {
866            let file = hdf5::File::create(&path).expect("create file");
867            let g1 = file.create_group("entry").expect("create entry");
868            let g2 = g1.create_group("histogram").expect("create histogram");
869            g2.new_dataset::<f64>()
870                .shape([3])
871                .create("data")
872                .expect("create data")
873                .write_raw(&[1.0, 2.0, 3.0])
874                .expect("write data");
875        }
876
877        let tree = list_hdf5_tree(&path, 10).unwrap();
878        assert!(!tree.is_empty());
879
880        // Check that we find the expected paths
881        let paths: Vec<&str> = tree.iter().map(|e| e.path.as_str()).collect();
882        assert!(paths.contains(&"/entry"));
883        assert!(paths.contains(&"/entry/histogram"));
884        assert!(paths.contains(&"/entry/histogram/data"));
885
886        // The dataset should have a shape
887        let data_entry = tree
888            .iter()
889            .find(|e| e.path == "/entry/histogram/data")
890            .unwrap();
891        assert!(data_entry.shape.is_some());
892    }
893
894    #[test]
895    fn test_nan_xy_coords_dropped() {
896        let dir = tempfile::tempdir().unwrap();
897        let path = dir.path().join("nan_xy.h5");
898
899        // 4 events: 1 good, 1 NaN x, 1 Inf y, 1 good
900        let tof_ns = vec![1_500_000, 1_500_000, 1_500_000, 2_500_000];
901        let x = vec![0.0, f64::NAN, 0.0, 1.0];
902        let y = vec![0.0, 0.0, f64::INFINITY, 0.0];
903        create_test_events(&path, &tof_ns, &x, &y, None);
904
905        let params = EventBinningParams {
906            n_bins: 2,
907            tof_min_us: 1000.0,
908            tof_max_us: 3000.0,
909            height: 2,
910            width: 3,
911        };
912
913        let data = load_nexus_events(&path, &params).unwrap();
914
915        // Only 2 good events should be counted
916        let total_counts: f64 = data.counts.iter().sum();
917        assert_eq!(total_counts, 2.0);
918
919        let stats = data
920            .event_stats
921            .as_ref()
922            .expect("event_stats should be Some");
923        assert_eq!(stats.total, 4);
924        assert_eq!(stats.kept, 2);
925        assert_eq!(stats.dropped_non_finite, 2);
926        assert_eq!(stats.dropped_tof_range, 0);
927        assert_eq!(stats.dropped_spatial, 0);
928    }
929}