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 and the maintainers' VENUS fixture
61//! extraction tooling, which write nanoseconds without a `units`
62//! attribute.  Any file
63//! that *does* set `units` is parsed strictly: an unrecognised value
64//! is rejected rather than silently mis-scaled.  This closes a
65//! 1000× silent-rescale bug on `units = "us"` (issue #554).
66
67use std::path::Path;
68
69use hdf5::types::VarLenUnicode;
70use ndarray::{Array3, s};
71
72use crate::error::IoError;
73
74/// Multiplicative scale factor from a NeXus `units` attribute string to
75/// the canonical internal unit (microseconds).
76///
77/// See the module-level "Units convention" table for the full mapping.
78/// `None` for the `units` attribute means "attribute absent" and falls
79/// back to the rustpix legacy assumption of nanoseconds.  Any
80/// recognised unit is matched case-insensitively after trimming
81/// surrounding whitespace.  An unrecognised non-empty string returns
82/// an error rather than silently mis-scaling.
83fn tof_scale_to_us(units: Option<&str>) -> Result<f64, IoError> {
84    match units {
85        // Absent attribute — rustpix legacy default.  The project's own
86        // fixture producers (the maintainers' VENUS extraction tooling)
87        // write nanoseconds without a `units` attribute, so we
88        // preserve that contract for backward compatibility.
89        None => Ok(1e-3),
90        Some(raw) => {
91            let normalised = raw.trim().to_ascii_lowercase();
92            match normalised.as_str() {
93                "ns" | "nanosecond" | "nanoseconds" => Ok(1e-3),
94                // "µs" lowercases to "µs" — the only non-ASCII form we
95                // accept.  The MICRO SIGN U+00B5 (the literal "µ"
96                // appearing in source above) and the Greek small
97                // letter MU U+03BC are visually identical but are
98                // distinct Unicode codepoints; both are written
99                // verbatim by various NeXus producers, so we accept
100                // both.
101                "us" | "µs" | "\u{03bc}s" | "microsecond" | "microseconds" => Ok(1.0),
102                "ms" | "millisecond" | "milliseconds" => Ok(1e3),
103                "s" | "sec" | "second" | "seconds" => Ok(1e6),
104                _ => Err(IoError::InvalidParameter(format!(
105                    "Unsupported NeXus TOF units attribute {raw:?}: expected one of \
106                     'ns', 'us'/'µs', 'ms', 's' (case-insensitive); refusing to \
107                     guess a scale factor (issue #554)"
108                ))),
109            }
110        }
111    }
112}
113
114/// Read a string-valued attribute from an HDF5 `Location` (Group or
115/// Dataset both deref to `Location`), returning `None` if the
116/// attribute is absent.  Both storage conventions decode: variable-length
117/// (rustpix) and fixed-length (SNS/ADARA) strings, ASCII or UTF-8, with
118/// trailing NUL/space padding trimmed.  `Err` when the attribute exists
119/// but is not a string, is a fixed string longer than the 1024-byte read
120/// buffer, or cannot be read/decoded.
121///
122/// Absence is detected via [`Location::attr_names`] (rather than
123/// catching any error from [`Location::attr`]) so that genuine HDF5
124/// errors — corrupt file, permission denied, internal failure —
125/// surface as [`IoError::InvalidParameter`] instead of silently
126/// becoming "attribute missing".  This was a latent bug:
127/// the previous implementation mapped *every* `attr()` failure to
128/// `Ok(None)`, including non-"not found" errors.
129pub(crate) fn read_string_attr(
130    loc: &hdf5::Location,
131    name: &str,
132) -> Result<Option<String>, IoError> {
133    // Probe the attribute table first.  `attr_names()` is the only
134    // discriminator the hdf5-metno 0.12 `Error` enum exposes for
135    // "absent vs. other failure" — its `Error` is a flat
136    // `HDF5(ErrorStack) | Internal(String)` with no typed
137    // "attribute not found" variant.
138    let names = loc.attr_names().map_err(|e| {
139        IoError::InvalidParameter(format!(
140            "Failed to list attributes while looking for {name:?}: {e}"
141        ))
142    })?;
143    if !names.iter().any(|n| n == name) {
144        return Ok(None);
145    }
146    let attr = loc.attr(name).map_err(|e| {
147        IoError::InvalidParameter(format!(
148            "Failed to open attribute {name:?} (listed but unreadable): {e}"
149        ))
150    })?;
151    // Producers disagree on string storage: rustpix writes variable-length
152    // UTF-8, while SNS/ADARA facility files write fixed-length ASCII
153    // (e.g. the 35-byte ISO timestamp on `event_time_zero@offset`).
154    // Dispatch on the stored type descriptor instead of assuming one
155    // (issue #637; previously only variable-length UTF-8 was readable).
156    use hdf5::types::{FixedAscii, FixedUnicode, TypeDescriptor, VarLenAscii};
157    let td = attr.dtype().and_then(|d| d.to_descriptor()).map_err(|e| {
158        IoError::InvalidParameter(format!("Failed to inspect type of attribute {name:?}: {e}"))
159    })?;
160    let read_err = |e: hdf5::Error| {
161        IoError::InvalidParameter(format!(
162            "Failed to read string attribute {name:?}: {e} (stored as {td:?})"
163        ))
164    };
165    let value = match td {
166        TypeDescriptor::VarLenUnicode => attr
167            .read_scalar::<VarLenUnicode>()
168            .map_err(read_err)?
169            .as_str()
170            .to_string(),
171        TypeDescriptor::VarLenAscii => attr
172            .read_scalar::<VarLenAscii>()
173            .map_err(read_err)?
174            .as_str()
175            .to_string(),
176        // Fixed-length strings: HDF5's string-to-string soft conversion
177        // repacks any length into this generous fixed buffer; trim the
178        // NUL/space padding it leaves behind.
179        TypeDescriptor::FixedAscii(n) | TypeDescriptor::FixedUnicode(n) if n <= 1024 => match td {
180            TypeDescriptor::FixedAscii(_) => attr
181                .read_scalar::<FixedAscii<1024>>()
182                .map_err(read_err)?
183                .as_str()
184                .to_string(),
185            _ => attr
186                .read_scalar::<FixedUnicode<1024>>()
187                .map_err(read_err)?
188                .as_str()
189                .to_string(),
190        },
191        TypeDescriptor::FixedAscii(n) | TypeDescriptor::FixedUnicode(n) => {
192            return Err(IoError::InvalidParameter(format!(
193                "String attribute {name:?} is {n} bytes, exceeding the supported \
194                 fixed-string read buffer (1024)"
195            )));
196        }
197        other => {
198            return Err(IoError::InvalidParameter(format!(
199                "Attribute {name:?} is not a string (stored as {other:?})"
200            )));
201        }
202    };
203    let value = value.trim_end_matches(['\0', ' ']).to_string();
204    Ok(Some(value))
205}
206
207/// Metadata probed from a NeXus/HDF5 file without loading full data.
208#[derive(Debug, Clone)]
209pub struct NexusMetadata {
210    /// Whether `/entry/histogram/counts` exists.
211    pub has_histogram: bool,
212    /// Whether `/entry/neutrons` group exists with event data.
213    pub has_events: bool,
214    /// Shape of the histogram `(rot_angle, y, x, tof)`, if present.
215    pub histogram_shape: Option<[usize; 4]>,
216    /// Number of events in `/entry/neutrons/event_time_offset`, if present.
217    pub n_events: Option<usize>,
218    /// Flight path in meters (from attributes), if present.
219    pub flight_path_m: Option<f64>,
220    /// TOF offset in nanoseconds (from attributes), if present.
221    pub tof_offset_ns: Option<f64>,
222    /// TOF bin edges or centers in **microseconds**, if present.  The
223    /// probe path consults the dataset's `units` attribute and
224    /// rescales to µs the same way [`load_nexus_histogram`] does, so
225    /// this field is unit-consistent with [`NexusHistogramData::tof_edges_us`].
226    pub tof_edges_us: Option<Vec<f64>>,
227}
228
229/// An entry in the HDF5 group/dataset tree hierarchy.
230#[derive(Debug, Clone)]
231pub struct Hdf5TreeEntry {
232    /// Full path within the HDF5 file (e.g., `/entry/histogram/counts`).
233    pub path: String,
234    /// Whether this entry is a group or dataset.
235    pub kind: Hdf5EntryKind,
236    /// Dataset shape, if this entry is a dataset.
237    pub shape: Option<Vec<usize>>,
238}
239
240/// Kind of HDF5 tree entry.
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242pub enum Hdf5EntryKind {
243    Group,
244    Dataset,
245}
246
247/// Histogram data loaded from a NeXus file, ready for NEREIDS processing.
248#[derive(Debug, Clone)]
249pub struct NexusHistogramData {
250    /// Counts array in NEREIDS convention: `(n_tof, height, width)`.
251    pub counts: Array3<f64>,
252    /// TOF bin edges in microseconds.
253    pub tof_edges_us: Vec<f64>,
254    /// Flight path in meters, if available from the file.
255    pub flight_path_m: Option<f64>,
256    /// Dead pixel mask from `/entry/pixel_masks/dead`, if present.
257    pub dead_pixels: Option<ndarray::Array2<bool>>,
258    /// Number of rotation angles summed (D-5). 1 means no collapse occurred.
259    pub n_rotation_angles: usize,
260    /// Event retention statistics (only populated for event-mode loading).
261    pub event_stats: Option<EventRetentionStats>,
262}
263
264/// Statistics on how many events were kept vs dropped during histogramming.
265#[derive(Debug, Clone)]
266pub struct EventRetentionStats {
267    /// Total events read from the file.
268    pub total: usize,
269    /// Events successfully histogrammed.
270    pub kept: usize,
271    /// Events dropped due to non-finite values in TOF or spatial coordinates.
272    ///
273    /// For u64 TOF input (`event_time_offset`), the TOF channel is always
274    /// finite, so the TOF path contributes zero to this counter. Non-finite
275    /// values arise from the f64 x/y pixel coordinates (NaN or Inf from
276    /// upstream processing or detector artifacts).
277    pub dropped_non_finite: usize,
278    /// Events dropped due to TOF outside `[tof_min, tof_max)`.
279    pub dropped_tof_range: usize,
280    /// Events dropped due to pixel coordinates outside detector bounds.
281    pub dropped_spatial: usize,
282}
283
284/// Probe a NeXus/HDF5 file for available data modalities and metadata.
285///
286/// Opens the file read-only and checks for histogram and event groups
287/// without loading any large datasets.
288pub fn probe_nexus(path: &Path) -> Result<NexusMetadata, IoError> {
289    let file = hdf5::File::open(path).map_err(|e| {
290        IoError::FileNotFound(
291            path.display().to_string(),
292            std::io::Error::other(e.to_string()),
293        )
294    })?;
295
296    let entry = file
297        .group("entry")
298        .map_err(|e| IoError::InvalidParameter(format!("Missing /entry group: {e}")))?;
299
300    // Probe histogram
301    let (has_histogram, histogram_shape, tof_edges_us) = probe_histogram_group(&entry);
302
303    // Probe events
304    let (has_events, n_events) = probe_event_group(&entry);
305
306    // Read metadata attributes from the /entry group
307    let flight_path_m = read_f64_attr(&entry, "flight_path_m");
308    let tof_offset_ns = read_f64_attr(&entry, "tof_offset_ns");
309
310    Ok(NexusMetadata {
311        has_histogram,
312        has_events,
313        histogram_shape,
314        n_events,
315        flight_path_m,
316        tof_offset_ns,
317        tof_edges_us,
318    })
319}
320
321/// Policy for handling multi-angle NeXus histogram files.
322///
323/// Issue #430: the loader must refuse to silently collapse the
324/// rotation-angle dimension.  Callers choose explicitly which
325/// projection (or combination of projections) they want.
326#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
327pub enum MultiAngleMode {
328    /// Reject files with more than one rotation angle with a clear
329    /// [`IoError::InvalidParameter`].  Single-angle files (`n_rot == 1`)
330    /// load normally.  This is the default — it prevents silent data
331    /// loss for callers that aren't multi-angle-aware.
332    #[default]
333    Error,
334    /// Sum across all rotation angles into a single `(tof, y, x)`
335    /// volume.  This is the legacy auto-sum behaviour, preserved as an
336    /// **explicit opt-in** so that callers can't invoke it by
337    /// accident.  Multi-angle analysis information is irreversibly
338    /// lost on this path.
339    Sum,
340    /// Extract a single rotation angle by index.  Returns an error if
341    /// the index is out of range.
342    SelectAngle(usize),
343}
344
345/// Load histogram data from a NeXus file, refusing multi-angle inputs.
346///
347/// Reads `/entry/histogram/counts` (u64 4D), converts to f64, and
348/// transposes the chosen single-angle slice to NEREIDS convention
349/// `(tof, y, x)`.  TOF values are converted from nanoseconds to
350/// microseconds.
351///
352/// If the file has more than one rotation angle (`n_rot > 1`), the
353/// call returns [`IoError::InvalidParameter`] pointing at
354/// [`load_nexus_histogram_with_mode`] — silent sum-over-angles
355/// was the pre-#430 behaviour and has been removed because it lost
356/// projection-resolved information without the caller's knowledge.
357///
358/// Single-angle files (`n_rot == 1`) load normally and reach the same
359/// output as before #430.
360pub fn load_nexus_histogram(path: &Path) -> Result<NexusHistogramData, IoError> {
361    load_nexus_histogram_with_mode(path, MultiAngleMode::Error)
362}
363
364/// Load histogram data from a NeXus file with an explicit multi-angle
365/// handling policy.  See [`MultiAngleMode`] for the options.
366///
367/// This is the explicit-opt-in variant behind
368/// [`load_nexus_histogram`].  Use it when you know the file may have
369/// multiple rotation angles and you have made a deliberate choice
370/// about how to combine them.
371pub fn load_nexus_histogram_with_mode(
372    path: &Path,
373    mode: MultiAngleMode,
374) -> Result<NexusHistogramData, IoError> {
375    let file = hdf5::File::open(path).map_err(|e| {
376        IoError::FileNotFound(
377            path.display().to_string(),
378            std::io::Error::other(e.to_string()),
379        )
380    })?;
381
382    let entry = file
383        .group("entry")
384        .map_err(|e| IoError::InvalidParameter(format!("Missing /entry group: {e}")))?;
385
386    let hist_group = entry
387        .group("histogram")
388        .map_err(|e| IoError::InvalidParameter(format!("Missing /entry/histogram group: {e}")))?;
389
390    // Read counts: u64 4D [rot_angle, y, x, tof]
391    let counts_ds = hist_group.dataset("counts").map_err(|e| {
392        IoError::InvalidParameter(format!("Missing /entry/histogram/counts dataset: {e}"))
393    })?;
394
395    let shape = counts_ds.shape();
396    if shape.len() != 4 {
397        return Err(IoError::ShapeMismatch(format!(
398            "Expected 4D histogram counts, got {}D",
399            shape.len()
400        )));
401    }
402
403    // Validate the rotation-angle policy BEFORE reading the full 4D
404    // counts dataset: the check is purely metadata-driven and the
405    // rejection paths should be cheap.  Reading the full u64 cube just
406    // to error out is wasteful on production multi-angle NeXus files
407    // (easily multi-GB), and historically caused OOM-before-error on
408    // the default "refuse" code path.
409    let n_rot = shape[0];
410    if n_rot == 0 {
411        // Degenerate file with a zero-sized rotation-angle axis.
412        // Reject rather than produce an all-zero output (which would
413        // look like a valid-but-empty measurement).
414        return Err(IoError::InvalidParameter(
415            "NeXus histogram has zero rotation angles; /entry/histogram/counts axis 0 must \
416             be >= 1"
417                .into(),
418        ));
419    }
420    // Mirror the rotation-angle guard for the sibling y / x / tof axes
421    // (counts layout is [rot_angle, y, x, tof]).  A zero-sized y, x, or tof
422    // axis is just as degenerate: it would produce an empty detector plane or
423    // an empty energy series that looks like a valid-but-empty measurement
424    // downstream instead of a clear load error.
425    for (axis, name) in [(1usize, "y"), (2, "x"), (3, "tof")] {
426        if shape[axis] == 0 {
427            return Err(IoError::InvalidParameter(format!(
428                "NeXus histogram has a zero-sized {name} axis; \
429                 /entry/histogram/counts axis {axis} must be >= 1 (shape {shape:?})"
430            )));
431        }
432    }
433    match mode {
434        MultiAngleMode::Error if n_rot > 1 => {
435            return Err(IoError::InvalidParameter(format!(
436                "NeXus histogram has {n_rot} rotation angles — refusing to silently \
437                 combine them (issue #430).  Call load_nexus_histogram_with_mode with \
438                 MultiAngleMode::Sum to preserve the legacy sum-over-angles behaviour, \
439                 or MultiAngleMode::SelectAngle(i) to extract a single projection."
440            )));
441        }
442        MultiAngleMode::SelectAngle(idx) if idx >= n_rot => {
443            return Err(IoError::InvalidParameter(format!(
444                "MultiAngleMode::SelectAngle({idx}) out of range: file has {n_rot} \
445                 rotation angle(s); valid indices are 0..{n_rot} (exclusive, i.e. \
446                 last valid index is {last})",
447                last = n_rot - 1
448            )));
449        }
450        _ => {}
451    }
452
453    // Read only the rotation-angle slice(s) the caller actually needs.
454    // Reading the full 4D cube when the caller wants one projection is
455    // wasteful on production multi-angle files (multi-GB per
456    // acquisition).
457    //
458    // - `Error` is guaranteed to have `n_rot == 1` (validated above),
459    //   so we hyperslab-read the single projection.
460    // - `Sum` on a single-angle file is identity with `Error`.
461    // - `Sum` on a multi-angle file needs every angle; the full read
462    //   is unavoidable and the legacy opt-in carries its memory cost.
463    // - `SelectAngle(idx)` hyperslab-reads only the selected
464    //   projection — the other angles' bytes never enter memory.
465    //
466    // All paths produce a `[y, x, tof]` 3D `u64` array ready for the
467    // f64 conversion + transpose below.
468    let combined_yxtof: ndarray::Array3<u64> = match mode {
469        MultiAngleMode::Error | MultiAngleMode::Sum if n_rot == 1 => {
470            counts_ds.read_slice(s![0, .., .., ..]).map_err(|e| {
471                IoError::InvalidParameter(format!("Failed to read single-angle slice: {e}"))
472            })?
473        }
474        MultiAngleMode::Sum => {
475            let full: ndarray::Array4<u64> = counts_ds.read().map_err(|e| {
476                IoError::InvalidParameter(format!("Failed to read histogram counts: {e}"))
477            })?;
478            full.sum_axis(ndarray::Axis(0))
479        }
480        MultiAngleMode::SelectAngle(idx) => {
481            counts_ds.read_slice(s![idx, .., .., ..]).map_err(|e| {
482                IoError::InvalidParameter(format!("Failed to read selected-angle slice: {e}"))
483            })?
484        }
485        MultiAngleMode::Error => {
486            // Unreachable: n_rot > 1 was rejected above, n_rot == 1 is
487            // matched by the first arm, n_rot == 0 was rejected earlier.
488            unreachable!("Error mode reached with n_rot = {n_rot}")
489        }
490    };
491
492    // Convert to f64 and transpose [y, x, tof] → NEREIDS convention [tof, y, x]
493    let counts_f64: Array3<f64> = combined_yxtof
494        .mapv(|v| v as f64)
495        .permuted_axes([2, 0, 1])
496        .as_standard_layout()
497        .into_owned();
498    let n_tof = counts_f64.shape()[0];
499
500    // Read TOF axis (nanoseconds → microseconds)
501    let tof_edges_us = read_tof_axis(&hist_group)?;
502
503    // Validate TOF edges count against histogram TOF dimension
504    if tof_edges_us.len() != n_tof + 1 && tof_edges_us.len() != n_tof {
505        return Err(IoError::InvalidParameter(format!(
506            "TOF axis length {} is incompatible with {} histogram bins (expected {} or {})",
507            tof_edges_us.len(),
508            n_tof,
509            n_tof,
510            n_tof + 1
511        )));
512    }
513
514    // Read flight path
515    let flight_path_m = read_f64_attr(&hist_group, "flight_path_m")
516        .or_else(|| read_f64_attr(&entry, "flight_path_m"));
517
518    // Read dead pixel mask, validated against the detector's spatial dims.
519    // counts_f64 is [tof, y, x], so (height, width) = (shape[1], shape[2]).
520    let dead_pixels = read_dead_pixel_mask(&entry, (counts_f64.shape()[1], counts_f64.shape()[2]))?;
521
522    Ok(NexusHistogramData {
523        counts: counts_f64,
524        tof_edges_us,
525        flight_path_m,
526        dead_pixels,
527        n_rotation_angles: n_rot,
528        event_stats: None, // histogram mode, not events
529    })
530}
531
532/// Parameters for histogramming neutron event data into a 3D grid.
533#[derive(Debug, Clone, PartialEq)]
534pub struct EventBinningParams {
535    /// Number of TOF bins.
536    pub n_bins: usize,
537    /// Minimum TOF in microseconds.
538    pub tof_min_us: f64,
539    /// Maximum TOF in microseconds.
540    pub tof_max_us: f64,
541    /// Detector height in pixels.
542    pub height: usize,
543    /// Detector width in pixels.
544    pub width: usize,
545}
546
547/// Load neutron event data from a NeXus file and histogram into a 3D grid.
548///
549/// Reads `/entry/neutrons/event_time_offset` (u64), `x` (f64), `y` (f64),
550/// rescales TOF to the canonical internal unit of microseconds based on
551/// the `event_time_offset` dataset's `units` attribute (issue #554), then
552/// bins events into a `(n_bins, height, width)` histogram grid.
553///
554/// # TOF units handling (issue #554)
555///
556/// The loader consults the NeXus `units` attribute on the
557/// `event_time_offset` dataset and rescales the raw `u64` channel
558/// counts to µs accordingly.  See the module-level "Units convention"
559/// table for the recognised values.  If the `units` attribute is
560/// absent, the loader falls back to the rustpix legacy assumption of
561/// nanoseconds (`× 1e-3`); if it is present but unrecognised, the
562/// call returns [`IoError::InvalidParameter`] rather than silently
563/// guessing a scale factor.
564///
565/// # Binning behaviour (D-8)
566///
567/// - **Out-of-range events are dropped and counted**: events with TOF outside
568///   `[tof_min_us, tof_max_us)`, pixel coordinates outside `[0, width)` /
569///   `[0, height)`, or non-finite spatial coordinates are excluded. Per-category
570///   drop counts are returned in [`EventRetentionStats`] via
571///   [`NexusHistogramData::event_stats`].
572/// - **Pixel coordinates are rounded to the nearest integer** (`f64::round()`
573///   then cast to `isize`), snapping sub-pixel positions to a discrete grid.
574///   Fractional coordinates exactly at 0.5 round up.
575pub fn load_nexus_events(
576    path: &Path,
577    params: &EventBinningParams,
578) -> Result<NexusHistogramData, IoError> {
579    if params.n_bins == 0 {
580        return Err(IoError::InvalidParameter("n_bins must be positive".into()));
581    }
582    if params.height == 0 || params.width == 0 {
583        return Err(IoError::InvalidParameter(
584            "height and width must be positive".into(),
585        ));
586    }
587    if !params.tof_min_us.is_finite() || !params.tof_max_us.is_finite() {
588        return Err(IoError::InvalidParameter(
589            "TOF bounds must be finite".into(),
590        ));
591    }
592    if params.tof_max_us <= params.tof_min_us {
593        return Err(IoError::InvalidParameter(format!(
594            "tof_max_us ({}) must be greater than tof_min_us ({})",
595            params.tof_max_us, params.tof_min_us
596        )));
597    }
598
599    let file = hdf5::File::open(path).map_err(|e| {
600        IoError::FileNotFound(
601            path.display().to_string(),
602            std::io::Error::other(e.to_string()),
603        )
604    })?;
605
606    let entry = file
607        .group("entry")
608        .map_err(|e| IoError::InvalidParameter(format!("Missing /entry group: {e}")))?;
609
610    let neutrons = entry
611        .group("neutrons")
612        .map_err(|e| IoError::InvalidParameter(format!("Missing /entry/neutrons group: {e}")))?;
613
614    // Read event arrays.  Open the dataset first so we can consult its
615    // `units` attribute (issue #554) before reading the data.
616    let tof_ds = neutrons.dataset("event_time_offset").map_err(|e| {
617        IoError::InvalidParameter(format!("Missing event_time_offset dataset: {e}"))
618    })?;
619    let tof_units = read_string_attr(&tof_ds, "units")?;
620    let tof_scale = tof_scale_to_us(tof_units.as_deref())?;
621    let tof_raw: Vec<u64> = tof_ds
622        .read_1d()
623        .map_err(|e| IoError::InvalidParameter(format!("Failed to read event_time_offset: {e}")))?
624        .to_vec();
625
626    let x_coords: Vec<f64> = neutrons
627        .dataset("x")
628        .map_err(|e| IoError::InvalidParameter(format!("Missing x dataset: {e}")))?
629        .read_1d()
630        .map_err(|e| IoError::InvalidParameter(format!("Failed to read x: {e}")))?
631        .to_vec();
632
633    let y_coords: Vec<f64> = neutrons
634        .dataset("y")
635        .map_err(|e| IoError::InvalidParameter(format!("Missing y dataset: {e}")))?
636        .read_1d()
637        .map_err(|e| IoError::InvalidParameter(format!("Failed to read y: {e}")))?
638        .to_vec();
639
640    if tof_raw.len() != x_coords.len() || tof_raw.len() != y_coords.len() {
641        return Err(IoError::ShapeMismatch(format!(
642            "Event arrays have mismatched lengths: tof={}, x={}, y={}",
643            tof_raw.len(),
644            x_coords.len(),
645            y_coords.len()
646        )));
647    }
648
649    // Generate linear TOF bin edges
650    let tof_edges_us =
651        crate::tof::linspace_tof_edges(params.tof_min_us, params.tof_max_us, params.n_bins)?;
652
653    // Histogram events with retention tracking.
654    let dt_us = (params.tof_max_us - params.tof_min_us) / params.n_bins as f64;
655    let mut counts = Array3::<f64>::zeros((params.n_bins, params.height, params.width));
656    let total = tof_raw.len();
657    let mut kept = 0usize;
658    let mut dropped_non_finite = 0usize;
659    let mut dropped_tof_range = 0usize;
660    let mut dropped_spatial = 0usize;
661
662    for i in 0..tof_raw.len() {
663        // Convert raw TOF to canonical µs via the units-attribute scale
664        // factor (issue #554).  For the default rustpix case (`units`
665        // absent → ns assumed), `tof_scale` is `1e-3`, recovering the
666        // pre-fix expression `tof_raw[i] / 1000.0`.
667        let tof_us = tof_raw[i] as f64 * tof_scale;
668        if !tof_us.is_finite() {
669            dropped_non_finite += 1;
670            continue;
671        }
672
673        if tof_us < params.tof_min_us || tof_us >= params.tof_max_us {
674            dropped_tof_range += 1;
675            continue;
676        }
677
678        let xf = x_coords[i];
679        let yf = y_coords[i];
680        if !xf.is_finite() || !yf.is_finite() {
681            dropped_non_finite += 1;
682            continue;
683        }
684        let px = xf.round() as isize;
685        let py = yf.round() as isize;
686
687        if px < 0 || py < 0 || px >= params.width as isize || py >= params.height as isize {
688            dropped_spatial += 1;
689            continue;
690        }
691
692        let tof_bin = ((tof_us - params.tof_min_us) / dt_us) as usize;
693        let tof_bin = tof_bin.min(params.n_bins - 1);
694        counts[[tof_bin, py as usize, px as usize]] += 1.0;
695        kept += 1;
696    }
697
698    // Read flight path
699    let flight_path_m = read_f64_attr(&neutrons, "flight_path_m")
700        .or_else(|| read_f64_attr(&entry, "flight_path_m"));
701
702    // Read dead pixel mask, validated against the requested detector dims.
703    let dead_pixels = read_dead_pixel_mask(&entry, (params.height, params.width))?;
704
705    debug_assert_eq!(
706        total,
707        kept + dropped_non_finite + dropped_tof_range + dropped_spatial,
708        "event retention accounting mismatch"
709    );
710
711    Ok(NexusHistogramData {
712        counts,
713        tof_edges_us,
714        flight_path_m,
715        dead_pixels,
716        n_rotation_angles: 1,
717        event_stats: Some(EventRetentionStats {
718            total,
719            kept,
720            dropped_non_finite,
721            dropped_tof_range,
722            dropped_spatial,
723        }),
724    })
725}
726
727// ---- Internal helpers ----
728
729/// Probe the histogram group for shape and TOF axis without loading counts.
730///
731/// The returned TOF edges are in **microseconds**, rescaled from the
732/// dataset's NeXus `units` attribute via [`tof_scale_to_us`] — the
733/// same logic the full [`load_nexus_histogram`] uses.  If the `units`
734/// attribute is unparseable, the TOF axis is dropped entirely
735/// (returned as `None`) rather than silently propagated at the wrong
736/// scale, matching the function's "any failure → no data for that
737/// field" contract.  The previous implementation returned the raw
738/// values verbatim — a silent 1000× error for any file written with
739/// `units = "us"`, symmetric with the load-path bug closed by issue
740/// #554.
741fn probe_histogram_group(entry: &hdf5::Group) -> (bool, Option<[usize; 4]>, Option<Vec<f64>>) {
742    let hist = match entry.group("histogram") {
743        Ok(g) => g,
744        Err(_) => return (false, None, None),
745    };
746
747    let counts = match hist.dataset("counts") {
748        Ok(ds) => ds,
749        Err(_) => return (false, None, None),
750    };
751
752    let shape = counts.shape();
753    if shape.len() != 4 {
754        return (false, None, None);
755    }
756
757    let histogram_shape = Some([shape[0], shape[1], shape[2], shape[3]]);
758
759    // Try reading TOF axis and rescaling to µs via the `units`
760    // attribute.  Any failure (missing dataset, read error,
761    // unparseable units attr) collapses to `None` — the probe is
762    // best-effort and must never poison the rest of the metadata.
763    let tof_edges_us = hist.dataset("time_of_flight").ok().and_then(|ds| {
764        let raw = ds.read_1d::<f64>().ok()?.to_vec();
765        // `read_string_attr` returns Ok(None) for absent and Err for
766        // genuine HDF5 failures; either should propagate to "no TOF
767        // axis" rather than fall through to the wrong-scale raw
768        // values.
769        let units = read_string_attr(&ds, "units").ok()?;
770        let scale = tof_scale_to_us(units.as_deref()).ok()?;
771        Some(raw.into_iter().map(|v| v * scale).collect())
772    });
773
774    (true, histogram_shape, tof_edges_us)
775}
776
777/// Probe the neutron event group for event count.
778fn probe_event_group(entry: &hdf5::Group) -> (bool, Option<usize>) {
779    let neutrons = match entry.group("neutrons") {
780        Ok(g) => g,
781        Err(_) => return (false, None),
782    };
783
784    let n_events = neutrons
785        .dataset("event_time_offset")
786        .ok()
787        .map(|ds| ds.shape().first().copied().unwrap_or(0));
788
789    (n_events.is_some(), n_events)
790}
791
792/// Read TOF axis from the histogram group, rescaling to µs based on
793/// the dataset's `units` attribute (see module docs / issue #554).
794fn read_tof_axis(hist_group: &hdf5::Group) -> Result<Vec<f64>, IoError> {
795    let tof_ds = hist_group.dataset("time_of_flight").map_err(|e| {
796        IoError::InvalidParameter(format!(
797            "Missing /entry/histogram/time_of_flight dataset: {e}"
798        ))
799    })?;
800
801    let raw: Vec<f64> = tof_ds
802        .read_1d::<f64>()
803        .map_err(|e| IoError::InvalidParameter(format!("Failed to read time_of_flight: {e}")))?
804        .to_vec();
805
806    // Consult the dataset's NeXus `units` attribute.  Missing →
807    // legacy nanoseconds assumption (rustpix); known value → use
808    // table; unknown value → hard error (no silent mis-scale).
809    let units = read_string_attr(&tof_ds, "units")?;
810    let scale = tof_scale_to_us(units.as_deref())?;
811
812    let edges: Vec<f64> = raw.iter().map(|&v| v * scale).collect();
813
814    // Validate the TOF axis is finite, strictly positive, and strictly
815    // increasing, mirroring the spectrum-file load path (`guided::load` runs
816    // `validate_monotonic` on the parsed spectrum before use).  A non-finite,
817    // non-positive, or non-increasing TOF edge produces a `tof_to_energy` NaN /
818    // negative-energy downstream; reject it here at the I/O boundary instead.
819    //
820    // `validate_monotonic` alone is *not* sufficient for the finite/positive
821    // half: a trailing `+∞` satisfies `prev < +∞` (so monotonicity passes), a
822    // single-edge axis never enters `windows(2)` at all, and `first <= 0.0` is
823    // bypassed by `NaN` (`NaN <= 0.0` is `false`).  Check every scaled edge
824    // explicitly with `is_finite() && > 0.0` (the `is_finite()` half is what
825    // catches `NaN` / `±∞`, which order comparisons silently pass), then defer
826    // the strictly-increasing requirement to `validate_monotonic`.
827    for (i, &edge) in edges.iter().enumerate() {
828        if !edge.is_finite() || edge <= 0.0 {
829            return Err(IoError::InvalidParameter(format!(
830                "NeXus TOF axis edge {i} must be finite and positive, got {edge}"
831            )));
832        }
833    }
834    crate::spectrum::validate_monotonic(&edges)?;
835
836    Ok(edges)
837}
838
839/// Read a scalar f64 attribute from a group.
840fn read_f64_attr(group: &hdf5::Group, name: &str) -> Option<f64> {
841    group
842        .attr(name)
843        .ok()
844        .and_then(|a| a.read_scalar::<f64>().ok())
845}
846
847/// Read the dead-pixel mask from `/entry/pixel_masks/dead`, validating its
848/// shape against the detector's `(height, width)`.
849///
850/// Returns `Ok(None)` when the mask group / dataset is simply *absent* (a file
851/// without a dead-pixel mask is valid).  Returns `Err` when the mask is
852/// *present but malformed*:
853/// * `pixel_masks` exists but is not a group, or `dead` exists but is not a
854///   readable dataset — surfaced as `InvalidParameter` rather than silently
855///   treated as absence (a malformed mask is an upstream-writer bug, and
856///   silently dropping it would mask the wrong pixels or none at all);
857/// * the mask shape does not match the counts' spatial dimensions — surfaced
858///   as `ShapeMismatch`.
859///
860/// Absence vs malformed is decided by link existence (`member_names`), not by
861/// whether `group()` / `dataset()` *succeed*: those collapse "the link is not
862/// there" and "the link is there but the wrong object kind / unreadable" into
863/// the same `Err`, which would otherwise mask real corruption as absence.
864fn read_dead_pixel_mask(
865    entry: &hdf5::Group,
866    expected_hw: (usize, usize),
867) -> Result<Option<ndarray::Array2<bool>>, IoError> {
868    // `pixel_masks` link absent → no mask (valid file).
869    let entry_members = entry
870        .member_names()
871        .map_err(|e| IoError::InvalidParameter(format!("Failed to list /entry members: {e}")))?;
872    if !entry_members.iter().any(|n| n == "pixel_masks") {
873        return Ok(None);
874    }
875    // Link present but not openable as a group → malformed, not absent.
876    let masks = entry.group("pixel_masks").map_err(|e| {
877        IoError::InvalidParameter(format!(
878            "/entry/pixel_masks is present but is not a readable group: {e}"
879        ))
880    })?;
881
882    // `dead` link absent → no mask (valid file).
883    let mask_members = masks.member_names().map_err(|e| {
884        IoError::InvalidParameter(format!("Failed to list /entry/pixel_masks members: {e}"))
885    })?;
886    if !mask_members.iter().any(|n| n == "dead") {
887        return Ok(None);
888    }
889    // Link present but not openable as a dataset → malformed, not absent.
890    let dead_ds = masks.dataset("dead").map_err(|e| {
891        IoError::InvalidParameter(format!(
892            "/entry/pixel_masks/dead is present but is not a readable dataset: {e}"
893        ))
894    })?;
895    let dead_u8: ndarray::Array2<u8> = dead_ds.read().map_err(|e| {
896        IoError::InvalidParameter(format!("Failed to read /entry/pixel_masks/dead: {e}"))
897    })?;
898    let (eh, ew) = expected_hw;
899    if dead_u8.dim() != (eh, ew) {
900        return Err(IoError::ShapeMismatch(format!(
901            "dead-pixel mask shape {:?} != detector spatial dimensions ({eh}, {ew})",
902            dead_u8.dim(),
903        )));
904    }
905    Ok(Some(dead_u8.mapv(|v| v != 0)))
906}
907
908/// List the group/dataset tree structure of an HDF5 file.
909///
910/// Walks the file hierarchy recursively up to `max_depth` levels deep,
911/// returning entries with their path, kind (group vs dataset), and shape
912/// (for datasets).  Useful for displaying file structure in a GUI browser.
913pub fn list_hdf5_tree(path: &Path, max_depth: usize) -> Result<Vec<Hdf5TreeEntry>, IoError> {
914    let file = hdf5::File::open(path)
915        .map_err(|e| IoError::Hdf5Error(format!("Cannot open HDF5 file: {e}")))?;
916    let mut entries = Vec::new();
917    walk_group(
918        &file
919            .as_group()
920            .map_err(|e| IoError::Hdf5Error(format!("Cannot read root group: {e}")))?,
921        "/",
922        0,
923        max_depth,
924        &mut entries,
925    );
926    Ok(entries)
927}
928
929/// Recursively walk an HDF5 group, collecting tree entries.
930fn walk_group(
931    group: &hdf5::Group,
932    prefix: &str,
933    depth: usize,
934    max_depth: usize,
935    entries: &mut Vec<Hdf5TreeEntry>,
936) {
937    let Ok(members) = group.member_names() else {
938        return;
939    };
940    let mut members = members;
941    members.sort();
942    for name in &members {
943        let child_path = if prefix == "/" {
944            format!("/{name}")
945        } else {
946            format!("{prefix}/{name}")
947        };
948
949        // Try dataset first (leaf nodes)
950        if let Ok(ds) = group.dataset(name) {
951            let shape = ds.shape();
952            entries.push(Hdf5TreeEntry {
953                path: child_path,
954                kind: Hdf5EntryKind::Dataset,
955                shape: Some(shape),
956            });
957        } else if let Ok(child_group) = group.group(name) {
958            // It's a group — record it and recurse if within depth
959            entries.push(Hdf5TreeEntry {
960                path: child_path.clone(),
961                kind: Hdf5EntryKind::Group,
962                shape: None,
963            });
964            if depth < max_depth {
965                walk_group(&child_group, &child_path, depth + 1, max_depth, entries);
966            }
967        }
968    }
969}
970
971#[cfg(test)]
972mod tests {
973    use super::*;
974
975    /// Create a minimal NeXus HDF5 file with histogram data for testing.
976    fn create_test_histogram(
977        path: &Path,
978        counts: &[u64],
979        shape: [usize; 4],
980        tof_ns: &[f64],
981        flight_path_m: Option<f64>,
982    ) {
983        let file = hdf5::File::create(path).expect("create test file");
984        let entry = file.create_group("entry").expect("create entry");
985
986        if let Some(fp) = flight_path_m {
987            entry
988                .new_attr::<f64>()
989                .shape(())
990                .create("flight_path_m")
991                .expect("create attr")
992                .write_scalar(&fp)
993                .expect("write attr");
994        }
995
996        let hist = entry.create_group("histogram").expect("create histogram");
997        hist.new_dataset::<u64>()
998            .shape(shape)
999            .create("counts")
1000            .expect("create counts")
1001            .write_raw(counts)
1002            .expect("write counts");
1003
1004        hist.new_dataset::<f64>()
1005            .shape([tof_ns.len()])
1006            .create("time_of_flight")
1007            .expect("create tof")
1008            .write_raw(tof_ns)
1009            .expect("write tof");
1010    }
1011
1012    #[test]
1013    fn test_probe_nexus_histogram() {
1014        let dir = tempfile::tempdir().unwrap();
1015        let path = dir.path().join("test.h5");
1016
1017        // 1 rot angle, 2x3 spatial, 4 TOF bins → shape [1, 2, 3, 4]
1018        let counts = vec![0u64; 24];
1019        let tof_ns = vec![1000.0, 2000.0, 3000.0, 4000.0, 5000.0]; // 5 edges for 4 bins
1020        create_test_histogram(&path, &counts, [1, 2, 3, 4], &tof_ns, Some(25.0));
1021
1022        let meta = probe_nexus(&path).unwrap();
1023        assert!(meta.has_histogram);
1024        assert!(!meta.has_events);
1025        assert_eq!(meta.histogram_shape, Some([1, 2, 3, 4]));
1026        assert_eq!(meta.flight_path_m, Some(25.0));
1027        // No `units` attribute on this fixture → rustpix legacy-ns
1028        // assumption, so the probe rescales 1000/2000/.../5000 ns
1029        // into 1/2/.../5 µs (× 1e-3).
1030        let edges = meta.tof_edges_us.expect("probe should return TOF edges");
1031        assert_eq!(edges.len(), 5);
1032        for (i, &expected_us) in [1.0_f64, 2.0, 3.0, 4.0, 5.0].iter().enumerate() {
1033            assert!(
1034                (edges[i] - expected_us).abs() < 1e-12,
1035                "edge {i}: expected {expected_us} µs, got {} µs",
1036                edges[i]
1037            );
1038        }
1039    }
1040
1041    /// `probe_nexus` must respect the `units`
1042    /// attribute on `time_of_flight` the same way `load_nexus_histogram`
1043    /// does.  A file written with `units = "us"` must surface µs
1044    /// values verbatim through the probe (no 1000× silent rescale).
1045    #[test]
1046    fn test_probe_nexus_histogram_units_us_no_rescale() {
1047        let dir = tempfile::tempdir().unwrap();
1048        let path = dir.path().join("probe_units_us.h5");
1049
1050        let counts = vec![0u64; 4];
1051        // Values that would be wrong by 1000× if treated as ns.
1052        let tof_us = vec![1000.0, 2000.0, 3000.0, 4000.0, 5000.0];
1053        create_test_histogram_with_units(&path, &counts, [1, 1, 1, 4], &tof_us, Some("us"));
1054
1055        let meta = probe_nexus(&path).expect("probe with units=us");
1056        let edges = meta.tof_edges_us.expect("TOF axis should be present");
1057        assert_eq!(edges.len(), 5);
1058        for (i, &expected_us) in tof_us.iter().enumerate() {
1059            assert!(
1060                (edges[i] - expected_us).abs() < 1e-9,
1061                "probe edge {i}: expected {expected_us} µs (no rescale), got {} µs",
1062                edges[i]
1063            );
1064        }
1065    }
1066
1067    #[test]
1068    fn test_load_nexus_histogram_single_angle() {
1069        let dir = tempfile::tempdir().unwrap();
1070        let path = dir.path().join("test.h5");
1071
1072        // 1 rot angle, 2x3 spatial, 2 TOF bins
1073        let mut counts = vec![0u64; 2 * 3 * 2];
1074        counts[0] = 15; // rot=0, y=0, x=0, tof=0
1075
1076        let tof_ns = vec![1000.0, 2000.0, 3000.0]; // 3 edges for 2 bins
1077        create_test_histogram(&path, &counts, [1, 2, 3, 2], &tof_ns, Some(25.0));
1078
1079        let data = load_nexus_histogram(&path).unwrap();
1080
1081        // Shape should be (n_tof=2, n_y=2, n_x=3) after transposing
1082        assert_eq!(data.counts.shape(), &[2, 2, 3]);
1083        // Single angle: value is preserved exactly
1084        assert_eq!(data.counts[[0, 0, 0]], 15.0);
1085
1086        // TOF edges converted ns → µs
1087        assert_eq!(data.tof_edges_us.len(), 3);
1088        assert!((data.tof_edges_us[0] - 1.0).abs() < 1e-10);
1089        assert!((data.tof_edges_us[1] - 2.0).abs() < 1e-10);
1090        assert!((data.tof_edges_us[2] - 3.0).abs() < 1e-10);
1091        assert_eq!(data.flight_path_m, Some(25.0));
1092        assert_eq!(data.n_rotation_angles, 1);
1093    }
1094
1095    /// Issue #430: default `load_nexus_histogram` must refuse multi-angle
1096    /// files rather than silently collapse the rotation dimension.
1097    #[test]
1098    fn test_load_nexus_histogram_multi_angle_errors_by_default() {
1099        let dir = tempfile::tempdir().unwrap();
1100        let path = dir.path().join("multi_angle.h5");
1101
1102        let counts = vec![1u64; 2 * 2 * 3 * 2];
1103        let tof_ns = vec![1000.0, 2000.0, 3000.0];
1104        create_test_histogram(&path, &counts, [2, 2, 3, 2], &tof_ns, Some(25.0));
1105
1106        let err = load_nexus_histogram(&path)
1107            .expect_err("multi-angle file must be rejected by the default loader");
1108        let msg = err.to_string();
1109        assert!(
1110            msg.contains("2 rotation angles") && msg.contains("#430"),
1111            "error message should name the angle count and reference #430, got: {msg}"
1112        );
1113        assert!(
1114            msg.contains("MultiAngleMode::Sum") && msg.contains("MultiAngleMode::SelectAngle"),
1115            "error message should point at the explicit-opt-in APIs, got: {msg}"
1116        );
1117    }
1118
1119    /// Issue #430: `MultiAngleMode::Sum` is the explicit opt-in for the
1120    /// legacy auto-sum behaviour.  Recovers the pre-#430 output exactly.
1121    #[test]
1122    fn test_load_nexus_histogram_multi_angle_sum_mode() {
1123        let dir = tempfile::tempdir().unwrap();
1124        let path = dir.path().join("multi_angle_sum.h5");
1125
1126        let mut counts = vec![0u64; 2 * 2 * 3 * 2];
1127        counts[0] = 10; // rot=0, y=0, x=0, tof=0
1128        counts[12] = 5; // rot=1, y=0, x=0, tof=0
1129        let tof_ns = vec![1000.0, 2000.0, 3000.0];
1130        create_test_histogram(&path, &counts, [2, 2, 3, 2], &tof_ns, Some(25.0));
1131
1132        let data = load_nexus_histogram_with_mode(&path, MultiAngleMode::Sum).unwrap();
1133        assert_eq!(data.counts.shape(), &[2, 2, 3]);
1134        // Summed: 10 + 5 = 15
1135        assert_eq!(data.counts[[0, 0, 0]], 15.0);
1136        assert_eq!(data.n_rotation_angles, 2);
1137    }
1138
1139    /// Issue #430: `MultiAngleMode::SelectAngle(i)` extracts a single
1140    /// projection by index, leaving the other angles' data unread.
1141    #[test]
1142    fn test_load_nexus_histogram_multi_angle_select_mode() {
1143        let dir = tempfile::tempdir().unwrap();
1144        let path = dir.path().join("multi_angle_select.h5");
1145
1146        let mut counts = vec![0u64; 3 * 2 * 3 * 2];
1147        counts[0] = 100; // rot=0, y=0, x=0, tof=0
1148        counts[12] = 200; // rot=1, y=0, x=0, tof=0
1149        counts[24] = 300; // rot=2, y=0, x=0, tof=0
1150        let tof_ns = vec![1000.0, 2000.0, 3000.0];
1151        create_test_histogram(&path, &counts, [3, 2, 3, 2], &tof_ns, Some(25.0));
1152
1153        // Select angle 1 — should see 200, not 100 / 300 / 600.
1154        let data = load_nexus_histogram_with_mode(&path, MultiAngleMode::SelectAngle(1)).unwrap();
1155        assert_eq!(data.counts[[0, 0, 0]], 200.0);
1156        assert_eq!(data.n_rotation_angles, 3);
1157
1158        // Out-of-range index → error.
1159        let err = load_nexus_histogram_with_mode(&path, MultiAngleMode::SelectAngle(3))
1160            .expect_err("out-of-range angle index must error");
1161        let msg = err.to_string();
1162        assert!(
1163            msg.contains("SelectAngle(3)") && msg.contains("3 rotation angle"),
1164            "error should name the bad index and the actual count, got: {msg}"
1165        );
1166    }
1167
1168    /// `MultiAngleMode::Error` on a single-angle file is a no-op:
1169    /// `n_rot == 1` is the trivial non-collapsing case.  All three
1170    /// modes must produce identical output here.
1171    #[test]
1172    fn test_load_nexus_histogram_single_angle_mode_parity() {
1173        let dir = tempfile::tempdir().unwrap();
1174        let path = dir.path().join("single_parity.h5");
1175        let counts = vec![7u64; 2 * 3 * 2];
1176        let tof_ns = vec![1000.0, 2000.0, 3000.0];
1177        create_test_histogram(&path, &counts, [1, 2, 3, 2], &tof_ns, None);
1178
1179        let d_err = load_nexus_histogram_with_mode(&path, MultiAngleMode::Error).unwrap();
1180        let d_sum = load_nexus_histogram_with_mode(&path, MultiAngleMode::Sum).unwrap();
1181        let d_sel = load_nexus_histogram_with_mode(&path, MultiAngleMode::SelectAngle(0)).unwrap();
1182        // All three modes produce the same output on a single-angle file.
1183        assert_eq!(d_err.counts, d_sum.counts);
1184        assert_eq!(d_err.counts, d_sel.counts);
1185        // Value preserved (not doubled — single angle).
1186        assert_eq!(d_err.counts[[0, 0, 0]], 7.0);
1187        assert_eq!(d_err.n_rotation_angles, 1);
1188    }
1189
1190    /// A zero-angle file (degenerate, `shape[0] == 0`) must be
1191    /// rejected on every mode — otherwise `Sum` would produce an
1192    /// all-zero output indistinguishable from a valid but dark
1193    /// measurement, and `Error` would silently accept the degenerate
1194    /// file.
1195    #[test]
1196    fn test_load_nexus_histogram_zero_angles_rejected() {
1197        let dir = tempfile::tempdir().unwrap();
1198        let path = dir.path().join("zero_angles.h5");
1199        let counts: Vec<u64> = Vec::new();
1200        let tof_ns = vec![1000.0, 2000.0, 3000.0];
1201        create_test_histogram(&path, &counts, [0, 2, 3, 2], &tof_ns, None);
1202
1203        for mode in [
1204            MultiAngleMode::Error,
1205            MultiAngleMode::Sum,
1206            MultiAngleMode::SelectAngle(0),
1207        ] {
1208            let err = load_nexus_histogram_with_mode(&path, mode).unwrap_err();
1209            let msg = err.to_string();
1210            assert!(
1211                msg.contains("zero rotation angles"),
1212                "mode {mode:?} zero-angle rejection should name the axis, got: {msg}"
1213            );
1214        }
1215    }
1216
1217    /// A zero-sized y / x / tof axis is just as degenerate as a zero-angle
1218    /// axis and must be rejected the same way, rather than producing an empty
1219    /// detector plane / energy series downstream.
1220    #[test]
1221    fn test_load_nexus_histogram_zero_sibling_axes_rejected() {
1222        for (shape, axis_name) in [
1223            ([1usize, 0, 3, 2], "y"),
1224            ([1, 2, 0, 2], "x"),
1225            ([1, 2, 3, 0], "tof"),
1226        ] {
1227            let dir = tempfile::tempdir().unwrap();
1228            let path = dir.path().join(format!("zero_{axis_name}.h5"));
1229            let counts: Vec<u64> = Vec::new(); // any axis is 0 → empty cube
1230            let tof_ns = vec![1000.0, 2000.0, 3000.0];
1231            create_test_histogram(&path, &counts, shape, &tof_ns, None);
1232
1233            let err = load_nexus_histogram(&path).unwrap_err();
1234            let msg = err.to_string();
1235            assert!(
1236                msg.contains(&format!("zero-sized {axis_name} axis")),
1237                "axis {axis_name} ({shape:?}) should be rejected by name, got: {msg}"
1238            );
1239        }
1240    }
1241
1242    /// The NeXus TOF axis must be strictly monotonic and positive, mirroring
1243    /// the spectrum-file path.  A non-increasing or non-positive axis would
1244    /// silently feed `tof_to_energy` a bad value downstream.
1245    #[test]
1246    fn test_load_nexus_histogram_rejects_non_monotonic_tof() {
1247        let dir = tempfile::tempdir().unwrap();
1248        let path = dir.path().join("nonmono_tof.h5");
1249        // 1 angle, 1×1 spatial, 2 TOF bins → 3 edges, but they decrease.
1250        let counts = vec![1u64, 2u64];
1251        let tof_ns = vec![3000.0, 2000.0, 1000.0];
1252        create_test_histogram(&path, &counts, [1, 1, 1, 2], &tof_ns, None);
1253
1254        let err = load_nexus_histogram(&path).unwrap_err();
1255        assert!(
1256            err.to_string().contains("strictly increasing"),
1257            "non-monotonic TOF should be rejected, got: {err}"
1258        );
1259    }
1260
1261    #[test]
1262    fn test_load_nexus_histogram_rejects_non_positive_tof() {
1263        let dir = tempfile::tempdir().unwrap();
1264        let path = dir.path().join("nonpos_tof.h5");
1265        // First edge is zero → non-positive TOF axis.
1266        let counts = vec![1u64, 2u64];
1267        let tof_ns = vec![0.0, 1000.0, 2000.0];
1268        create_test_histogram(&path, &counts, [1, 1, 1, 2], &tof_ns, None);
1269
1270        let err = load_nexus_histogram(&path).unwrap_err();
1271        assert!(
1272            err.to_string().contains("finite and positive"),
1273            "non-positive TOF should be rejected, got: {err}"
1274        );
1275    }
1276
1277    /// A trailing `+∞` TOF edge satisfies `prev < +∞` so it passes a
1278    /// monotonicity-only check, but it is not a real time — the per-edge
1279    /// `is_finite()` guard must reject it.
1280    #[test]
1281    fn test_load_nexus_histogram_rejects_trailing_infinite_tof() {
1282        let dir = tempfile::tempdir().unwrap();
1283        let path = dir.path().join("inf_tail_tof.h5");
1284        // 1 angle, 1×1 spatial, 2 TOF bins → 3 edges, last is +∞.
1285        let counts = vec![1u64, 2u64];
1286        let tof_ns = vec![1000.0, 2000.0, f64::INFINITY];
1287        create_test_histogram(&path, &counts, [1, 1, 1, 2], &tof_ns, None);
1288
1289        let err = load_nexus_histogram(&path).unwrap_err();
1290        assert!(
1291            err.to_string().contains("finite and positive"),
1292            "trailing +inf TOF edge should be rejected, got: {err}"
1293        );
1294    }
1295
1296    /// A single-bin axis has only 2 edges; if a malformed scale yields a
1297    /// degenerate axis the per-edge guard still fires.  A 1-edge axis never
1298    /// enters `windows(2)` at all, so `validate_monotonic` is vacuously OK —
1299    /// the per-edge `is_finite() && > 0` check is the only thing that rejects
1300    /// a lone `NaN` / `+∞` edge.  Exercise `read_tof_axis` directly so the
1301    /// single-edge case is reachable without tripping the bin-count
1302    /// cross-check in `load_nexus_histogram`.
1303    #[test]
1304    fn test_read_tof_axis_rejects_single_nan_or_inf_edge() {
1305        let dir = tempfile::tempdir().unwrap();
1306
1307        for (name, edge) in [("nan", f64::NAN), ("inf", f64::INFINITY)] {
1308            let path = dir.path().join(format!("single_{name}_edge.h5"));
1309            let file = hdf5::File::create(&path).expect("create");
1310            let entry = file.create_group("entry").expect("entry");
1311            let hist = entry.create_group("histogram").expect("histogram");
1312            hist.new_dataset::<f64>()
1313                .shape([1])
1314                .create("time_of_flight")
1315                .expect("create tof")
1316                .write_raw(&[edge])
1317                .expect("write tof");
1318            // No `units` attr → legacy-ns scale (finite, so the bad edge is
1319            // preserved as bad, not normalised away).
1320            drop(file);
1321
1322            let file = hdf5::File::open(&path).expect("reopen");
1323            let hist_group = file
1324                .group("entry")
1325                .expect("entry")
1326                .group("histogram")
1327                .expect("histogram");
1328            let err = read_tof_axis(&hist_group).expect_err("single bad edge must reject");
1329            assert!(
1330                err.to_string().contains("finite and positive"),
1331                "single {name} edge should be rejected, got: {err}"
1332            );
1333        }
1334    }
1335
1336    /// Create a histogram fixture that also carries a `/entry/pixel_masks/dead`
1337    /// mask of the given shape, for dead-mask shape-validation tests.
1338    fn create_test_histogram_with_dead_mask(
1339        path: &Path,
1340        counts: &[u64],
1341        shape: [usize; 4],
1342        tof_ns: &[f64],
1343        dead: &[u8],
1344        dead_shape: [usize; 2],
1345    ) {
1346        create_test_histogram(path, counts, shape, tof_ns, None);
1347        let file = hdf5::File::append(path).expect("reopen test file");
1348        let entry = file.group("entry").expect("entry");
1349        let masks = entry.create_group("pixel_masks").expect("pixel_masks");
1350        masks
1351            .new_dataset::<u8>()
1352            .shape(dead_shape)
1353            .create("dead")
1354            .expect("create dead")
1355            .write_raw(dead)
1356            .expect("write dead");
1357    }
1358
1359    /// A dead-pixel mask whose shape does not match the detector's spatial
1360    /// dimensions must be rejected — applying it would mask the wrong pixels.
1361    #[test]
1362    fn test_load_nexus_histogram_rejects_mismatched_dead_mask() {
1363        let dir = tempfile::tempdir().unwrap();
1364        let path = dir.path().join("bad_mask.h5");
1365        // counts shape [1, 2, 3, 2] → detector is 2×3; write a 5×5 mask.
1366        let counts = vec![1u64; 2 * 3 * 2];
1367        let tof_ns = vec![1000.0, 2000.0, 3000.0];
1368        let dead = vec![0u8; 25];
1369        create_test_histogram_with_dead_mask(&path, &counts, [1, 2, 3, 2], &tof_ns, &dead, [5, 5]);
1370
1371        let err = load_nexus_histogram(&path).unwrap_err();
1372        assert!(
1373            matches!(err, IoError::ShapeMismatch(_)),
1374            "expected ShapeMismatch, got {err:?}"
1375        );
1376        assert!(err.to_string().contains("dead-pixel mask shape"));
1377    }
1378
1379    /// A correctly-shaped dead-pixel mask still loads (no false rejection).
1380    #[test]
1381    fn test_load_nexus_histogram_accepts_matching_dead_mask() {
1382        let dir = tempfile::tempdir().unwrap();
1383        let path = dir.path().join("ok_mask.h5");
1384        let counts = vec![1u64; 2 * 3 * 2];
1385        let tof_ns = vec![1000.0, 2000.0, 3000.0];
1386        // 2×3 mask matching the detector; mark one pixel (row 0, col 1) dead.
1387        let dead = vec![0u8, 1, 0, 0, 0, 0];
1388        create_test_histogram_with_dead_mask(&path, &counts, [1, 2, 3, 2], &tof_ns, &dead, [2, 3]);
1389
1390        let data = load_nexus_histogram(&path).expect("matching mask should load");
1391        let mask = data.dead_pixels.expect("mask present");
1392        assert_eq!(mask.dim(), (2, 3));
1393        assert!(mask[[0, 1]]);
1394    }
1395
1396    /// A file with *no* `pixel_masks` group is valid: the mask is absent, not
1397    /// malformed, so the load succeeds with `dead_pixels == None`.
1398    #[test]
1399    fn test_load_nexus_histogram_absent_dead_mask_is_none() {
1400        let dir = tempfile::tempdir().unwrap();
1401        let path = dir.path().join("no_mask.h5");
1402        let counts = vec![1u64; 2 * 3 * 2];
1403        let tof_ns = vec![1000.0, 2000.0, 3000.0];
1404        create_test_histogram(&path, &counts, [1, 2, 3, 2], &tof_ns, None);
1405
1406        let data = load_nexus_histogram(&path).expect("absent mask should load");
1407        assert!(
1408            data.dead_pixels.is_none(),
1409            "absent dead mask must map to None"
1410        );
1411    }
1412
1413    /// A `/entry/pixel_masks/dead` link that exists but is the wrong object
1414    /// kind (a group, not a dataset) is *present-but-malformed*: it must be
1415    /// surfaced as an error, not silently swallowed as absence (which would
1416    /// drop a real-but-corrupt mask and mask no pixels).
1417    #[test]
1418    fn test_load_nexus_histogram_rejects_present_but_invalid_dead_mask() {
1419        let dir = tempfile::tempdir().unwrap();
1420        let path = dir.path().join("invalid_mask.h5");
1421        let counts = vec![1u64; 2 * 3 * 2];
1422        let tof_ns = vec![1000.0, 2000.0, 3000.0];
1423        create_test_histogram(&path, &counts, [1, 2, 3, 2], &tof_ns, None);
1424
1425        // Write `dead` as a *group*, not a dataset — present but malformed.
1426        let file = hdf5::File::append(&path).expect("reopen");
1427        let entry = file.group("entry").expect("entry");
1428        let masks = entry.create_group("pixel_masks").expect("pixel_masks");
1429        masks.create_group("dead").expect("dead-as-group");
1430        drop(file);
1431
1432        let err = load_nexus_histogram(&path).unwrap_err();
1433        assert!(
1434            matches!(err, IoError::InvalidParameter(_)),
1435            "present-but-malformed dead mask must be InvalidParameter, got {err:?}"
1436        );
1437        assert!(
1438            err.to_string().contains("dead") && err.to_string().contains("not a readable dataset"),
1439            "error should identify the malformed dead dataset, got: {err}"
1440        );
1441    }
1442
1443    /// `MultiAngleMode::Error` must reject multi-angle
1444    /// files BEFORE reading the full 4D counts dataset.  On a real
1445    /// multi-angle file this dataset can be multi-GB; wasting a read
1446    /// to then error out is prohibitive.  This test uses metadata
1447    /// (shape is 4D, n_rot > 1) from a tiny synthetic fixture to
1448    /// assert the error is returned — the underlying file is
1449    /// small here, but the code-path assertion is that rejection
1450    /// happens via the shape check alone.  (We can't assert "no
1451    /// read happened" directly without hooking HDF5, but the
1452    /// structural guarantee is preserved by the order of
1453    /// statements in `load_nexus_histogram_with_mode`.)
1454    #[test]
1455    fn test_multi_angle_rejection_happens_before_counts_read() {
1456        let dir = tempfile::tempdir().unwrap();
1457        let path = dir.path().join("big_shape.h5");
1458        // Small synthetic file, but with shape[0]=4 so we exercise the
1459        // rejection path.
1460        let counts = vec![1u64; 4 * 2 * 3 * 2];
1461        let tof_ns = vec![1000.0, 2000.0, 3000.0];
1462        create_test_histogram(&path, &counts, [4, 2, 3, 2], &tof_ns, None);
1463
1464        let err = load_nexus_histogram_with_mode(&path, MultiAngleMode::Error).unwrap_err();
1465        let msg = err.to_string();
1466        assert!(
1467            msg.contains("4 rotation angles") && msg.contains("#430"),
1468            "error message should name angle count + reference the issue, got: {msg}"
1469        );
1470    }
1471
1472    #[test]
1473    fn test_ns_to_us_conversion() {
1474        let dir = tempfile::tempdir().unwrap();
1475        let path = dir.path().join("test.h5");
1476
1477        let counts = vec![0u64; 3];
1478        let tof_ns = vec![500_000.0, 1_000_000.0, 1_500_000.0, 2_000_000.0];
1479        create_test_histogram(&path, &counts, [1, 1, 1, 3], &tof_ns, None);
1480
1481        let data = load_nexus_histogram(&path).unwrap();
1482
1483        // 500_000 ns = 500 µs, etc.
1484        assert!((data.tof_edges_us[0] - 500.0).abs() < 1e-10);
1485        assert!((data.tof_edges_us[1] - 1000.0).abs() < 1e-10);
1486        assert!((data.tof_edges_us[2] - 1500.0).abs() < 1e-10);
1487        assert!((data.tof_edges_us[3] - 2000.0).abs() < 1e-10);
1488    }
1489
1490    #[test]
1491    fn test_probe_missing_dataset() {
1492        let dir = tempfile::tempdir().unwrap();
1493        let path = dir.path().join("empty.h5");
1494
1495        let file = hdf5::File::create(&path).expect("create");
1496        file.create_group("entry").expect("create entry");
1497        drop(file);
1498
1499        let meta = probe_nexus(&path).unwrap();
1500        assert!(!meta.has_histogram);
1501        assert!(!meta.has_events);
1502        assert!(meta.histogram_shape.is_none());
1503        assert!(meta.n_events.is_none());
1504    }
1505
1506    /// Create a minimal NeXus file with neutron event data.
1507    fn create_test_events(
1508        path: &Path,
1509        tof_ns: &[u64],
1510        x: &[f64],
1511        y: &[f64],
1512        flight_path_m: Option<f64>,
1513    ) {
1514        let file = hdf5::File::create(path).expect("create");
1515        let entry = file.create_group("entry").expect("create entry");
1516
1517        if let Some(fp) = flight_path_m {
1518            entry
1519                .new_attr::<f64>()
1520                .shape(())
1521                .create("flight_path_m")
1522                .expect("create attr")
1523                .write_scalar(&fp)
1524                .expect("write attr");
1525        }
1526
1527        let neutrons = entry.create_group("neutrons").expect("create neutrons");
1528        neutrons
1529            .new_dataset::<u64>()
1530            .shape([tof_ns.len()])
1531            .create("event_time_offset")
1532            .expect("create tof")
1533            .write_raw(tof_ns)
1534            .expect("write tof");
1535        neutrons
1536            .new_dataset::<f64>()
1537            .shape([x.len()])
1538            .create("x")
1539            .expect("create x")
1540            .write_raw(x)
1541            .expect("write x");
1542        neutrons
1543            .new_dataset::<f64>()
1544            .shape([y.len()])
1545            .create("y")
1546            .expect("create y")
1547            .write_raw(y)
1548            .expect("write y");
1549    }
1550
1551    #[test]
1552    fn test_histogram_known_events() {
1553        let dir = tempfile::tempdir().unwrap();
1554        let path = dir.path().join("events.h5");
1555
1556        // 3 events: all at pixel (1, 0), TOFs at 1500 µs, 2500 µs, 1800 µs (in ns)
1557        let tof_ns = vec![1_500_000, 2_500_000, 1_800_000];
1558        let x = vec![1.0, 1.0, 1.0];
1559        let y = vec![0.0, 0.0, 0.0];
1560        create_test_events(&path, &tof_ns, &x, &y, Some(25.0));
1561
1562        let params = EventBinningParams {
1563            n_bins: 2,
1564            tof_min_us: 1000.0,
1565            tof_max_us: 3000.0,
1566            height: 2,
1567            width: 3,
1568        };
1569
1570        let data = load_nexus_events(&path, &params).unwrap();
1571        assert_eq!(data.counts.shape(), &[2, 2, 3]);
1572
1573        // Bin 0: TOF [1000, 2000) µs → events at 1500 and 1800 µs → 2 counts
1574        assert_eq!(data.counts[[0, 0, 1]], 2.0);
1575        // Bin 1: TOF [2000, 3000) µs → event at 2500 µs → 1 count
1576        assert_eq!(data.counts[[1, 0, 1]], 1.0);
1577
1578        assert_eq!(data.flight_path_m, Some(25.0));
1579        assert_eq!(data.tof_edges_us.len(), 3); // n_bins + 1 edges
1580
1581        // All 3 events kept, none dropped
1582        let stats = data
1583            .event_stats
1584            .as_ref()
1585            .expect("event_stats should be Some");
1586        assert_eq!(stats.total, 3);
1587        assert_eq!(stats.kept, 3);
1588        assert_eq!(stats.dropped_non_finite, 0);
1589        assert_eq!(stats.dropped_tof_range, 0);
1590        assert_eq!(stats.dropped_spatial, 0);
1591    }
1592
1593    #[test]
1594    fn test_filter_out_of_range_events() {
1595        let dir = tempfile::tempdir().unwrap();
1596        let path = dir.path().join("events_oob.h5");
1597
1598        // Events: one in range, one out of TOF range, one out of spatial range
1599        let tof_ns = vec![
1600            1_500_000, // in range
1601            500_000,   // below tof_min
1602            1_500_000, // in range but x out of bounds
1603        ];
1604        let x = vec![0.0, 0.0, 5.0]; // 5.0 is out of width=3
1605        let y = vec![0.0, 0.0, 0.0];
1606        create_test_events(&path, &tof_ns, &x, &y, None);
1607
1608        let params = EventBinningParams {
1609            n_bins: 2,
1610            tof_min_us: 1000.0,
1611            tof_max_us: 3000.0,
1612            height: 2,
1613            width: 3,
1614        };
1615
1616        let data = load_nexus_events(&path, &params).unwrap();
1617
1618        // Only 1 event should be counted (the first one)
1619        let total: f64 = data.counts.iter().sum();
1620        assert_eq!(total, 1.0);
1621        assert_eq!(data.counts[[0, 0, 0]], 1.0);
1622
1623        // 1 kept, 1 dropped by TOF range, 1 dropped by spatial bounds
1624        let stats = data
1625            .event_stats
1626            .as_ref()
1627            .expect("event_stats should be Some");
1628        assert_eq!(stats.total, 3);
1629        assert_eq!(stats.kept, 1);
1630        assert_eq!(stats.dropped_non_finite, 0);
1631        assert_eq!(stats.dropped_tof_range, 1);
1632        assert_eq!(stats.dropped_spatial, 1);
1633    }
1634
1635    #[test]
1636    fn test_empty_events() {
1637        let dir = tempfile::tempdir().unwrap();
1638        let path = dir.path().join("empty_events.h5");
1639
1640        create_test_events(&path, &[], &[], &[], None);
1641
1642        let params = EventBinningParams {
1643            n_bins: 10,
1644            tof_min_us: 1000.0,
1645            tof_max_us: 20000.0,
1646            height: 4,
1647            width: 4,
1648        };
1649
1650        let data = load_nexus_events(&path, &params).unwrap();
1651        assert_eq!(data.counts.shape(), &[10, 4, 4]);
1652
1653        let total: f64 = data.counts.iter().sum();
1654        assert_eq!(total, 0.0);
1655
1656        // Zero events in, zero events out
1657        let stats = data
1658            .event_stats
1659            .as_ref()
1660            .expect("event_stats should be Some");
1661        assert_eq!(stats.total, 0);
1662        assert_eq!(stats.kept, 0);
1663        assert_eq!(stats.dropped_non_finite, 0);
1664        assert_eq!(stats.dropped_tof_range, 0);
1665        assert_eq!(stats.dropped_spatial, 0);
1666    }
1667
1668    #[test]
1669    fn test_probe_with_events() {
1670        let dir = tempfile::tempdir().unwrap();
1671        let path = dir.path().join("with_events.h5");
1672
1673        create_test_events(
1674            &path,
1675            &[1000, 2000, 3000],
1676            &[0.0, 1.0, 2.0],
1677            &[0.0, 0.0, 1.0],
1678            None,
1679        );
1680
1681        let meta = probe_nexus(&path).unwrap();
1682        assert!(!meta.has_histogram);
1683        assert!(meta.has_events);
1684        assert_eq!(meta.n_events, Some(3));
1685    }
1686
1687    #[test]
1688    fn test_list_hdf5_tree() {
1689        let dir = tempfile::tempdir().unwrap();
1690        let path = dir.path().join("tree.h5");
1691
1692        // Create a file with nested groups and a dataset
1693        {
1694            let file = hdf5::File::create(&path).expect("create file");
1695            let g1 = file.create_group("entry").expect("create entry");
1696            let g2 = g1.create_group("histogram").expect("create histogram");
1697            g2.new_dataset::<f64>()
1698                .shape([3])
1699                .create("data")
1700                .expect("create data")
1701                .write_raw(&[1.0, 2.0, 3.0])
1702                .expect("write data");
1703        }
1704
1705        let tree = list_hdf5_tree(&path, 10).unwrap();
1706        assert!(!tree.is_empty());
1707
1708        // Check that we find the expected paths
1709        let paths: Vec<&str> = tree.iter().map(|e| e.path.as_str()).collect();
1710        assert!(paths.contains(&"/entry"));
1711        assert!(paths.contains(&"/entry/histogram"));
1712        assert!(paths.contains(&"/entry/histogram/data"));
1713
1714        // The dataset should have a shape
1715        let data_entry = tree
1716            .iter()
1717            .find(|e| e.path == "/entry/histogram/data")
1718            .unwrap();
1719        assert!(data_entry.shape.is_some());
1720    }
1721
1722    #[test]
1723    fn test_nan_xy_coords_dropped() {
1724        let dir = tempfile::tempdir().unwrap();
1725        let path = dir.path().join("nan_xy.h5");
1726
1727        // 4 events: 1 good, 1 NaN x, 1 Inf y, 1 good
1728        let tof_ns = vec![1_500_000, 1_500_000, 1_500_000, 2_500_000];
1729        let x = vec![0.0, f64::NAN, 0.0, 1.0];
1730        let y = vec![0.0, 0.0, f64::INFINITY, 0.0];
1731        create_test_events(&path, &tof_ns, &x, &y, None);
1732
1733        let params = EventBinningParams {
1734            n_bins: 2,
1735            tof_min_us: 1000.0,
1736            tof_max_us: 3000.0,
1737            height: 2,
1738            width: 3,
1739        };
1740
1741        let data = load_nexus_events(&path, &params).unwrap();
1742
1743        // Only 2 good events should be counted
1744        let total_counts: f64 = data.counts.iter().sum();
1745        assert_eq!(total_counts, 2.0);
1746
1747        let stats = data
1748            .event_stats
1749            .as_ref()
1750            .expect("event_stats should be Some");
1751        assert_eq!(stats.total, 4);
1752        assert_eq!(stats.kept, 2);
1753        assert_eq!(stats.dropped_non_finite, 2);
1754        assert_eq!(stats.dropped_tof_range, 0);
1755        assert_eq!(stats.dropped_spatial, 0);
1756    }
1757
1758    // -----------------------------------------------------------------
1759    // Issue #554 — NeXus `units` attribute on TOF datasets must be
1760    // honoured.  A file written with `units = "us"` was previously
1761    // divided by 1000 silently, shifting the energy axis by 1000×.
1762    // -----------------------------------------------------------------
1763
1764    /// Write a scalar string attribute on an HDF5 dataset.  Tests use
1765    /// this to inject `units = "ns"`, `units = "us"`, etc. on the
1766    /// `time_of_flight` / `event_time_offset` datasets.
1767    fn write_units_attr(ds: &hdf5::Dataset, units: &str) {
1768        let val: VarLenUnicode = units.parse().expect("parse units string");
1769        ds.new_attr::<VarLenUnicode>()
1770            .shape(())
1771            .create("units")
1772            .expect("create units attr")
1773            .write_scalar(&val)
1774            .expect("write units attr");
1775    }
1776
1777    /// Variant of `create_test_histogram` that stamps a `units`
1778    /// attribute on the `time_of_flight` dataset.
1779    fn create_test_histogram_with_units(
1780        path: &Path,
1781        counts: &[u64],
1782        shape: [usize; 4],
1783        tof_values: &[f64],
1784        units: Option<&str>,
1785    ) {
1786        let file = hdf5::File::create(path).expect("create test file");
1787        let entry = file.create_group("entry").expect("create entry");
1788        let hist = entry.create_group("histogram").expect("create histogram");
1789        hist.new_dataset::<u64>()
1790            .shape(shape)
1791            .create("counts")
1792            .expect("create counts")
1793            .write_raw(counts)
1794            .expect("write counts");
1795        let tof_ds = hist
1796            .new_dataset::<f64>()
1797            .shape([tof_values.len()])
1798            .create("time_of_flight")
1799            .expect("create tof");
1800        tof_ds.write_raw(tof_values).expect("write tof");
1801        if let Some(u) = units {
1802            write_units_attr(&tof_ds, u);
1803        }
1804    }
1805
1806    /// Variant of `create_test_events` that stamps a `units` attribute
1807    /// on the `event_time_offset` dataset.
1808    fn create_test_events_with_units(
1809        path: &Path,
1810        tof_values: &[u64],
1811        x: &[f64],
1812        y: &[f64],
1813        units: Option<&str>,
1814    ) {
1815        let file = hdf5::File::create(path).expect("create");
1816        let entry = file.create_group("entry").expect("create entry");
1817        let neutrons = entry.create_group("neutrons").expect("create neutrons");
1818        let tof_ds = neutrons
1819            .new_dataset::<u64>()
1820            .shape([tof_values.len()])
1821            .create("event_time_offset")
1822            .expect("create tof");
1823        tof_ds.write_raw(tof_values).expect("write tof");
1824        if let Some(u) = units {
1825            write_units_attr(&tof_ds, u);
1826        }
1827        neutrons
1828            .new_dataset::<f64>()
1829            .shape([x.len()])
1830            .create("x")
1831            .expect("create x")
1832            .write_raw(x)
1833            .expect("write x");
1834        neutrons
1835            .new_dataset::<f64>()
1836            .shape([y.len()])
1837            .create("y")
1838            .expect("create y")
1839            .write_raw(y)
1840            .expect("write y");
1841    }
1842
1843    /// `tof_scale_to_us` table: each recognised spelling maps to the
1844    /// documented multiplier.  Pure-helper test — exercises the lookup
1845    /// without an HDF5 file.
1846    #[test]
1847    fn test_tof_scale_to_us_table() {
1848        // Missing → legacy ns assumption.
1849        assert!((tof_scale_to_us(None).unwrap() - 1e-3).abs() < 1e-15);
1850        for (spelling, expected) in &[
1851            ("ns", 1e-3),
1852            ("Ns", 1e-3),
1853            ("NS", 1e-3),
1854            ("nanoseconds", 1e-3),
1855            ("us", 1.0),
1856            ("US", 1.0),
1857            ("microseconds", 1.0),
1858            ("µs", 1.0),
1859            ("ms", 1e3),
1860            ("milliseconds", 1e3),
1861            ("s", 1e6),
1862            ("seconds", 1e6),
1863            ("  s  ", 1e6),
1864        ] {
1865            let got = tof_scale_to_us(Some(*spelling))
1866                .unwrap_or_else(|e| panic!("spelling {spelling:?} unexpectedly errored: {e}"));
1867            assert!(
1868                (got - expected).abs() < 1e-15,
1869                "spelling {spelling:?}: expected scale {expected}, got {got}"
1870            );
1871        }
1872        // Unknown units must error — no silent fallback.
1873        for bad in &["picoseconds", "ticks", "us per channel", "", "garbage"] {
1874            let err = tof_scale_to_us(Some(*bad)).expect_err("unknown units must error");
1875            let msg = err.to_string();
1876            assert!(
1877                msg.contains("Unsupported NeXus TOF units"),
1878                "error for {bad:?} should mention 'Unsupported NeXus TOF units', got: {msg}"
1879            );
1880        }
1881    }
1882
1883    /// Histogram path with `units = "ns"` (current canonical
1884    /// assumption): explicit ns annotation must produce the same µs
1885    /// edges as the rustpix-legacy "no attribute, assume ns" path.
1886    #[test]
1887    fn test_load_nexus_histogram_units_ns_explicit() {
1888        let dir = tempfile::tempdir().unwrap();
1889        let path = dir.path().join("hist_units_ns.h5");
1890        let counts = vec![0u64; 2];
1891        // 3 ns edges → 0.001, 0.002, 0.003 µs
1892        let tof_ns = vec![1.0, 2.0, 3.0];
1893        create_test_histogram_with_units(&path, &counts, [1, 1, 1, 2], &tof_ns, Some("ns"));
1894
1895        let data = load_nexus_histogram(&path).expect("load with units=ns");
1896        assert_eq!(data.tof_edges_us.len(), 3);
1897        assert!((data.tof_edges_us[0] - 0.001).abs() < 1e-12);
1898        assert!((data.tof_edges_us[1] - 0.002).abs() < 1e-12);
1899        assert!((data.tof_edges_us[2] - 0.003).abs() < 1e-12);
1900    }
1901
1902    /// Histogram path with `units = "us"` (NeXus-standard
1903    /// microseconds): values must be passed through unchanged, NOT
1904    /// divided by 1000.  Pre-#554 this produced a 1000× too-small
1905    /// energy axis silently.
1906    #[test]
1907    fn test_load_nexus_histogram_units_us_no_rescale() {
1908        let dir = tempfile::tempdir().unwrap();
1909        let path = dir.path().join("hist_units_us.h5");
1910        let counts = vec![0u64; 4];
1911        // µs values that would be catastrophically wrong if divided by 1000.
1912        let tof_us = vec![1000.0, 2000.0, 3000.0, 4000.0, 5000.0];
1913        create_test_histogram_with_units(&path, &counts, [1, 1, 1, 4], &tof_us, Some("us"));
1914
1915        let data = load_nexus_histogram(&path).expect("load with units=us");
1916        assert_eq!(data.tof_edges_us.len(), 5);
1917        for (i, &expected) in tof_us.iter().enumerate() {
1918            assert!(
1919                (data.tof_edges_us[i] - expected).abs() < 1e-9,
1920                "edge {i}: expected {expected} µs (no rescale), got {} µs",
1921                data.tof_edges_us[i]
1922            );
1923        }
1924    }
1925
1926    /// Histogram path: NeXus `units = "s"` (seconds) must rescale
1927    /// ×1e6 → µs.
1928    #[test]
1929    fn test_load_nexus_histogram_units_seconds() {
1930        let dir = tempfile::tempdir().unwrap();
1931        let path = dir.path().join("hist_units_s.h5");
1932        let counts = vec![0u64; 2];
1933        // 1 ms = 1000 µs, written as 0.001 s
1934        let tof_s = vec![0.001, 0.002, 0.003];
1935        create_test_histogram_with_units(&path, &counts, [1, 1, 1, 2], &tof_s, Some("s"));
1936
1937        let data = load_nexus_histogram(&path).expect("load with units=s");
1938        assert!((data.tof_edges_us[0] - 1000.0).abs() < 1e-9);
1939        assert!((data.tof_edges_us[1] - 2000.0).abs() < 1e-9);
1940        assert!((data.tof_edges_us[2] - 3000.0).abs() < 1e-9);
1941    }
1942
1943    /// Histogram path: unknown `units` must hard-error rather than
1944    /// silently default to ns.  Without this check, a typo
1945    /// (e.g. `"microsecond"` vs `"microseconds"`) could be mis-scaled
1946    /// — and worse, an exotic-but-real unit (`"ticks"`) would be
1947    /// silently dropped on the floor.
1948    #[test]
1949    fn test_load_nexus_histogram_units_unknown_rejected() {
1950        let dir = tempfile::tempdir().unwrap();
1951        let path = dir.path().join("hist_units_bad.h5");
1952        let counts = vec![0u64; 2];
1953        let tof = vec![1.0, 2.0, 3.0];
1954        create_test_histogram_with_units(&path, &counts, [1, 1, 1, 2], &tof, Some("picoseconds"));
1955
1956        let err = load_nexus_histogram(&path).expect_err("unknown units must error");
1957        let msg = err.to_string();
1958        assert!(
1959            msg.contains("Unsupported NeXus TOF units") && msg.contains("picoseconds"),
1960            "error should name the offending value, got: {msg}"
1961        );
1962    }
1963
1964    /// Histogram path: missing `units` attribute is allowed and
1965    /// preserves the rustpix-legacy ns assumption.  This is the
1966    /// backward-compatibility guarantee for files produced by the
1967    /// rustpix-era extraction tooling, which writes nanoseconds
1968    /// without a `units` attribute.
1969    #[test]
1970    fn test_load_nexus_histogram_units_missing_legacy_ns() {
1971        let dir = tempfile::tempdir().unwrap();
1972        let path = dir.path().join("hist_units_missing.h5");
1973        let counts = vec![0u64; 2];
1974        let tof_ns = vec![1000.0, 2000.0, 3000.0];
1975        // No `units` attribute → assume ns → 1.0 / 2.0 / 3.0 µs.
1976        create_test_histogram_with_units(&path, &counts, [1, 1, 1, 2], &tof_ns, None);
1977        let data = load_nexus_histogram(&path).expect("load with no units attr");
1978        assert!((data.tof_edges_us[0] - 1.0).abs() < 1e-12);
1979        assert!((data.tof_edges_us[1] - 2.0).abs() < 1e-12);
1980        assert!((data.tof_edges_us[2] - 3.0).abs() < 1e-12);
1981    }
1982
1983    /// Events path with `units = "us"`: the TOF binning must place
1984    /// events at the correct µs values, NOT divide them by 1000.
1985    /// Pre-#554 a file written in µs would have all events land
1986    /// 1000× lower than the user-specified bins, leaving the
1987    /// histogram empty / all dropped by `tof_range`.
1988    #[test]
1989    fn test_load_nexus_events_units_us_no_rescale() {
1990        let dir = tempfile::tempdir().unwrap();
1991        let path = dir.path().join("events_units_us.h5");
1992
1993        // Events at 1500 µs and 2500 µs (written in µs, units=us).
1994        let tof_us = vec![1500u64, 2500u64, 1800u64];
1995        let x = vec![1.0, 1.0, 1.0];
1996        let y = vec![0.0, 0.0, 0.0];
1997        create_test_events_with_units(&path, &tof_us, &x, &y, Some("us"));
1998
1999        let params = EventBinningParams {
2000            n_bins: 2,
2001            tof_min_us: 1000.0,
2002            tof_max_us: 3000.0,
2003            height: 2,
2004            width: 3,
2005        };
2006        let data = load_nexus_events(&path, &params).expect("load events with units=us");
2007
2008        // Bin 0: [1000, 2000) µs → 1500 + 1800 = 2 events
2009        assert_eq!(data.counts[[0, 0, 1]], 2.0);
2010        // Bin 1: [2000, 3000) µs → 2500 = 1 event
2011        assert_eq!(data.counts[[1, 0, 1]], 1.0);
2012        let stats = data.event_stats.as_ref().expect("event stats");
2013        assert_eq!(stats.kept, 3);
2014        assert_eq!(stats.dropped_tof_range, 0);
2015    }
2016
2017    /// Events path with `units = "ns"` (explicit): same result as
2018    /// the legacy "no attribute" path.
2019    #[test]
2020    fn test_load_nexus_events_units_ns_explicit() {
2021        let dir = tempfile::tempdir().unwrap();
2022        let path = dir.path().join("events_units_ns.h5");
2023
2024        // Events at 1500/2500/1800 µs, written in ns with units=ns.
2025        let tof_ns = vec![1_500_000u64, 2_500_000u64, 1_800_000u64];
2026        let x = vec![1.0, 1.0, 1.0];
2027        let y = vec![0.0, 0.0, 0.0];
2028        create_test_events_with_units(&path, &tof_ns, &x, &y, Some("ns"));
2029
2030        let params = EventBinningParams {
2031            n_bins: 2,
2032            tof_min_us: 1000.0,
2033            tof_max_us: 3000.0,
2034            height: 2,
2035            width: 3,
2036        };
2037        let data = load_nexus_events(&path, &params).expect("load events with units=ns");
2038        assert_eq!(data.counts[[0, 0, 1]], 2.0);
2039        assert_eq!(data.counts[[1, 0, 1]], 1.0);
2040    }
2041
2042    /// Events path: unknown `units` must hard-error.
2043    #[test]
2044    fn test_load_nexus_events_units_unknown_rejected() {
2045        let dir = tempfile::tempdir().unwrap();
2046        let path = dir.path().join("events_units_bad.h5");
2047        let tof = vec![1_500_000u64];
2048        let x = vec![0.0];
2049        let y = vec![0.0];
2050        create_test_events_with_units(&path, &tof, &x, &y, Some("clock-ticks"));
2051
2052        let params = EventBinningParams {
2053            n_bins: 2,
2054            tof_min_us: 1000.0,
2055            tof_max_us: 3000.0,
2056            height: 2,
2057            width: 3,
2058        };
2059        let err = load_nexus_events(&path, &params).expect_err("unknown units must error");
2060        let msg = err.to_string();
2061        assert!(
2062            msg.contains("Unsupported NeXus TOF units") && msg.contains("clock-ticks"),
2063            "error should name the offending value, got: {msg}"
2064        );
2065    }
2066}
2067
2068// ---------------------------------------------------------------------------
2069// NXevent_data bank spectra with wall-clock interval filtering (issue #637)
2070// ---------------------------------------------------------------------------
2071
2072/// TOF binning parameters for a 1-D NXevent_data bank spectrum (issue #637).
2073///
2074/// NXevent_data banks (facility NeXus convention: `/entry/<bank>/` with
2075/// `event_time_offset`, `event_index`, `event_time_zero`) have no per-event
2076/// pixel coordinates in the general case (monitors never do), so the result
2077/// is a 1-D TOF spectrum rather than a `(tof, y, x)` cube.
2078#[derive(Debug, Clone, Copy)]
2079pub struct BankBinningParams {
2080    /// Number of TOF bins.
2081    pub n_bins: usize,
2082    /// Minimum TOF in microseconds (inclusive).
2083    pub tof_min_us: f64,
2084    /// Maximum TOF in microseconds (exclusive).
2085    pub tof_max_us: f64,
2086}
2087
2088/// Result of [`load_nexus_bank_spectrum`]: a 1-D TOF spectrum plus pulse and
2089/// event retention statistics.
2090///
2091/// The drop counters (`dropped_tof_range`, `dropped_non_finite`) cover only
2092/// events belonging to **kept** pulses; events on pulses excluded by
2093/// `keep_intervals` are accounted for as `events_total - events_kept -
2094/// dropped_tof_range - dropped_non_finite` and are not itemised.
2095#[derive(Debug, Clone)]
2096pub struct BankSpectrum {
2097    /// TOF bin edges in microseconds (`n_bins + 1` values, linear grid).
2098    pub tof_edges_us: Vec<f64>,
2099    /// Histogrammed event counts per TOF bin (`n_bins` values).
2100    pub counts: Vec<u64>,
2101    /// Total number of pulses recorded in the bank.
2102    pub pulses_total: usize,
2103    /// Pulses whose `event_time_zero` fell inside `keep_intervals`
2104    /// (equals `pulses_total` when no filter was given).
2105    pub pulses_kept: usize,
2106    /// Total number of events recorded in the bank.
2107    pub events_total: usize,
2108    /// Events on kept pulses that landed inside the TOF window.
2109    pub events_kept: usize,
2110    /// Events on kept pulses dropped for TOF outside `[tof_min_us, tof_max_us)`.
2111    pub dropped_tof_range: usize,
2112    /// Events on kept pulses dropped for non-finite TOF.
2113    pub dropped_non_finite: usize,
2114    /// ISO-8601 `offset` attribute of `event_time_zero`, when recorded —
2115    /// the absolute wall-clock epoch that pulse times are relative to.
2116    /// Compare with [`crate::runlog::RunLog::offset_iso`] to confirm that
2117    /// interval and pulse clocks share a zero point (at SNS both are
2118    /// seconds since run start and the attributes match exactly).
2119    pub pulse_time_offset_iso: Option<String>,
2120}
2121
2122/// Load one NXevent_data bank (e.g. a beam monitor) as a 1-D TOF spectrum,
2123/// optionally keeping only pulses inside wall-clock `keep_intervals`
2124/// (issue #637).
2125///
2126/// Reads `/entry/<bank>/{event_time_offset, event_index, event_time_zero}`:
2127///
2128/// - `event_time_offset` — TOF per event; its `units` attribute is
2129///   **required** on this path (facility files always write it; refusing to
2130///   guess closes the #554 silent-rescale class).  Recognised values are
2131///   the module-level table (ns/us/ms/s), scaled to canonical µs.
2132/// - `event_index` — cumulative first-event index per pulse (validated
2133///   non-decreasing, last entry ≤ total events).  Events of pulse `p` are
2134///   `event_index[p] .. event_index[p+1]` (last pulse runs to the end).
2135/// - `event_time_zero` — pulse wall-clock times; its `units` attribute is
2136///   also required (NXevent_data specifies no default; SNS writes
2137///   `"second"`), accepted via the same table and rescaled to seconds.
2138///
2139/// `keep_intervals` are `(t_start, t_end)` pairs in seconds on the same
2140/// clock as `event_time_zero` (at SNS: seconds since run start — the same
2141/// clock as `/entry/DASlogs/<pv>/time`, so lists from
2142/// [`crate::runlog::intervals_where`] /
2143/// [`crate::runlog::intervals_intersect`] apply directly).  Pulse `p` is
2144/// kept iff `t_start <= event_time_zero[p] < t_end` for some interval;
2145/// the list may be unsorted/overlapping (it is normalised internally), but
2146/// every pair must be finite with `t_end > t_start`.
2147///
2148/// **Empty-bank grace (issue #637)**: a bank with zero events (the normal
2149/// state of every imaging-detector bank on VENUS, where tpx1 is frame-mode)
2150/// loads to an all-zero spectrum with correct pulse statistics — it never
2151/// errors.
2152pub fn load_nexus_bank_spectrum(
2153    path: &Path,
2154    bank: &str,
2155    params: &BankBinningParams,
2156    keep_intervals: Option<&[(f64, f64)]>,
2157) -> Result<BankSpectrum, IoError> {
2158    if params.n_bins == 0 {
2159        return Err(IoError::InvalidParameter("n_bins must be positive".into()));
2160    }
2161    if !params.tof_min_us.is_finite() || !params.tof_max_us.is_finite() {
2162        return Err(IoError::InvalidParameter(
2163            "TOF bounds must be finite".into(),
2164        ));
2165    }
2166    if params.tof_max_us <= params.tof_min_us {
2167        return Err(IoError::InvalidParameter(format!(
2168            "tof_max_us ({}) must be greater than tof_min_us ({})",
2169            params.tof_max_us, params.tof_min_us
2170        )));
2171    }
2172    // Normalise the keep-list once: validate pairs, sort, merge overlaps.
2173    let intervals: Option<Vec<(f64, f64)>> = match keep_intervals {
2174        None => None,
2175        Some(raw) => Some(crate::runlog::normalize_intervals(raw)?),
2176    };
2177
2178    let file = hdf5::File::open(path).map_err(|e| {
2179        IoError::FileNotFound(
2180            path.display().to_string(),
2181            std::io::Error::other(e.to_string()),
2182        )
2183    })?;
2184    let group = file
2185        .group(&format!("entry/{bank}"))
2186        .map_err(|e| IoError::InvalidParameter(format!("Missing /entry/{bank} group: {e}")))?;
2187
2188    let etz_ds = group.dataset("event_time_zero").map_err(|e| {
2189        IoError::InvalidParameter(format!("Missing /entry/{bank}/event_time_zero: {e}"))
2190    })?;
2191    // NXevent_data specifies only the unit CATEGORY (NX_TIME) with no
2192    // default, so a missing attribute is an error, not a guess — the same
2193    // policy #554 established for event_time_offset.  Every surveyed SNS
2194    // file writes units="second" here.
2195    let etz_to_s = match read_string_attr(&etz_ds, "units")? {
2196        None => {
2197            return Err(IoError::InvalidParameter(format!(
2198                "/entry/{bank}/event_time_zero has no units attribute; refusing to \
2199                 guess a time scale (issues #554/#637)"
2200            )));
2201        }
2202        Some(u) => tof_scale_to_us(Some(&u))? * 1e-6,
2203    };
2204    let event_time_zero: Vec<f64> = etz_ds
2205        .read_1d::<f64>()
2206        .map_err(|e| IoError::Hdf5Error(format!("Failed to read {bank}/event_time_zero: {e}")))?
2207        .to_vec()
2208        .into_iter()
2209        .map(|t| t * etz_to_s)
2210        .collect();
2211    // Retention accounting must be exact (same policy as the
2212    // event_index guards below): a pulse with a non-finite wall-clock
2213    // time can never match a keep-interval, so its events would vanish
2214    // from the counts without being tallied — fail loud instead.
2215    if let Some(i) = event_time_zero.iter().position(|t| !t.is_finite()) {
2216        return Err(IoError::InvalidParameter(format!(
2217            "{bank}/event_time_zero[{i}] is not finite ({}); corrupt pulse \
2218             times would silently exclude events from the accounting",
2219            event_time_zero[i]
2220        )));
2221    }
2222    let pulse_time_offset_iso = read_string_attr(&etz_ds, "offset")?;
2223
2224    let event_index: Vec<u64> = group
2225        .dataset("event_index")
2226        .map_err(|e| IoError::InvalidParameter(format!("Missing /entry/{bank}/event_index: {e}")))?
2227        .read_1d::<u64>()
2228        .map_err(|e| IoError::Hdf5Error(format!("Failed to read {bank}/event_index: {e}")))?
2229        .to_vec();
2230    if event_index.len() != event_time_zero.len() {
2231        return Err(IoError::ShapeMismatch(format!(
2232            "{bank}: event_index has {} entries but event_time_zero has {}",
2233            event_index.len(),
2234            event_time_zero.len()
2235        )));
2236    }
2237    if event_index.windows(2).any(|w| w[1] < w[0]) {
2238        return Err(IoError::InvalidParameter(format!(
2239            "{bank}/event_index must be non-decreasing (cumulative first-event index per pulse)"
2240        )));
2241    }
2242
2243    let eto_ds = group.dataset("event_time_offset").map_err(|e| {
2244        IoError::InvalidParameter(format!("Missing /entry/{bank}/event_time_offset: {e}"))
2245    })?;
2246    let tof_scale = match read_string_attr(&eto_ds, "units")? {
2247        Some(u) => tof_scale_to_us(Some(&u))?,
2248        None => {
2249            return Err(IoError::InvalidParameter(format!(
2250                "/entry/{bank}/event_time_offset has no units attribute; NXevent_data \
2251                 producers declare TOF units explicitly and this loader refuses to \
2252                 guess a scale factor (issues #554/#637)"
2253            )));
2254        }
2255    };
2256    let tof_raw: Vec<f64> = eto_ds
2257        .read_1d::<f64>()
2258        .map_err(|e| IoError::Hdf5Error(format!("Failed to read {bank}/event_time_offset: {e}")))?
2259        .to_vec();
2260    let events_total = tof_raw.len();
2261    if let Some(&last) = event_index.last()
2262        && last as usize > events_total
2263    {
2264        return Err(IoError::InvalidParameter(format!(
2265            "{bank}/event_index last entry ({last}) exceeds total event count ({events_total})"
2266        )));
2267    }
2268    // Retention accounting must be exact: every event belongs to a pulse
2269    // slice or a drop counter.  A first index > 0 (events preceding the
2270    // first pulse) or events without any pulse record would vanish
2271    // silently — fail loud instead (issue #637).
2272    match event_index.first() {
2273        Some(&first) if first != 0 => {
2274            return Err(IoError::InvalidParameter(format!(
2275                "{bank}/event_index first entry ({first}) must be 0: {first} event(s) \
2276                 precede the first pulse and would be silently dropped"
2277            )));
2278        }
2279        None if events_total > 0 => {
2280            return Err(IoError::InvalidParameter(format!(
2281                "{bank} has {events_total} events but no pulses (empty event_index)"
2282            )));
2283        }
2284        _ => {}
2285    }
2286
2287    let pulses_total = event_time_zero.len();
2288    let bin_w = (params.tof_max_us - params.tof_min_us) / params.n_bins as f64;
2289    let keep_pulse = |t: f64| -> bool {
2290        // Normalise -0.0 to +0.0: membership is defined numerically, but
2291        // total_cmp (needed for NaN robustness) orders -0.0 below +0.0.
2292        let t = if t == 0.0 { 0.0 } else { t };
2293        match &intervals {
2294            None => true,
2295            Some(iv) => match iv.binary_search_by(|&(a, _)| a.total_cmp(&t)) {
2296                Ok(i) => t < iv[i].1,
2297                Err(0) => false,
2298                Err(i) => t < iv[i - 1].1,
2299            },
2300        }
2301    };
2302
2303    let mut counts = vec![0u64; params.n_bins];
2304    let mut pulses_kept = 0usize;
2305    let mut events_kept = 0usize;
2306    let mut dropped_tof_range = 0usize;
2307    let mut dropped_non_finite = 0usize;
2308    for p in 0..pulses_total {
2309        if !keep_pulse(event_time_zero[p]) {
2310            continue;
2311        }
2312        pulses_kept += 1;
2313        let e0 = event_index[p] as usize;
2314        let e1 = if p + 1 < pulses_total {
2315            event_index[p + 1] as usize
2316        } else {
2317            events_total
2318        };
2319        for &raw in &tof_raw[e0..e1] {
2320            let tof = raw * tof_scale;
2321            if !tof.is_finite() {
2322                dropped_non_finite += 1;
2323                continue;
2324            }
2325            if tof < params.tof_min_us || tof >= params.tof_max_us {
2326                dropped_tof_range += 1;
2327                continue;
2328            }
2329            // Guard the floating-point upper edge: tof < max is checked, but
2330            // (tof - min) / bin_w can still round up to n_bins.
2331            let bin = (((tof - params.tof_min_us) / bin_w) as usize).min(params.n_bins - 1);
2332            counts[bin] += 1;
2333            events_kept += 1;
2334        }
2335    }
2336    let tof_edges_us = (0..=params.n_bins)
2337        .map(|i| params.tof_min_us + i as f64 * bin_w)
2338        .collect();
2339    Ok(BankSpectrum {
2340        tof_edges_us,
2341        counts,
2342        pulses_total,
2343        pulses_kept,
2344        events_total,
2345        events_kept,
2346        dropped_tof_range,
2347        dropped_non_finite,
2348        pulse_time_offset_iso,
2349    })
2350}
2351
2352#[cfg(test)]
2353mod bank_tests {
2354    use super::*;
2355
2356    /// Write a synthetic NXevent_data bank: per-pulse wall times (s) and
2357    /// per-pulse event TOF lists (µs, stored in the given units).
2358    fn create_test_bank(
2359        path: &Path,
2360        bank: &str,
2361        pulse_times_s: &[f64],
2362        events_per_pulse: &[Vec<f64>],
2363        tof_units: Option<&str>,
2364        tof_store_scale: f64,
2365    ) {
2366        assert_eq!(pulse_times_s.len(), events_per_pulse.len());
2367        let file = hdf5::File::create(path).expect("create test file");
2368        let entry = if let Ok(g) = file.group("entry") {
2369            g
2370        } else {
2371            file.create_group("entry").expect("create entry")
2372        };
2373        let g = entry.create_group(bank).expect("create bank");
2374        let mut index: Vec<u64> = Vec::new();
2375        let mut tofs: Vec<f64> = Vec::new();
2376        for evs in events_per_pulse {
2377            index.push(tofs.len() as u64);
2378            tofs.extend(evs.iter().map(|t| t * tof_store_scale));
2379        }
2380        let etz = g
2381            .new_dataset_builder()
2382            .with_data(pulse_times_s)
2383            .create("event_time_zero")
2384            .expect("etz");
2385        etz.new_attr::<hdf5::types::VarLenUnicode>()
2386            .create("units")
2387            .expect("attr")
2388            .write_scalar(&"second".parse::<hdf5::types::VarLenUnicode>().unwrap())
2389            .expect("write");
2390        etz.new_attr::<hdf5::types::VarLenUnicode>()
2391            .create("offset")
2392            .expect("attr")
2393            .write_scalar(
2394                &"2026-06-22T19:01:07.183368667-04:00"
2395                    .parse::<hdf5::types::VarLenUnicode>()
2396                    .unwrap(),
2397            )
2398            .expect("write");
2399        g.new_dataset_builder()
2400            .with_data(&index)
2401            .create("event_index")
2402            .expect("ei");
2403        let eto = g
2404            .new_dataset_builder()
2405            .with_data(&tofs)
2406            .create("event_time_offset")
2407            .expect("eto");
2408        if let Some(u) = tof_units {
2409            eto.new_attr::<hdf5::types::VarLenUnicode>()
2410                .create("units")
2411                .expect("attr")
2412                .write_scalar(&u.parse::<hdf5::types::VarLenUnicode>().unwrap())
2413                .expect("write");
2414        }
2415    }
2416
2417    fn params(n_bins: usize, lo: f64, hi: f64) -> BankBinningParams {
2418        BankBinningParams {
2419            n_bins,
2420            tof_min_us: lo,
2421            tof_max_us: hi,
2422        }
2423    }
2424
2425    #[test]
2426    fn unfiltered_spectrum_counts_all_events() {
2427        let dir = tempfile::tempdir().unwrap();
2428        let path = dir.path().join("bank.h5");
2429        create_test_bank(
2430            &path,
2431            "monitor1",
2432            &[0.0, 1.0, 2.0],
2433            &[vec![100.0, 900.0], vec![500.0], vec![100.0, 500.0, 900.0]],
2434            Some("microsecond"),
2435            1.0,
2436        );
2437        let s = load_nexus_bank_spectrum(&path, "monitor1", &params(2, 0.0, 1000.0), None)
2438            .expect("load");
2439        assert_eq!(s.pulses_total, 3);
2440        assert_eq!(s.pulses_kept, 3);
2441        assert_eq!(s.events_total, 6);
2442        assert_eq!(s.events_kept, 6);
2443        assert_eq!(s.counts, vec![2, 4]); // [0,500): the two 100s; [500,1000): 500,500,900,900
2444        assert_eq!(s.tof_edges_us, vec![0.0, 500.0, 1000.0]);
2445        assert!(s.pulse_time_offset_iso.unwrap().starts_with("2026-06-22"));
2446    }
2447
2448    #[test]
2449    fn interval_filter_keeps_only_matching_pulses_with_boundary_semantics() {
2450        let dir = tempfile::tempdir().unwrap();
2451        let path = dir.path().join("bank.h5");
2452        // Pulses at t = 0, 10, 20, 30 s with 1, 2, 4, 8 events.
2453        create_test_bank(
2454            &path,
2455            "monitor1",
2456            &[0.0, 10.0, 20.0, 30.0],
2457            &[vec![50.0], vec![50.0; 2], vec![50.0; 4], vec![50.0; 8]],
2458            Some("microsecond"),
2459            1.0,
2460        );
2461        // Half-open [10, 30): keeps pulses at 10 and 20, not 0 and not 30.
2462        let s = load_nexus_bank_spectrum(
2463            &path,
2464            "monitor1",
2465            &params(1, 0.0, 100.0),
2466            Some(&[(10.0, 30.0)]),
2467        )
2468        .expect("load");
2469        assert_eq!(s.pulses_kept, 2);
2470        assert_eq!(s.events_kept, 6);
2471        assert_eq!(s.counts, vec![6]);
2472        // Unsorted, overlapping intervals normalise to the same union.
2473        let s2 = load_nexus_bank_spectrum(
2474            &path,
2475            "monitor1",
2476            &params(1, 0.0, 100.0),
2477            Some(&[(15.0, 30.0), (10.0, 20.0)]),
2478        )
2479        .expect("load");
2480        assert_eq!(s2.events_kept, 6);
2481        // Empty keep-list keeps nothing.
2482        let s3 = load_nexus_bank_spectrum(&path, "monitor1", &params(1, 0.0, 100.0), Some(&[]))
2483            .expect("load");
2484        assert_eq!((s3.pulses_kept, s3.events_kept), (0, 0));
2485        assert_eq!(s3.counts, vec![0]);
2486    }
2487
2488    #[test]
2489    fn empty_bank_loads_gracefully() {
2490        let dir = tempfile::tempdir().unwrap();
2491        let path = dir.path().join("bank.h5");
2492        // The VENUS reality: pulses recorded, zero events (frame-mode tpx1).
2493        create_test_bank(
2494            &path,
2495            "bank100_events",
2496            &[0.0, 1.0, 2.0],
2497            &[vec![], vec![], vec![]],
2498            Some("microsecond"),
2499            1.0,
2500        );
2501        let s = load_nexus_bank_spectrum(
2502            &path,
2503            "bank100_events",
2504            &params(4, 0.0, 1000.0),
2505            Some(&[(0.5, 2.5)]),
2506        )
2507        .expect("empty bank must load");
2508        assert_eq!(s.pulses_total, 3);
2509        assert_eq!(s.pulses_kept, 2);
2510        assert_eq!(s.events_total, 0);
2511        assert_eq!(s.counts, vec![0, 0, 0, 0]);
2512    }
2513
2514    #[test]
2515    fn tof_units_are_scaled_and_required() {
2516        let dir = tempfile::tempdir().unwrap();
2517        // Nanosecond storage scales to the same µs spectrum.
2518        let p_ns = dir.path().join("ns.h5");
2519        create_test_bank(&p_ns, "m", &[0.0], &[vec![250.0, 750.0]], Some("ns"), 1e3);
2520        let s = load_nexus_bank_spectrum(&p_ns, "m", &params(2, 0.0, 1000.0), None).unwrap();
2521        assert_eq!(s.counts, vec![1, 1]);
2522        // Missing units attribute on this path is an error, not a guess.
2523        let p_none = dir.path().join("none.h5");
2524        create_test_bank(&p_none, "m", &[0.0], &[vec![250.0]], None, 1.0);
2525        let err = load_nexus_bank_spectrum(&p_none, "m", &params(2, 0.0, 1000.0), None)
2526            .expect_err("must refuse to guess units");
2527        assert!(err.to_string().contains("units"), "{err}");
2528    }
2529
2530    #[test]
2531    fn fixed_length_ascii_attributes_read_correctly() {
2532        // SNS/ADARA facility files store attributes as FIXED-length ASCII
2533        // (rustpix uses variable-length UTF-8) — both must read (issue #637).
2534        let dir = tempfile::tempdir().unwrap();
2535        let path = dir.path().join("fixed.h5");
2536        create_test_bank(&path, "m", &[0.0], &[vec![250.0, 750.0]], None, 1.0);
2537        {
2538            let file = hdf5::File::open_rw(&path).expect("reopen");
2539            let eto = file.dataset("entry/m/event_time_offset").expect("eto");
2540            let units = hdf5::types::FixedAscii::<16>::from_ascii(b"microsecond").unwrap();
2541            eto.new_attr::<hdf5::types::FixedAscii<16>>()
2542                .create("units")
2543                .expect("attr")
2544                .write_scalar(&units)
2545                .expect("write");
2546        }
2547        let s = load_nexus_bank_spectrum(&path, "m", &params(2, 0.0, 1000.0), None)
2548            .expect("fixed-ascii units must parse");
2549        assert_eq!(s.counts, vec![1, 1]);
2550    }
2551
2552    #[test]
2553    fn non_finite_pulse_time_fails_loud() {
2554        // A NaN event_time_zero can never match a keep-interval, so its
2555        // events would silently vanish from the retention accounting.
2556        let dir = tempfile::tempdir().unwrap();
2557        let path = dir.path().join("nanpulse.h5");
2558        create_test_bank(
2559            &path,
2560            "m",
2561            &[0.0, f64::NAN],
2562            &[vec![100.0], vec![200.0]],
2563            Some("us"),
2564            1.0,
2565        );
2566        let err = load_nexus_bank_spectrum(&path, "m", &params(1, 0.0, 1000.0), None)
2567            .expect_err("non-finite pulse time must error");
2568        assert!(err.to_string().contains("not finite"), "{err}");
2569    }
2570
2571    #[test]
2572    fn orphan_head_events_fail_loud() {
2573        // event_index[0] != 0 means events precede the first pulse; they
2574        // belong to no pulse slice and would vanish from the accounting.
2575        let dir = tempfile::tempdir().unwrap();
2576        let path = dir.path().join("orphan.h5");
2577        create_test_bank(&path, "m", &[0.0], &[vec![100.0, 200.0]], Some("us"), 1.0);
2578        {
2579            let file = hdf5::File::open_rw(&path).expect("reopen");
2580            let ei = file.dataset("entry/m/event_index").expect("ei");
2581            ei.write(&ndarray::arr1(&[1u64])).expect("overwrite");
2582        }
2583        let err = load_nexus_bank_spectrum(&path, "m", &params(1, 0.0, 1000.0), None)
2584            .expect_err("orphan head events must error");
2585        assert!(err.to_string().contains("precede the first pulse"), "{err}");
2586    }
2587
2588    #[test]
2589    fn malformed_inputs_error() {
2590        let dir = tempfile::tempdir().unwrap();
2591        let path = dir.path().join("bank.h5");
2592        create_test_bank(
2593            &path,
2594            "m",
2595            &[0.0, 1.0],
2596            &[vec![1.0], vec![2.0]],
2597            Some("us"),
2598            1.0,
2599        );
2600        // Bad interval pairs.
2601        for bad in [(5.0, 5.0), (5.0, 1.0), (f64::NAN, 1.0)] {
2602            assert!(
2603                load_nexus_bank_spectrum(&path, "m", &params(1, 0.0, 10.0), Some(&[bad])).is_err()
2604            );
2605        }
2606        // Bad binning params.
2607        assert!(load_nexus_bank_spectrum(&path, "m", &params(0, 0.0, 10.0), None).is_err());
2608        assert!(load_nexus_bank_spectrum(&path, "m", &params(1, 10.0, 10.0), None).is_err());
2609        // Missing bank.
2610        assert!(load_nexus_bank_spectrum(&path, "nope", &params(1, 0.0, 10.0), None).is_err());
2611    }
2612}