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