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)`.  The loader
5//!   requires the caller to choose how multi-angle files are handled via
6//!   [`MultiAngleMode`] (error, sum, or select-angle) and transposes the
7//!   chosen 3D slice to NEREIDS convention `(tof, y, x)`.
8//! - **Events**: per-neutron `(event_time_offset, x, y)` histogrammed into
9//!   a `(tof, y, x)` grid with user-specified binning parameters.
10//!
11//! ## Multi-angle handling (issue #430)
12//!
13//! Earlier revisions of this module silently summed multi-angle
14//! histograms into a single `(tof, y, x)` volume at load time — an
15//! irreversible data loss in the import path.  The default now is to
16//! **refuse** multi-angle files via [`MultiAngleMode::Error`]; callers
17//! who genuinely want the legacy sum-over-angles behaviour opt in
18//! explicitly with [`MultiAngleMode::Sum`], and callers who want to
19//! work with a single projection from a multi-angle acquisition
20//! choose [`MultiAngleMode::SelectAngle`].
21//!
22//! ## HDF5 Schema (rustpix convention)
23//!
24//! ```text
25//! /entry/histogram/counts          — u64 4D [rot_angle, y, x, tof]
26//! /entry/histogram/time_of_flight  — f64 1D, TOF axis (see "Units" below)
27//! /entry/neutrons/event_time_offset — u64 1D, TOF per event (see "Units" below)
28//! /entry/neutrons/x                — f64 1D, pixel coordinate
29//! /entry/neutrons/y                — f64 1D, pixel coordinate
30//! /entry/pixel_masks/dead          — u8  2D [y, x]
31//! ```
32//!
33//! Metadata attributes on `/entry` or group level:
34//! - `flight_path_m` (f64)
35//! - `tof_offset_ns` (f64)
36//!
37//! ## Units convention
38//!
39//! **Canonical internal TOF unit: microseconds (µs).**  Every TOF
40//! quantity returned to NEREIDS callers — `tof_edges_us`,
41//! `EventBinningParams::tof_min_us`/`tof_max_us`, downstream
42//! `nereids_io::tof` energy conversions — is in µs.  All other parts of
43//! the pipeline (energy mapping, normalization, fitting) assume µs.
44//!
45//! On read, both `time_of_flight` (histogram path) and
46//! `event_time_offset` (events path) consult the HDF5 `units`
47//! attribute on the dataset (the NeXus/NXtof convention) and rescale
48//! to µs accordingly:
49//!
50//! | `units` attribute       | Rescale to µs |
51//! |-------------------------|---------------|
52//! | `ns`, `nanoseconds`     | `× 1e-3`      |
53//! | `us`, `µs`, `microseconds` | `× 1`      |
54//! | `ms`, `milliseconds`    | `× 1e3`       |
55//! | `s`, `seconds`          | `× 1e6`       |
56//! | (missing)               | `× 1e-3` (assume ns — rustpix legacy default) |
57//! | anything else           | hard error    |
58//!
59//! The "missing → assume ns" fallback preserves backward compatibility
60//! with the rustpix producer (`scripts/fixtures/extract_venus_hf_nexus.py`)
61//! which writes nanoseconds without a `units` attribute.  Any file
62//! that *does* set `units` is parsed strictly: an unrecognised value
63//! is rejected rather than silently mis-scaled.  This closes a
64//! 1000× silent-rescale bug on `units = "us"` (issue #554).
65
66use std::path::Path;
67
68use hdf5::types::VarLenUnicode;
69use ndarray::{Array3, s};
70
71use crate::error::IoError;
72
73/// Multiplicative scale factor from a NeXus `units` attribute string to
74/// the canonical internal unit (microseconds).
75///
76/// See the module-level "Units convention" table for the full mapping.
77/// `None` for the `units` attribute means "attribute absent" and falls
78/// back to the rustpix legacy assumption of nanoseconds.  Any
79/// recognised unit is matched case-insensitively after trimming
80/// surrounding whitespace.  An unrecognised non-empty string returns
81/// an error rather than silently mis-scaling.
82fn tof_scale_to_us(units: Option<&str>) -> Result<f64, IoError> {
83    match units {
84        // Absent attribute — rustpix legacy default.  The repository's
85        // own producers (`scripts/fixtures/extract_venus_hf_nexus.py`)
86        // write nanoseconds without a `units` attribute, so we
87        // preserve that contract for backward compatibility.
88        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                // "µs" lowercases to "µs" — the only non-ASCII form we
94                // accept.  The MICRO SIGN U+00B5 (the literal "µ"
95                // appearing in source above) and the Greek small
96                // letter MU U+03BC are visually identical but are
97                // distinct Unicode codepoints; both are written
98                // verbatim by various NeXus producers, so we accept
99                // both.
100                "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
113/// Read a string-valued attribute from an HDF5 `Location` (Group or
114/// Dataset both deref to `Location`), returning `None` if the
115/// attribute is absent and `Err` only if the attribute exists but
116/// cannot be decoded as a UTF-8 string.
117///
118/// Absence is detected via [`Location::attr_names`] (rather than
119/// catching any error from [`Location::attr`]) so that genuine HDF5
120/// errors — corrupt file, permission denied, internal failure —
121/// surface as [`IoError::InvalidParameter`] instead of silently
122/// becoming "attribute missing".  This was a latent bug pre-Round 2:
123/// the previous implementation mapped *every* `attr()` failure to
124/// `Ok(None)`, including non-"not found" errors.
125fn read_string_attr(loc: &hdf5::Location, name: &str) -> Result<Option<String>, IoError> {
126    // Probe the attribute table first.  `attr_names()` is the only
127    // discriminator the hdf5-metno 0.12 `Error` enum exposes for
128    // "absent vs. other failure" — its `Error` is a flat
129    // `HDF5(ErrorStack) | Internal(String)` with no typed
130    // "attribute not found" variant.
131    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/// Metadata probed from a NeXus/HDF5 file without loading full data.
153#[derive(Debug, Clone)]
154pub struct NexusMetadata {
155    /// Whether `/entry/histogram/counts` exists.
156    pub has_histogram: bool,
157    /// Whether `/entry/neutrons` group exists with event data.
158    pub has_events: bool,
159    /// Shape of the histogram `(rot_angle, y, x, tof)`, if present.
160    pub histogram_shape: Option<[usize; 4]>,
161    /// Number of events in `/entry/neutrons/event_time_offset`, if present.
162    pub n_events: Option<usize>,
163    /// Flight path in meters (from attributes), if present.
164    pub flight_path_m: Option<f64>,
165    /// TOF offset in nanoseconds (from attributes), if present.
166    pub tof_offset_ns: Option<f64>,
167    /// TOF bin edges or centers in **microseconds**, if present.  The
168    /// probe path consults the dataset's `units` attribute and
169    /// rescales to µs the same way [`load_nexus_histogram`] does, so
170    /// this field is unit-consistent with [`NexusHistogramData::tof_edges_us`].
171    pub tof_edges_us: Option<Vec<f64>>,
172}
173
174/// An entry in the HDF5 group/dataset tree hierarchy.
175#[derive(Debug, Clone)]
176pub struct Hdf5TreeEntry {
177    /// Full path within the HDF5 file (e.g., `/entry/histogram/counts`).
178    pub path: String,
179    /// Whether this entry is a group or dataset.
180    pub kind: Hdf5EntryKind,
181    /// Dataset shape, if this entry is a dataset.
182    pub shape: Option<Vec<usize>>,
183}
184
185/// Kind of HDF5 tree entry.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum Hdf5EntryKind {
188    Group,
189    Dataset,
190}
191
192/// Histogram data loaded from a NeXus file, ready for NEREIDS processing.
193#[derive(Debug, Clone)]
194pub struct NexusHistogramData {
195    /// Counts array in NEREIDS convention: `(n_tof, height, width)`.
196    pub counts: Array3<f64>,
197    /// TOF bin edges in microseconds.
198    pub tof_edges_us: Vec<f64>,
199    /// Flight path in meters, if available from the file.
200    pub flight_path_m: Option<f64>,
201    /// Dead pixel mask from `/entry/pixel_masks/dead`, if present.
202    pub dead_pixels: Option<ndarray::Array2<bool>>,
203    /// Number of rotation angles summed (D-5). 1 means no collapse occurred.
204    pub n_rotation_angles: usize,
205    /// Event retention statistics (only populated for event-mode loading).
206    pub event_stats: Option<EventRetentionStats>,
207}
208
209/// Statistics on how many events were kept vs dropped during histogramming.
210#[derive(Debug, Clone)]
211pub struct EventRetentionStats {
212    /// Total events read from the file.
213    pub total: usize,
214    /// Events successfully histogrammed.
215    pub kept: usize,
216    /// Events dropped due to non-finite values in TOF or spatial coordinates.
217    ///
218    /// For u64 TOF input (`event_time_offset`), the TOF channel is always
219    /// finite, so the TOF path contributes zero to this counter. Non-finite
220    /// values arise from the f64 x/y pixel coordinates (NaN or Inf from
221    /// upstream processing or detector artifacts).
222    pub dropped_non_finite: usize,
223    /// Events dropped due to TOF outside `[tof_min, tof_max)`.
224    pub dropped_tof_range: usize,
225    /// Events dropped due to pixel coordinates outside detector bounds.
226    pub dropped_spatial: usize,
227}
228
229/// Probe a NeXus/HDF5 file for available data modalities and metadata.
230///
231/// Opens the file read-only and checks for histogram and event groups
232/// without loading any large datasets.
233pub 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    // Probe histogram
246    let (has_histogram, histogram_shape, tof_edges_us) = probe_histogram_group(&entry);
247
248    // Probe events
249    let (has_events, n_events) = probe_event_group(&entry);
250
251    // Read metadata attributes from the /entry group
252    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/// Policy for handling multi-angle NeXus histogram files.
267///
268/// Issue #430: the loader must refuse to silently collapse the
269/// rotation-angle dimension.  Callers choose explicitly which
270/// projection (or combination of projections) they want.
271#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
272pub enum MultiAngleMode {
273    /// Reject files with more than one rotation angle with a clear
274    /// [`IoError::InvalidParameter`].  Single-angle files (`n_rot == 1`)
275    /// load normally.  This is the default — it prevents silent data
276    /// loss for callers that aren't multi-angle-aware.
277    #[default]
278    Error,
279    /// Sum across all rotation angles into a single `(tof, y, x)`
280    /// volume.  This is the legacy auto-sum behaviour, preserved as an
281    /// **explicit opt-in** so that callers can't invoke it by
282    /// accident.  Multi-angle analysis information is irreversibly
283    /// lost on this path.
284    Sum,
285    /// Extract a single rotation angle by index.  Returns an error if
286    /// the index is out of range.
287    SelectAngle(usize),
288}
289
290/// Load histogram data from a NeXus file, refusing multi-angle inputs.
291///
292/// Reads `/entry/histogram/counts` (u64 4D), converts to f64, and
293/// transposes the chosen single-angle slice to NEREIDS convention
294/// `(tof, y, x)`.  TOF values are converted from nanoseconds to
295/// microseconds.
296///
297/// If the file has more than one rotation angle (`n_rot > 1`), the
298/// call returns [`IoError::InvalidParameter`] pointing at
299/// [`load_nexus_histogram_with_mode`] — silent sum-over-angles
300/// was the pre-#430 behaviour and has been removed because it lost
301/// projection-resolved information without the caller's knowledge.
302///
303/// Single-angle files (`n_rot == 1`) load normally and reach the same
304/// output as before #430.
305pub fn load_nexus_histogram(path: &Path) -> Result<NexusHistogramData, IoError> {
306    load_nexus_histogram_with_mode(path, MultiAngleMode::Error)
307}
308
309/// Load histogram data from a NeXus file with an explicit multi-angle
310/// handling policy.  See [`MultiAngleMode`] for the options.
311///
312/// This is the explicit-opt-in variant behind
313/// [`load_nexus_histogram`].  Use it when you know the file may have
314/// multiple rotation angles and you have made a deliberate choice
315/// about how to combine them.
316pub 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    // Read counts: u64 4D [rot_angle, y, x, tof]
336    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    // Validate the rotation-angle policy BEFORE reading the full 4D
349    // counts dataset (Codex review on PR-B): the check is purely
350    // metadata-driven and the rejection paths should be cheap.  Reading
351    // the full u64 cube just to error out is wasteful on production
352    // multi-angle NeXus files (easily multi-GB), and historically
353    // caused OOM-before-error on the default "refuse" code path.
354    let n_rot = shape[0];
355    if n_rot == 0 {
356        // Degenerate file with a zero-sized rotation-angle axis.
357        // Reject rather than produce an all-zero output (which would
358        // look like a valid-but-empty measurement).
359        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    // Mirror the rotation-angle guard for the sibling y / x / tof axes
366    // (counts layout is [rot_angle, y, x, tof]).  A zero-sized y, x, or tof
367    // axis is just as degenerate: it would produce an empty detector plane or
368    // an empty energy series that looks like a valid-but-empty measurement
369    // downstream instead of a clear load error.
370    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    // Read only the rotation-angle slice(s) the caller actually needs
399    // (Copilot review on PR-B).  Reading the full 4D cube when the
400    // caller wants one projection is wasteful on production
401    // multi-angle files (multi-GB per acquisition).
402    //
403    // - `Error` is guaranteed to have `n_rot == 1` (validated above),
404    //   so we hyperslab-read the single projection.
405    // - `Sum` on a single-angle file is identity with `Error`.
406    // - `Sum` on a multi-angle file needs every angle; the full read
407    //   is unavoidable and the legacy opt-in carries its memory cost.
408    // - `SelectAngle(idx)` hyperslab-reads only the selected
409    //   projection — the other angles' bytes never enter memory.
410    //
411    // All paths produce a `[y, x, tof]` 3D `u64` array ready for the
412    // f64 conversion + transpose below.
413    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: n_rot > 1 was rejected above, n_rot == 1 is
432            // matched by the first arm, n_rot == 0 was rejected earlier.
433            unreachable!("Error mode reached with n_rot = {n_rot}")
434        }
435    };
436
437    // Convert to f64 and transpose [y, x, tof] → NEREIDS convention [tof, y, x]
438    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    // Read TOF axis (nanoseconds → microseconds)
446    let tof_edges_us = read_tof_axis(&hist_group)?;
447
448    // Validate TOF edges count against histogram TOF dimension
449    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    // Read flight path
460    let flight_path_m = read_f64_attr(&hist_group, "flight_path_m")
461        .or_else(|| read_f64_attr(&entry, "flight_path_m"));
462
463    // Read dead pixel mask, validated against the detector's spatial dims.
464    // counts_f64 is [tof, y, x], so (height, width) = (shape[1], shape[2]).
465    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, // histogram mode, not events
474    })
475}
476
477/// Parameters for histogramming neutron event data into a 3D grid.
478#[derive(Debug, Clone)]
479pub struct EventBinningParams {
480    /// Number of TOF bins.
481    pub n_bins: usize,
482    /// Minimum TOF in microseconds.
483    pub tof_min_us: f64,
484    /// Maximum TOF in microseconds.
485    pub tof_max_us: f64,
486    /// Detector height in pixels.
487    pub height: usize,
488    /// Detector width in pixels.
489    pub width: usize,
490}
491
492/// Load neutron event data from a NeXus file and histogram into a 3D grid.
493///
494/// Reads `/entry/neutrons/event_time_offset` (u64), `x` (f64), `y` (f64),
495/// rescales TOF to the canonical internal unit of microseconds based on
496/// the `event_time_offset` dataset's `units` attribute (issue #554), then
497/// bins events into a `(n_bins, height, width)` histogram grid.
498///
499/// # TOF units handling (issue #554)
500///
501/// The loader consults the NeXus `units` attribute on the
502/// `event_time_offset` dataset and rescales the raw `u64` channel
503/// counts to µs accordingly.  See the module-level "Units convention"
504/// table for the recognised values.  If the `units` attribute is
505/// absent, the loader falls back to the rustpix legacy assumption of
506/// nanoseconds (`× 1e-3`); if it is present but unrecognised, the
507/// call returns [`IoError::InvalidParameter`] rather than silently
508/// guessing a scale factor.
509///
510/// # Binning behaviour (D-8)
511///
512/// - **Out-of-range events are dropped and counted**: events with TOF outside
513///   `[tof_min_us, tof_max_us)`, pixel coordinates outside `[0, width)` /
514///   `[0, height)`, or non-finite spatial coordinates are excluded. Per-category
515///   drop counts are returned in [`EventRetentionStats`] via
516///   [`NexusHistogramData::event_stats`].
517/// - **Pixel coordinates are rounded to the nearest integer** (`f64::round()`
518///   then cast to `isize`), snapping sub-pixel positions to a discrete grid.
519///   Fractional coordinates exactly at 0.5 round up.
520pub 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    // Read event arrays.  Open the dataset first so we can consult its
560    // `units` attribute (issue #554) before reading the data.
561    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    // Generate linear TOF bin edges
595    let tof_edges_us =
596        crate::tof::linspace_tof_edges(params.tof_min_us, params.tof_max_us, params.n_bins)?;
597
598    // Histogram events with retention tracking.
599    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        // Convert raw TOF to canonical µs via the units-attribute scale
609        // factor (issue #554).  For the default rustpix case (`units`
610        // absent → ns assumed), `tof_scale` is `1e-3`, recovering the
611        // pre-fix expression `tof_raw[i] / 1000.0`.
612        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    // Read flight path
644    let flight_path_m = read_f64_attr(&neutrons, "flight_path_m")
645        .or_else(|| read_f64_attr(&entry, "flight_path_m"));
646
647    // Read dead pixel mask, validated against the requested detector dims.
648    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
672// ---- Internal helpers ----
673
674/// Probe the histogram group for shape and TOF axis without loading counts.
675///
676/// The returned TOF edges are in **microseconds**, rescaled from the
677/// dataset's NeXus `units` attribute via [`tof_scale_to_us`] — the
678/// same logic the full [`load_nexus_histogram`] uses.  If the `units`
679/// attribute is unparseable, the TOF axis is dropped entirely
680/// (returned as `None`) rather than silently propagated at the wrong
681/// scale, matching the function's "any failure → no data for that
682/// field" contract.  Round 2 review (PR #561) found that the
683/// previous implementation returned the raw values verbatim — a
684/// silent 1000× error for any file written with `units = "us"`,
685/// symmetric with the load-path bug closed by issue #554.
686fn 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    // Try reading TOF axis and rescaling to µs via the `units`
705    // attribute.  Any failure (missing dataset, read error,
706    // unparseable units attr) collapses to `None` — the probe is
707    // best-effort and must never poison the rest of the metadata.
708    let tof_edges_us = hist.dataset("time_of_flight").ok().and_then(|ds| {
709        let raw = ds.read_1d::<f64>().ok()?.to_vec();
710        // `read_string_attr` returns Ok(None) for absent and Err for
711        // genuine HDF5 failures; either should propagate to "no TOF
712        // axis" rather than fall through to the wrong-scale raw
713        // values.
714        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
722/// Probe the neutron event group for event count.
723fn 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
737/// Read TOF axis from the histogram group, rescaling to µs based on
738/// the dataset's `units` attribute (see module docs / issue #554).
739fn 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    // Consult the dataset's NeXus `units` attribute.  Missing →
752    // legacy nanoseconds assumption (rustpix); known value → use
753    // table; unknown value → hard error (no silent mis-scale).
754    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    // Validate the TOF axis is finite, strictly positive, and strictly
760    // increasing, mirroring the spectrum-file load path (`guided::load` runs
761    // `validate_monotonic` on the parsed spectrum before use).  A non-finite,
762    // non-positive, or non-increasing TOF edge produces a `tof_to_energy` NaN /
763    // negative-energy downstream; reject it here at the I/O boundary instead.
764    //
765    // `validate_monotonic` alone is *not* sufficient for the finite/positive
766    // half: a trailing `+∞` satisfies `prev < +∞` (so monotonicity passes), a
767    // single-edge axis never enters `windows(2)` at all, and `first <= 0.0` is
768    // bypassed by `NaN` (`NaN <= 0.0` is `false`).  Check every scaled edge
769    // explicitly with `is_finite() && > 0.0` (the `is_finite()` half is what
770    // catches `NaN` / `±∞`, which order comparisons silently pass), then defer
771    // the strictly-increasing requirement to `validate_monotonic`.
772    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
784/// Read a scalar f64 attribute from a group.
785fn 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
792/// Read the dead-pixel mask from `/entry/pixel_masks/dead`, validating its
793/// shape against the detector's `(height, width)`.
794///
795/// Returns `Ok(None)` when the mask group / dataset is simply *absent* (a file
796/// without a dead-pixel mask is valid).  Returns `Err` when the mask is
797/// *present but malformed*:
798/// * `pixel_masks` exists but is not a group, or `dead` exists but is not a
799///   readable dataset — surfaced as `InvalidParameter` rather than silently
800///   treated as absence (a malformed mask is an upstream-writer bug, and
801///   silently dropping it would mask the wrong pixels or none at all);
802/// * the mask shape does not match the counts' spatial dimensions — surfaced
803///   as `ShapeMismatch`.
804///
805/// Absence vs malformed is decided by link existence (`member_names`), not by
806/// whether `group()` / `dataset()` *succeed*: those collapse "the link is not
807/// there" and "the link is there but the wrong object kind / unreadable" into
808/// the same `Err`, which would otherwise mask real corruption as absence.
809fn read_dead_pixel_mask(
810    entry: &hdf5::Group,
811    expected_hw: (usize, usize),
812) -> Result<Option<ndarray::Array2<bool>>, IoError> {
813    // `pixel_masks` link absent → no mask (valid file).
814    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    // Link present but not openable as a group → malformed, not absent.
821    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    // `dead` link absent → no mask (valid file).
828    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    // Link present but not openable as a dataset → malformed, not absent.
835    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
853/// List the group/dataset tree structure of an HDF5 file.
854///
855/// Walks the file hierarchy recursively up to `max_depth` levels deep,
856/// returning entries with their path, kind (group vs dataset), and shape
857/// (for datasets).  Useful for displaying file structure in a GUI browser.
858pub 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
874/// Recursively walk an HDF5 group, collecting tree entries.
875fn 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        // Try dataset first (leaf nodes)
895        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            // It's a group — record it and recurse if within depth
904            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    /// Create a minimal NeXus HDF5 file with histogram data for testing.
921    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        // 1 rot angle, 2x3 spatial, 4 TOF bins → shape [1, 2, 3, 4]
963        let counts = vec![0u64; 24];
964        let tof_ns = vec![1000.0, 2000.0, 3000.0, 4000.0, 5000.0]; // 5 edges for 4 bins
965        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        // No `units` attribute on this fixture → rustpix legacy-ns
973        // assumption, so the probe rescales 1000/2000/.../5000 ns
974        // into 1/2/.../5 µs (× 1e-3).
975        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    /// Round 2 review: `probe_nexus` must respect the `units`
987    /// attribute on `time_of_flight` the same way `load_nexus_histogram`
988    /// does.  A file written with `units = "us"` must surface µs
989    /// values verbatim through the probe (no 1000× silent rescale).
990    #[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        // Values that would be wrong by 1000× if treated as ns.
997        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        // 1 rot angle, 2x3 spatial, 2 TOF bins
1018        let mut counts = vec![0u64; 2 * 3 * 2];
1019        counts[0] = 15; // rot=0, y=0, x=0, tof=0
1020
1021        let tof_ns = vec![1000.0, 2000.0, 3000.0]; // 3 edges for 2 bins
1022        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        // Shape should be (n_tof=2, n_y=2, n_x=3) after transposing
1027        assert_eq!(data.counts.shape(), &[2, 2, 3]);
1028        // Single angle: value is preserved exactly
1029        assert_eq!(data.counts[[0, 0, 0]], 15.0);
1030
1031        // TOF edges converted ns → µs
1032        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    /// Issue #430: default `load_nexus_histogram` must refuse multi-angle
1041    /// files rather than silently collapse the rotation dimension.
1042    #[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    /// Issue #430: `MultiAngleMode::Sum` is the explicit opt-in for the
1065    /// legacy auto-sum behaviour.  Recovers the pre-#430 output exactly.
1066    #[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; // rot=0, y=0, x=0, tof=0
1073        counts[12] = 5; // rot=1, y=0, x=0, tof=0
1074        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        // Summed: 10 + 5 = 15
1080        assert_eq!(data.counts[[0, 0, 0]], 15.0);
1081        assert_eq!(data.n_rotation_angles, 2);
1082    }
1083
1084    /// Issue #430: `MultiAngleMode::SelectAngle(i)` extracts a single
1085    /// projection by index, leaving the other angles' data unread.
1086    #[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; // rot=0, y=0, x=0, tof=0
1093        counts[12] = 200; // rot=1, y=0, x=0, tof=0
1094        counts[24] = 300; // rot=2, y=0, x=0, tof=0
1095        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        // Select angle 1 — should see 200, not 100 / 300 / 600.
1099        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        // Out-of-range index → error.
1104        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    /// `MultiAngleMode::Error` on a single-angle file is a no-op:
1114    /// `n_rot == 1` is the trivial non-collapsing case.  All three
1115    /// modes must produce identical output here.
1116    #[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        // All three modes produce the same output on a single-angle file.
1128        assert_eq!(d_err.counts, d_sum.counts);
1129        assert_eq!(d_err.counts, d_sel.counts);
1130        // Value preserved (not doubled — single angle).
1131        assert_eq!(d_err.counts[[0, 0, 0]], 7.0);
1132        assert_eq!(d_err.n_rotation_angles, 1);
1133    }
1134
1135    /// A zero-angle file (degenerate, `shape[0] == 0`) must be
1136    /// rejected on every mode — otherwise `Sum` would produce an
1137    /// all-zero output indistinguishable from a valid but dark
1138    /// measurement, and `Error` would silently accept the degenerate
1139    /// file.
1140    #[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    /// A zero-sized y / x / tof axis is just as degenerate as a zero-angle
1163    /// axis and must be rejected the same way, rather than producing an empty
1164    /// detector plane / energy series downstream.
1165    #[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(); // any axis is 0 → empty cube
1175            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    /// The NeXus TOF axis must be strictly monotonic and positive, mirroring
1188    /// the spectrum-file path.  A non-increasing or non-positive axis would
1189    /// silently feed `tof_to_energy` a bad value downstream.
1190    #[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        // 1 angle, 1×1 spatial, 2 TOF bins → 3 edges, but they decrease.
1195        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        // First edge is zero → non-positive TOF axis.
1211        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    /// A trailing `+∞` TOF edge satisfies `prev < +∞` so it passes a
1223    /// monotonicity-only check, but it is not a real time — the per-edge
1224    /// `is_finite()` guard must reject it.
1225    #[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        // 1 angle, 1×1 spatial, 2 TOF bins → 3 edges, last is +∞.
1230        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    /// A single-bin axis has only 2 edges; if a malformed scale yields a
1242    /// degenerate axis the per-edge guard still fires.  A 1-edge axis never
1243    /// enters `windows(2)` at all, so `validate_monotonic` is vacuously OK —
1244    /// the per-edge `is_finite() && > 0` check is the only thing that rejects
1245    /// a lone `NaN` / `+∞` edge.  Exercise `read_tof_axis` directly so the
1246    /// single-edge case is reachable without tripping the bin-count
1247    /// cross-check in `load_nexus_histogram`.
1248    #[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            // No `units` attr → legacy-ns scale (finite, so the bad edge is
1264            // preserved as bad, not normalised away).
1265            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    /// Create a histogram fixture that also carries a `/entry/pixel_masks/dead`
1282    /// mask of the given shape, for dead-mask shape-validation tests.
1283    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    /// A dead-pixel mask whose shape does not match the detector's spatial
1305    /// dimensions must be rejected — applying it would mask the wrong pixels.
1306    #[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        // counts shape [1, 2, 3, 2] → detector is 2×3; write a 5×5 mask.
1311        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    /// A correctly-shaped dead-pixel mask still loads (no false rejection).
1325    #[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        // 2×3 mask matching the detector; mark one pixel (row 0, col 1) dead.
1332        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    /// A file with *no* `pixel_masks` group is valid: the mask is absent, not
1342    /// malformed, so the load succeeds with `dead_pixels == None`.
1343    #[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    /// A `/entry/pixel_masks/dead` link that exists but is the wrong object
1359    /// kind (a group, not a dataset) is *present-but-malformed*: it must be
1360    /// surfaced as an error, not silently swallowed as absence (which would
1361    /// drop a real-but-corrupt mask and mask no pixels).
1362    #[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        // Write `dead` as a *group*, not a dataset — present but malformed.
1371        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    /// Codex review: `MultiAngleMode::Error` must reject multi-angle
1389    /// files BEFORE reading the full 4D counts dataset.  On a real
1390    /// multi-angle file this dataset can be multi-GB; wasting a read
1391    /// to then error out is prohibitive.  This test uses metadata
1392    /// (shape is 4D, n_rot > 1) from a tiny synthetic fixture to
1393    /// assert the error is returned — the underlying file is
1394    /// small here, but the code-path assertion is that rejection
1395    /// happens via the shape check alone.  (We can't assert "no
1396    /// read happened" directly without hooking HDF5, but the
1397    /// structural guarantee is preserved by the order of
1398    /// statements in `load_nexus_histogram_with_mode`.)
1399    #[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        // Small synthetic file, but with shape[0]=4 so we exercise the
1404        // rejection path.
1405        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        // 500_000 ns = 500 µs, etc.
1429        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    /// Create a minimal NeXus file with neutron event data.
1452    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        // 3 events: all at pixel (1, 0), TOFs at 1500 µs, 2500 µs, 1800 µs (in ns)
1502        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, &params).unwrap();
1516        assert_eq!(data.counts.shape(), &[2, 2, 3]);
1517
1518        // Bin 0: TOF [1000, 2000) µs → events at 1500 and 1800 µs → 2 counts
1519        assert_eq!(data.counts[[0, 0, 1]], 2.0);
1520        // Bin 1: TOF [2000, 3000) µs → event at 2500 µs → 1 count
1521        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); // n_bins + 1 edges
1525
1526        // All 3 events kept, none dropped
1527        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        // Events: one in range, one out of TOF range, one out of spatial range
1544        let tof_ns = vec![
1545            1_500_000, // in range
1546            500_000,   // below tof_min
1547            1_500_000, // in range but x out of bounds
1548        ];
1549        let x = vec![0.0, 0.0, 5.0]; // 5.0 is out of width=3
1550        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, &params).unwrap();
1562
1563        // Only 1 event should be counted (the first one)
1564        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        // 1 kept, 1 dropped by TOF range, 1 dropped by spatial bounds
1569        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, &params).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        // Zero events in, zero events out
1602        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        // Create a file with nested groups and a dataset
1638        {
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        // Check that we find the expected paths
1654        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        // The dataset should have a shape
1660        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        // 4 events: 1 good, 1 NaN x, 1 Inf y, 1 good
1673        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, &params).unwrap();
1687
1688        // Only 2 good events should be counted
1689        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    // -----------------------------------------------------------------
1704    // Issue #554 — NeXus `units` attribute on TOF datasets must be
1705    // honoured.  A file written with `units = "us"` was previously
1706    // divided by 1000 silently, shifting the energy axis by 1000×.
1707    // -----------------------------------------------------------------
1708
1709    /// Write a scalar string attribute on an HDF5 dataset.  Tests use
1710    /// this to inject `units = "ns"`, `units = "us"`, etc. on the
1711    /// `time_of_flight` / `event_time_offset` datasets.
1712    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    /// Variant of `create_test_histogram` that stamps a `units`
1723    /// attribute on the `time_of_flight` dataset.
1724    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    /// Variant of `create_test_events` that stamps a `units` attribute
1752    /// on the `event_time_offset` dataset.
1753    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    /// `tof_scale_to_us` table: each recognised spelling maps to the
1789    /// documented multiplier.  Pure-helper test — exercises the lookup
1790    /// without an HDF5 file.
1791    #[test]
1792    fn test_tof_scale_to_us_table() {
1793        // Missing → legacy ns assumption.
1794        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        // Unknown units must error — no silent fallback.
1818        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    /// Histogram path with `units = "ns"` (current canonical
1829    /// assumption): explicit ns annotation must produce the same µs
1830    /// edges as the rustpix-legacy "no attribute, assume ns" path.
1831    #[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        // 3 ns edges → 0.001, 0.002, 0.003 µs
1837        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    /// Histogram path with `units = "us"` (NeXus-standard
1848    /// microseconds): values must be passed through unchanged, NOT
1849    /// divided by 1000.  Pre-#554 this produced a 1000× too-small
1850    /// energy axis silently.
1851    #[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        // µs values that would be catastrophically wrong if divided by 1000.
1857        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    /// Histogram path: NeXus `units = "s"` (seconds) must rescale
1872    /// ×1e6 → µs.
1873    #[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        // 1 ms = 1000 µs, written as 0.001 s
1879        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    /// Histogram path: unknown `units` must hard-error rather than
1889    /// silently default to ns.  Without this check, a typo
1890    /// (e.g. `"microsecond"` vs `"microseconds"`) could be mis-scaled
1891    /// — and worse, an exotic-but-real unit (`"ticks"`) would be
1892    /// silently dropped on the floor.
1893    #[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    /// Histogram path: missing `units` attribute is allowed and
1910    /// preserves the rustpix-legacy ns assumption.  This is the
1911    /// backward-compatibility guarantee for files produced by
1912    /// `scripts/fixtures/extract_venus_hf_nexus.py` etc., which write
1913    /// nanoseconds without a `units` attribute.
1914    #[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        // No `units` attribute → assume ns → 1.0 / 2.0 / 3.0 µs.
1921        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    /// Events path with `units = "us"`: the TOF binning must place
1929    /// events at the correct µs values, NOT divide them by 1000.
1930    /// Pre-#554 a file written in µs would have all events land
1931    /// 1000× lower than the user-specified bins, leaving the
1932    /// histogram empty / all dropped by `tof_range`.
1933    #[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        // Events at 1500 µs and 2500 µs (written in µs, units=us).
1939        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, &params).expect("load events with units=us");
1952
1953        // Bin 0: [1000, 2000) µs → 1500 + 1800 = 2 events
1954        assert_eq!(data.counts[[0, 0, 1]], 2.0);
1955        // Bin 1: [2000, 3000) µs → 2500 = 1 event
1956        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    /// Events path with `units = "ns"` (explicit): same result as
1963    /// the legacy "no attribute" path.
1964    #[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        // Events at 1500/2500/1800 µs, written in ns with units=ns.
1970        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, &params).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    /// Events path: unknown `units` must hard-error.
1988    #[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, &params).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}