Skip to main content

nereids_io/
normalization.rs

1//! Transmission normalization from raw neutron counts.
2//!
3//! Converts raw sample and open-beam (OB) neutron counts into a transmission
4//! spectrum, following the ORNL Method 2 approach used in PLEIADES.
5//!
6//! ## Method 2 Normalization
7//!
8//! For each TOF bin and pixel:
9//!
10//!   T[tof, y, x] = (C_sample / C_ob) × (PC_ob / PC_sample)
11//!
12//! where:
13//! - C_sample = raw sample counts (dark-current subtracted)
14//! - C_ob = open-beam counts (dark-current subtracted)
15//! - PC_sample = proton charge for sample run
16//! - PC_ob = proton charge for open-beam run
17//!
18//! The proton charge ratio corrects for different beam exposures.
19//!
20//! ## Uncertainty
21//!
22//! Assuming Poisson counting statistics:
23//!
24//!   σ_T / T = √(1/C_sample + 1/C_ob)
25//!
26//! ## Pixel masks — pipeline integrity only
27//!
28//! The boolean masks produced by [`detect_dead_pixels`],
29//! [`detect_dead_pixels_chunked`], [`detect_hot_pixels`], and
30//! [`detect_bad_pixels`] exist for exactly one purpose: excluding pixels
31//! whose data stream is broken in a way that would corrupt the downstream
32//! pipeline.  Downstream, a mask is a hard exclude — masked pixels are never
33//! fitted and appear as NaN in every result map (`nereids-pipeline`'s
34//! `spatial_map_typed` skips them entirely; see
35//! `crates/nereids-pipeline/src/spatial.rs`).
36//!
37//! The masks are **not** a data-quality or coverage filter:
38//!
39//! - **Low-count pixels are alive and MUST be kept.**  KL-domain fitting
40//!   handles them correctly; a statistical low-count screen was measured to
41//!   reject 13% of an ROI essentially at random (IPTS-37432).
42//! - **Coverage / thickness inhomogeneity is a model concern** (free density
43//!   per region), never a masking concern.
44//! - Deadness/hotness is per-acquisition, so always union the sample and
45//!   open-beam masks — [`detect_bad_pixels`] does this.
46//!
47//! See issue #643 for the methodology discussion.
48//!
49//! ## PLEIADES Reference
50//! - `processing/normalization_ornl.py` — Method 2 implementation
51
52use ndarray::{Array1, Array2, Array3, Axis, Zip};
53
54use crate::error::IoError;
55
56/// Parameters for transmission normalization.
57#[derive(Debug, Clone)]
58pub struct NormalizationParams {
59    /// Proton charge for the sample measurement.
60    pub proton_charge_sample: f64,
61    /// Proton charge for the open-beam measurement.
62    pub proton_charge_ob: f64,
63}
64
65/// Result of normalization: transmission and its uncertainty.
66#[derive(Debug)]
67pub struct NormalizedData {
68    /// Transmission values, shape (n_tof, height, width).
69    pub transmission: Array3<f64>,
70    /// Uncertainty on transmission, shape (n_tof, height, width).
71    pub uncertainty: Array3<f64>,
72}
73
74/// Normalize raw data to transmission using Method 2.
75///
76/// T = (C_sample / C_ob) × (PC_ob / PC_sample)
77///
78/// # Arguments
79/// * `sample` — Raw sample counts, shape (n_tof, height, width).
80/// * `open_beam` — Open-beam counts, shape (n_tof, height, width).
81/// * `params` — Normalization parameters (proton charges).
82/// * `dark_current` — Optional dark-current image to subtract, shape (height, width).
83///   If provided, it is subtracted from each TOF frame of both sample and OB.
84///
85/// # Returns
86/// Normalized transmission and uncertainty arrays.
87pub fn normalize(
88    sample: &Array3<f64>,
89    open_beam: &Array3<f64>,
90    params: &NormalizationParams,
91    dark_current: Option<&ndarray::Array2<f64>>,
92) -> Result<NormalizedData, IoError> {
93    if sample.shape() != open_beam.shape() {
94        return Err(IoError::ShapeMismatch(format!(
95            "Sample shape {:?} != open-beam shape {:?}",
96            sample.shape(),
97            open_beam.shape()
98        )));
99    }
100
101    if !(params.proton_charge_sample > 0.0
102        && params.proton_charge_sample.is_finite()
103        && params.proton_charge_ob > 0.0
104        && params.proton_charge_ob.is_finite())
105    {
106        return Err(IoError::InvalidParameter(
107            "Proton charges must be finite and positive".into(),
108        ));
109    }
110
111    if let Some(dc) = dark_current {
112        let dc_shape = dc.shape();
113        let s_shape = sample.shape();
114        if dc_shape[0] != s_shape[1] || dc_shape[1] != s_shape[2] {
115            return Err(IoError::ShapeMismatch(format!(
116                "dark_current shape {:?} != spatial dimensions ({}, {})",
117                dc_shape, s_shape[1], s_shape[2],
118            )));
119        }
120    }
121
122    // Reject non-finite / negative raw counts up front.  These are detector
123    // counts (and a dark-current estimate), so a NaN or a negative value
124    // signals an upstream loader / TOF-normalisation bug.  Validating here —
125    // rather than letting the per-bin `(x - dc).max(0.0)` clamp below absorb
126    // it — is the whole point of this guard: `NaN.max(0.0) == 0.0` would have
127    // silently turned a corrupt frame into a plausible "zero counts" bin,
128    // exactly the masking the sibling `nereids_fitting::joint_poisson`
129    // `validate_counts` exists to prevent.
130    validate_counts(sample, "sample")?;
131    validate_counts(open_beam, "open_beam")?;
132    if let Some(dc) = dark_current {
133        validate_counts(dc, "dark_current")?;
134    }
135
136    let shape = sample.shape();
137    let (n_tof, height, width) = (shape[0], shape[1], shape[2]);
138
139    let pc_ratio = params.proton_charge_ob / params.proton_charge_sample;
140
141    let mut transmission = Array3::<f64>::zeros((n_tof, height, width));
142    let mut uncertainty = Array3::<f64>::zeros((n_tof, height, width));
143
144    for t in 0..n_tof {
145        for y in 0..height {
146            for x in 0..width {
147                // Dark-current subtraction.  The DC noise contribution to
148                // the uncertainty is omitted — Var(DC) is not included in
149                // the error propagation below.  This is acceptable when DC
150                // is small relative to signal counts (typical for VENUS MCP
151                // detectors), but underestimates σ_T for very low-signal bins
152                // where DC is comparable to the sample or OB counts.
153                //
154                // Inputs are validated finite & non-negative above, so the
155                // subtraction is always finite here; the only way it can go
156                // negative is the legitimate `dc > counts` low-count noise
157                // case (the DC estimate overshoots the measured counts in a
158                // single bin).  Floor that physical edge at 0 — this is NOT
159                // masking bad input (a NaN / negative loader bug was already
160                // rejected), it is the Method-2 convention for a dark-frame
161                // estimate that exceeds the raw counts.
162                let dc = dark_current.map_or(0.0, |dc| dc[[y, x]]);
163                let c_s = (sample[[t, y, x]] - dc).max(0.0);
164                let c_o = (open_beam[[t, y, x]] - dc).max(0.0);
165
166                if c_o > 0.0 {
167                    let t_val = (c_s / c_o) * pc_ratio;
168                    transmission[[t, y, x]] = t_val;
169
170                    // Poisson uncertainty via absolute error propagation.
171                    //
172                    // σ_T = pc_ratio / c_o * √(c_s_eff + c_s² / c_o)
173                    //
174                    // where c_s_eff is the Bayesian floor (Jeffreys prior,
175                    // 0.5 counts) when c_s == 0.  This formula follows from
176                    // propagating Var(c_s)=c_s_eff and Var(c_o)=c_o through
177                    // T = (c_s / c_o) * pc_ratio.
178                    //
179                    // Unlike the relative-error form σ_T = T * √(1/c_s + 1/c_o),
180                    // this absolute form produces σ > 0 even when c_s == 0 (T == 0),
181                    // ensuring downstream weighted fits never see zero uncertainty.
182                    //
183                    // NOTE: c_o is always > 0 here (we are inside the if branch),
184                    // so the old `c_o_eff` dead-code branch is removed.
185                    let c_s_eff = if c_s > 0.0 { c_s } else { 0.5 };
186                    let abs_var_t = (pc_ratio / c_o).powi(2) * (c_s_eff + c_s * c_s / c_o);
187                    uncertainty[[t, y, x]] = abs_var_t.sqrt();
188                } else {
189                    // No open-beam counts: mark as invalid
190                    transmission[[t, y, x]] = 0.0;
191                    uncertainty[[t, y, x]] = f64::INFINITY;
192                }
193            }
194        }
195    }
196
197    Ok(NormalizedData {
198        transmission,
199        uncertainty,
200    })
201}
202
203/// Reject a raw-counts array that contains a non-finite or negative value.
204///
205/// Detector counts are non-negative by construction (zero is legitimate), so a
206/// NaN, ±∞, or negative entry signals an upstream loader / normalisation bug
207/// that must be surfaced rather than silently clamped.  Reports the first
208/// offending flat index and value.
209///
210/// The finite-&-non-negative invariant itself lives in
211/// [`nereids_core::validation::first_non_finite_or_negative`] so that the
212/// `nereids-fitting` joint-Poisson and this I/O loader enforce *identical*
213/// semantics (`NaN < 0.0` is `false`, so the check pairs `is_finite()` with
214/// the order comparison); this wrapper only maps the offending element onto
215/// the `IoError` message wording.
216fn validate_counts<D: ndarray::Dimension>(
217    counts: &ndarray::ArrayBase<impl ndarray::Data<Elem = f64>, D>,
218    field: &str,
219) -> Result<(), IoError> {
220    nereids_core::validation::first_non_finite_or_negative(counts.iter().copied()).map_err(
221        |(i, v)| {
222            IoError::InvalidParameter(format!(
223                "{field} counts at flat index {i} must be finite and >= 0, got {v}"
224            ))
225        },
226    )
227}
228
229/// Extract a single spectrum (all TOF bins) from a pixel in the 3D array.
230///
231/// # Arguments
232/// * `data` — 3D array with shape (n_tof, height, width).
233/// * `y` — Pixel row.
234/// * `x` — Pixel column.
235///
236/// # Returns
237/// 1D array of length n_tof.
238pub fn extract_spectrum(data: &Array3<f64>, y: usize, x: usize) -> Array1<f64> {
239    data.slice(ndarray::s![.., y, x]).to_owned()
240}
241
242/// Average spectra over a rectangular region of interest.
243///
244/// # Arguments
245/// * `data` — 3D array with shape (n_tof, height, width).
246/// * `y_range` — Row range (start..end).
247/// * `x_range` — Column range (start..end).
248///
249/// # Errors
250/// Returns `IoError::InvalidParameter` if the ROI is empty or exceeds the
251/// spatial dimensions of `data`.
252///
253/// # Returns
254/// Averaged 1D spectrum of length n_tof.
255pub fn average_roi(
256    data: &Array3<f64>,
257    y_range: std::ops::Range<usize>,
258    x_range: std::ops::Range<usize>,
259) -> Result<Array1<f64>, IoError> {
260    if y_range.is_empty() || x_range.is_empty() {
261        return Err(IoError::InvalidParameter(
262            "ROI ranges must be non-empty for average_roi".into(),
263        ));
264    }
265    if y_range.end > data.shape()[1] || x_range.end > data.shape()[2] {
266        return Err(IoError::InvalidParameter(format!(
267            "ROI range ({}..{}, {}..{}) exceeds data spatial dims ({}, {})",
268            y_range.start,
269            y_range.end,
270            x_range.start,
271            x_range.end,
272            data.shape()[1],
273            data.shape()[2],
274        )));
275    }
276    let roi = data.slice(ndarray::s![.., y_range, x_range]);
277    // Mean over spatial dimensions (axes 1 and 2).
278    // unwrap is safe here: the ROI is guaranteed non-empty by the check above.
279    Ok(roi.mean_axis(Axis(2)).unwrap().mean_axis(Axis(1)).unwrap())
280}
281
282/// Detect dead pixels (zero counts across all TOF bins of one stack).
283///
284/// Pipeline-integrity screen only — see the module-level "Pixel masks —
285/// pipeline integrity only" section.  Prefer [`detect_bad_pixels`] as the
286/// validating entry point; this function performs no input validation of its
287/// own (backward compatibility: GUI, Python ABI, persisted masks).
288///
289/// Precondition: `data` has been validated finite and non-negative (e.g. by
290/// [`normalize`]).  Under that invariant the exact `== 0.0` test is
291/// intentional:
292///
293/// - Counts are validated non-negative, and `0.0 × efficiency == 0.0` holds
294///   exactly in IEEE 754, so a `<= 0.0` test would be dead code.
295/// - A NaN bin makes a pixel appear *alive* (`NaN == 0.0` is `false`).  This
296///   is deliberate: corrupt input must be rejected upstream, never silently
297///   masked (house anti-masking rule — cf. the validation rationale comment
298///   in [`normalize`]).  [`detect_bad_pixels`] rejects such input up front.
299/// - An empty TOF axis (`shape[0] == 0`) makes the all-zero test vacuously
300///   true for *every* pixel — the whole detector would be reported dead.
301///   This non-validating function keeps that behaviour for backward
302///   compatibility; the validating entry points ([`detect_bad_pixels`],
303///   [`detect_dead_pixels_chunked`], [`detect_hot_pixels`]) reject empty
304///   stacks up front.
305///
306/// # Arguments
307/// * `data` — 3D array with shape (n_tof, height, width).
308///
309/// # Returns
310/// 2D boolean mask, shape (height, width). `true` = dead pixel.
311pub fn detect_dead_pixels(data: &Array3<f64>) -> ndarray::Array2<bool> {
312    let shape = data.shape();
313    let (height, width) = (shape[1], shape[2]);
314    let mut mask = ndarray::Array2::from_elem((height, width), false);
315
316    for y in 0..height {
317        for x in 0..width {
318            let all_zero = (0..shape[0]).all(|t| data[[t, y, x]] == 0.0);
319            mask[[y, x]] = all_zero;
320        }
321    }
322
323    mask
324}
325
326/// Default MAD multiplier for the global (stage-1) cut of
327/// [`detect_hot_pixels`].
328///
329/// The one-sided Gaussian tail at 6 robust σ is P(Z > 6) ≈ 9.9e-10, i.e.
330/// ~2.6e-4 expected false flags on a full 512×512 frame (262 144 pixels) —
331/// on a *unimodal* image the screen essentially never rejects a
332/// statistically plausible pixel, while a railed pixel sits tens of robust
333/// σ above any plausible median.
334///
335/// On a **bimodal** image the global cut alone is not trustworthy: when the
336/// darker population holds the median (a sample covering >50 % of the FOV,
337/// or an aperture-limited open beam), the MAD reflects only the dark
338/// population's internal spread, and *every* bright-region pixel lands
339/// above `med + k·MAD`.  [`detect_hot_pixels`] therefore never flags on the
340/// global cut alone — the local-neighborhood confirmation
341/// ([`HOT_LOCAL_FACTOR`]) must also pass.
342pub const HOT_PIXEL_K_MAD: f64 = 6.0;
343
344/// Local-neighborhood confirmation factor (stage 2) of
345/// [`detect_hot_pixels`].
346///
347/// A pixel that passes the global cut is flagged only if its total also
348/// exceeds `HOT_LOCAL_FACTOR ×` the median total of its available live
349/// 8-neighbors.  The factor separates detector *point defects* from scene
350/// structure:
351///
352/// - A railed/runaway pixel is spatially isolated and typically ≥100× its
353///   neighbors, so it clears 10× with a wide margin.
354/// - Adjacent-pixel scene gradients (beam profile, sample absorption) are
355///   ≤2–3×; even directly across a sharp sample edge only one ring of
356///   neighbors is mixed, and the neighbor *median* stays on the pixel's
357///   own side of the edge.
358/// - A fully-railed 1-px row/column still leaves each railed pixel with
359///   ≥5 normal neighbors of 8, so the neighbor median stays normal and the
360///   line IS caught in a single pass.  Railed CLUSTERS ≥2 px wide are
361///   caught by the stage-2 fixpoint erosion — see the
362///   "Fixpoint erosion of railed clusters" section of
363///   [`detect_hot_pixels`].
364///
365/// **Width-1 limitation (accepted trade-off)**: a 1-px-wide bright *scene*
366/// line at ≥`HOT_LOCAL_FACTOR`× local contrast is spatially
367/// indistinguishable from a railed line and IS masked.  Contiguous bright
368/// regions ≥2 px wide are safe (their boundary pixels keep a same-side
369/// neighbor median, so the erosion never seeds — see [`detect_hot_pixels`],
370/// "Why bright scene regions never erode").  Real scene features on VENUS
371/// are PSF-blurred over ≥2 px, so ≥10× single-pixel scene contrast is
372/// physically rare; masking it is the accepted price for catching railed
373/// rows/columns.
374pub const HOT_LOCAL_FACTOR: f64 = 10.0;
375
376/// Reject a stack with an empty TOF axis (`shape[0] == 0`).
377///
378/// Every-bin predicates are vacuously true over zero bins: an empty stack
379/// would mark the whole detector dead (and gives the hot screen an all-zero
380/// totals image) with no error.  Called up front by the validating detector
381/// entry points; [`detect_dead_pixels`] deliberately stays non-validating
382/// (see its rustdoc).
383fn validate_n_tof(data: &Array3<f64>, field: &str) -> Result<(), IoError> {
384    if data.shape()[0] == 0 {
385        return Err(IoError::InvalidParameter(format!(
386            "{field} has an empty TOF axis (shape[0] == 0) — dead/hot detection \
387             over zero bins is vacuous and would mask every pixel"
388        )));
389    }
390    Ok(())
391}
392
393/// Detect dead pixels across acquisition chunks (dead-in-any-chunk).
394///
395/// Catches *intermittent* deadness that [`detect_dead_pixels`] on the summed
396/// stack cannot see: a pixel that was dead for one acquisition chunk but
397/// alive in another has nonzero summed counts, yet its dead-chunk data
398/// corrupts the combined spectrum.  A pixel is flagged iff it is all-zero
399/// (exact `== 0.0` test, same rationale as [`detect_dead_pixels`]) in *any*
400/// chunk.
401///
402/// False-positive control: a live pixel with expected total counts λ within
403/// one chunk is all-zero in that chunk with probability P = e^(−λ) (Poisson);
404/// over m chunks, P(misflag) ≤ m·e^(−λ).  Guidance: chunk the acquisition so
405/// each live pixel has λ ≥ 20 expected counts per chunk — e^(−20) ≈ 2e-9,
406/// i.e. ~5e-4·m expected false flags on a 512² detector.
407///
408/// There is deliberately **no** per-TOF-block zero-run variant.  Within one
409/// TOF-summed stack, wall-clock-intermittent deadness is invisible — the
410/// pixel just shows uniformly reduced counts across all TOF bins, with no
411/// zeros to find.  And any within-stack zero-run cut is a statistical screen
412/// on low-count pixels (a pixel at 0.01 counts/bin normally has ~100-bin
413/// zero runs) — exactly the banned failure mode (see the module-level
414/// "Pixel masks — pipeline integrity only" section).
415///
416/// Chunks may have differing TOF axis lengths (`n_tof`) — ragged event-mode
417/// re-histogramming is fine; deadness is spatial, so only the spatial
418/// dimensions must agree.
419///
420/// # Arguments
421/// * `chunks` — One 3D counts array per acquisition chunk, each with shape
422///   (n_tof, height, width).  `n_tof` may differ between chunks; (height,
423///   width) must not.
424///
425/// # Returns
426/// 2D boolean mask, shape (height, width). `true` = dead in at least one
427/// chunk.
428///
429/// # Errors
430/// Returns `IoError::InvalidParameter` if `chunks` is empty, any chunk has
431/// an empty TOF axis (`shape[0] == 0` — its all-zero test would vacuously
432/// mark every pixel dead), or any chunk contains a non-finite or negative
433/// value, and `IoError::ShapeMismatch` if the chunks' spatial dimensions
434/// differ.
435pub fn detect_dead_pixels_chunked(chunks: &[Array3<f64>]) -> Result<Array2<bool>, IoError> {
436    if chunks.is_empty() {
437        return Err(IoError::InvalidParameter(
438            "detect_dead_pixels_chunked requires at least one chunk".into(),
439        ));
440    }
441
442    let first = chunks[0].shape();
443    let (height, width) = (first[1], first[2]);
444    for (i, chunk) in chunks.iter().enumerate() {
445        let s = chunk.shape();
446        // n_tof (s[0]) may differ between chunks; only spatial dims must agree.
447        if s[1] != height || s[2] != width {
448            return Err(IoError::ShapeMismatch(format!(
449                "chunks[{i}] spatial dims ({}, {}) != chunks[0] spatial dims ({height}, {width})",
450                s[1], s[2],
451            )));
452        }
453        validate_counts(chunk, &format!("chunks[{i}]"))?;
454        validate_n_tof(chunk, &format!("chunks[{i}]"))?;
455    }
456
457    let mut mask = Array2::from_elem((height, width), false);
458    for chunk in chunks {
459        let dead = detect_dead_pixels(chunk);
460        Zip::from(&mut mask)
461            .and(&dead)
462            .for_each(|m, &d| *m = *m || d);
463    }
464    Ok(mask)
465}
466
467/// Detect hot (railed / runaway) pixels via a two-stage criterion: a
468/// robust one-sided log-space median + k·MAD screen on per-pixel total
469/// counts (stage 1, global), confirmed by a local-neighborhood isolation
470/// test (stage 2).
471///
472/// Pipeline-integrity screen only — see the module-level "Pixel masks —
473/// pipeline integrity only" section.  The algorithm:
474///
475/// 1. `totals[y, x] = Σ_t data[t, y, x]`.
476/// 2. The statistics sample is `ln(totals)` over `totals > 0` pixels *only* —
477///    dead pixels are excluded *before* the median/MAD so `ln(0)` never
478///    enters and a large dead population cannot drag the median down.  If no
479///    live pixels exist, every pixel is unflagged (all-`false` mask).
480/// 3. `med = median(ln totals)`, `mad = median(|ln totals − med|)`.
481/// 4. `sigma = max(MAD_TO_SIGMA·mad, exp(−med/2))`.  The second term is the
482///    delta-method Poisson floor of `ln N`: `Var[ln N] ≈ 1/N` evaluated at
483///    `N = exp(med)` (medians commute with monotone maps, so `exp(med)` is
484///    the median total), giving `σ_floor = 1/√exp(med) = exp(−med/2)`.  The
485///    robust scale can never legitimately sit below counting noise; this
486///    guards `mad == 0` on quantized low-count images *without* becoming a
487///    low-count screen — it only ever raises the threshold.  Worked check:
488///    an image where most totals are 1.0 and some are 2.0 has `mad = 0` and
489///    floor `= 1`, so the threshold is `e^6 ≈ 403×` the median — the 2-count
490///    pixels are NOT flagged, while a railed pixel still is.
491/// 5. Stage 1 (global): a pixel is a *candidate* iff `totals > 0 &&
492///    ln(total) > med + k_mad·sigma` — **upper tail only**.  A stuck-low
493///    pixel is indistinguishable from a low-count-alive pixel and is
494///    deliberately kept (masking it would be the banned low-count screen).
495///    Railed/always-max pixels are subsumed by the upper tail: no fixed
496///    saturation value exists after efficiency correction, so a
497///    saturation-constant test would be wrong anyway.
498/// 6. Stage 2 (local confirmation), iterated to a **fixpoint**: a candidate
499///    is flagged iff its total also exceeds [`HOT_LOCAL_FACTOR`] × the
500///    median of its 8-neighborhood reference sample, where each neighbor
501///    contributes its total if live (`total > 0`) and not yet flagged,
502///    contributes `0.0` if already flagged (a known defect cannot vouch
503///    for its neighbors — see below), and is omitted if dead (a dead pixel
504///    carries no scene information).  Edge pixels use whatever neighbors
505///    exist.  A candidate whose reference sample is empty (every neighbor
506///    dead — isolated live pixel in a dead field) keeps the global verdict;
507///    a candidate whose neighbors are mostly flagged defects likewise stays
508///    flagged (its reference median is 0).  After each full pass over the
509///    fixed stage-1 candidate list, newly confirmed flags are applied and
510///    the pass repeats until a pass adds no new flag.
511///
512/// # Fixpoint erosion of railed clusters
513///
514/// A single stage-2 pass misses the INTERIOR of a railed cluster ≥2 px
515/// wide: an interior pixel's 8-neighbors are railed too, so its neighbor
516/// median is railed and the ratio test refutes the flag.  Iterating to a
517/// fixpoint erodes such clusters from the boundary inward — once the
518/// cluster's outermost pixels are flagged they stop vouching (each
519/// contributes `0.0` instead of its railed total), the reference median of
520/// the next ring drops back to the background level, and the next pass
521/// flags that ring.  Contributing `0.0` (rather than omitting the flagged
522/// neighbor) is load-bearing: with omission, a 3-background + 3-railed
523/// reference sample has an even-count [`nereids_core::stats::median`]
524/// midpoint mid-gap (≈ railed/2), the ratio test reads ~2× and the erosion
525/// stalls; with the zero contribution the median stays on the background
526/// side and erosion completes.  Erosion fully consumes clusters up to 3 px
527/// wide in their narrower dimension (point defects, 1-px lines, 2–3-px-wide
528/// blobs/segments — the physical shapes of railed detector defects)
529/// PROVIDED the cluster exposes at least one end cap or convex corner to
530/// normal-scene neighbors — erosion must seed somewhere.  An EDGE-TO-EDGE
531/// railed band ≥2 px wide (both ends off-detector — spanning the full
532/// detector width or height) exposes neither: every interior band pixel
533/// keeps ≥5 railed of 8 neighbors, and even the on-detector-border band
534/// ends keep 3 railed of 5, so every neighbor median stays railed, no
535/// pixel ever seeds, and the band is NOT caught
536/// (`test_detect_hot_pixels_edge_to_edge_2px_band_not_flagged_by_design`).
537/// This is deliberate, not an oversight: a slit-aperture open beam
538/// produces a genuine full-width bright SCENE band that is
539/// pixel-for-pixel indistinguishable from such a defect, so a full-span
540/// row/column screen would mask it — re-introducing the exact bimodal
541/// failure stage 2 exists to prevent.  Full-span detector pathologies of
542/// width ≥2 belong in a declared/file mask.  (A full-span width-1 railed
543/// line IS caught — each of its pixels keeps ≥4 normal neighbors,
544/// `test_detect_hot_pixels_full_railed_column_caught`; and a ≥2-px band
545/// with even one end cap inside the detector is fully consumed from that
546/// cap, `test_detect_hot_pixels_2px_band_one_end_on_detector_fully_caught`.)
547/// A hard-edged railed rectangle ≥4 px wide keeps its interior (only its
548/// convex corners flag): it is pixel-for-pixel indistinguishable from a
549/// hard-edged bright scene region, which must survive (below).
550///
551/// **Termination bound**: flags are only ever added, and every pass except
552/// the last adds at least one, so at most `height·width` passes can do
553/// work; the loop is additionally capped at `height·width` passes to make
554/// the bound structural rather than reasoned.  In practice the pass count
555/// is on the order of the defect-cluster radius (one pass for point
556/// defects and 1-px lines).
557///
558/// # Why bright scene regions never erode
559///
560/// Erosion must *seed* at a bright-region boundary pixel.  A boundary
561/// pixel of a contiguous bright scene region ≥2 px wide keeps ≥4 of its
562/// 8 neighbors on its own (bright) side for any straight or diagonal
563/// edge, so its reference median stays bright and its ratio is the scene
564/// gradient (≤2–3× across real edges) — far below the ≥10×
565/// [`HOT_LOCAL_FACTOR`].  Stage 1's global cut additionally gates which
566/// pixels can seed at all.  With no seed, the fixpoint is reached with
567/// zero flags in the region and it survives intact
568/// (`test_detect_hot_pixels_large_psf_bright_region_not_eroded`).  Two
569/// documented, test-pinned exceptions — both physically rare on VENUS,
570/// where the detector PSF blurs real scene features over ≥2 px so ≥10×
571/// single-pixel contrast steps do not occur in scene:
572///
573/// - a **width-1 bright line** at ≥10× local contrast is spatially
574///   indistinguishable from a railed line and IS masked — the accepted
575///   trade-off for catching railed rows/columns
576///   (`test_detect_hot_pixels_1px_bright_line_flagged_by_design`);
577/// - the single pixel at a **sharp convex (90°) corner** of a hard-edged
578///   ≥10× region sees only 3 same-side neighbors, its reference median
579///   falls on the dark side, and it is flagged (this predates the
580///   fixpoint); erosion does NOT propagate past it — the adjacent edge
581///   pixels keep ≥4 bright unflagged neighbors
582///   (`test_detect_hot_pixels_hard_edged_bright_rectangle_corners_only`).
583///
584/// The two stages encode complementary definitions of "hot": stage 1 says
585/// *statistically implausible for this image*, stage 2 says *spatially
586/// isolated*, and a railed/hot pixel is a detector **point defect** — it
587/// must be both.  Stage 2 exists because the global cut alone fails
588/// catastrophically on **bimodal** images: with a dark majority holding the
589/// median (a sample covering >50 % of the FOV, or an aperture-limited open
590/// beam), the MAD reflects only the dark population's internal spread and
591/// the ENTIRE bright minority exceeds `med + k·MAD`.  A contiguous bright
592/// REGION is scene, not a defect — masking it would reject statistically
593/// plausible pixels, the exact failure the module rules ban.  A true point
594/// defect beats its neighbor median by ≥100× and is caught by both stages
595/// even *inside* a bright region and even as part of a railed row/column
596/// or a small railed cluster (see [`HOT_LOCAL_FACTOR`]).
597///
598/// # Raw counts required
599///
600/// `data` must be **raw detected counts** (unscaled).  The Poisson floor in
601/// step 4 assumes `Var[N] = N`; any prior scaling silently breaks that
602/// identity — down-scaling (proton-charge-normalized rates ≪ 1, per-pixel
603/// gain division) inflates the floor and can suppress real flags, while
604/// up-scaling (event weights > 1) deflates it below true counting noise.
605/// Run the detectors on unscaled counts and normalize afterwards; the GUI
606/// does exactly this (all three of its raw-counts paths — TIFF pair,
607/// HDF5 with open beam, HDF5 without open beam — pass the raw
608/// sample/open-beam stacks, before any normalization).
609///
610/// # Arguments
611/// * `data` — 3D raw-counts array with shape (n_tof, height, width).
612/// * `k_mad` — Robust-σ multiplier for the stage-1 upper-tail cut; use
613///   [`HOT_PIXEL_K_MAD`] unless you have a reason not to.
614///
615/// # Returns
616/// 2D boolean mask, shape (height, width). `true` = hot pixel.
617///
618/// # Errors
619/// Returns `IoError::InvalidParameter` if `data` contains a non-finite or
620/// negative value or has an empty TOF axis (`shape[0] == 0`), or if `k_mad`
621/// is not finite and positive.
622pub fn detect_hot_pixels(data: &Array3<f64>, k_mad: f64) -> Result<Array2<bool>, IoError> {
623    validate_counts(data, "data")?;
624    // An empty TOF axis would yield an all-zero totals image (empty live
625    // set → all-false mask) rather than corrupt output, but the validating
626    // entry points reject it uniformly (see validate_n_tof).
627    validate_n_tof(data, "data")?;
628    // NaN bypasses `>`, so pair the order comparison with is_finite().
629    if !(k_mad.is_finite() && k_mad > 0.0) {
630        return Err(IoError::InvalidParameter(format!(
631            "k_mad must be finite and > 0, got {k_mad}"
632        )));
633    }
634
635    let totals = data.sum_axis(Axis(0));
636    let mut mask = Array2::from_elem(totals.raw_dim(), false);
637
638    // Statistics over live (totals > 0) pixels only: ln(0) never enters, and
639    // a large dead population cannot drag the median down.
640    let log_totals: Vec<f64> = totals
641        .iter()
642        .filter(|&&t| t > 0.0)
643        .map(|&t| t.ln())
644        .collect();
645    let Some(med) = nereids_core::stats::median(&log_totals) else {
646        // No live pixels at all: nothing to compare against — flag nothing.
647        return Ok(mask);
648    };
649    // unwrap is safe here: median() returned Some, so log_totals is non-empty.
650    let mad = nereids_core::stats::median_abs_deviation(&log_totals, med).unwrap();
651
652    // Delta-method Poisson floor of ln N at the median total (see rustdoc
653    // step 4): the robust scale can never sit below counting noise.
654    let sigma = f64::max(nereids_core::stats::MAD_TO_SIGMA * mad, (-med / 2.0).exp());
655    let threshold = med + k_mad * sigma;
656
657    // Stage 1 (global robust cut), upper tail only: the candidate list is
658    // fixed for the whole stage-2 fixpoint iteration below.
659    let (height, width) = totals.dim();
660    let mut candidates: Vec<(usize, usize)> = Vec::new();
661    for y in 0..height {
662        for x in 0..width {
663            let total = totals[[y, x]];
664            if total > 0.0 && total.ln() > threshold {
665                candidates.push((y, x));
666            }
667        }
668    }
669
670    // Stage 2 (local confirmation): only a spatially ISOLATED excess is a
671    // detector point defect — a contiguous bright region is scene.  This is
672    // what keeps the global cut honest on bimodal images (see rustdoc).
673    //
674    // Iterated to a FIXPOINT to erode railed clusters from their boundary
675    // inward (see rustdoc, "Fixpoint erosion of railed clusters"): each
676    // pass evaluates the not-yet-flagged candidates against the PREVIOUS
677    // pass's mask (batch update — order-independent within a pass), and
678    // the loop ends when a pass adds no new flag.
679    //
680    // Termination bound: flags are only ever added and every pass except
681    // the last adds at least one, so ≤ height·width passes can do work;
682    // the explicit cap makes that bound structural.  Pass 1 sees an
683    // all-false mask and is exactly the pre-fixpoint single pass.
684    let max_passes = height * width;
685    let mut neighbor_totals: Vec<f64> = Vec::with_capacity(8);
686    for _pass in 0..max_passes {
687        let mut newly_flagged: Vec<(usize, usize)> = Vec::new();
688        for &(y, x) in &candidates {
689            if mask[[y, x]] {
690                continue;
691            }
692            let total = totals[[y, x]];
693            // 8-neighborhood reference sample; edge pixels use what exists.
694            neighbor_totals.clear();
695            for ny in y.saturating_sub(1)..=(y + 1).min(height - 1) {
696                for nx in x.saturating_sub(1)..=(x + 1).min(width - 1) {
697                    if (ny, nx) == (y, x) {
698                        continue;
699                    }
700                    if mask[[ny, nx]] {
701                        // Already-flagged neighbor: a known defect cannot
702                        // vouch for the candidate.  It contributes the
703                        // lowest possible scene value (zero total) so the
704                        // reference median is not dragged up by the defect
705                        // itself — load-bearing for cluster erosion (see
706                        // rustdoc: an omitted neighbor leaves an even-count
707                        // sample whose midpoint median stalls the erosion).
708                        neighbor_totals.push(0.0);
709                    } else {
710                        // Live (total > 0) unflagged neighbors contribute
711                        // their totals; dead neighbors carry no scene
712                        // information and are omitted.
713                        let t = totals[[ny, nx]];
714                        if t > 0.0 {
715                            neighbor_totals.push(t);
716                        }
717                    }
718                }
719            }
720            let flagged = match nereids_core::stats::median(&neighbor_totals) {
721                Some(local_med) => total > HOT_LOCAL_FACTOR * local_med,
722                // Empty sample: every neighbor is dead (isolated live pixel
723                // in a dead field) — nothing local can refute the global
724                // verdict.  (A mostly-flagged neighborhood is NOT empty:
725                // the zeros give a reference median of 0 and the candidate
726                // stays flagged, the consistent generalization.)
727                None => true,
728            };
729            if flagged {
730                newly_flagged.push((y, x));
731            }
732        }
733        if newly_flagged.is_empty() {
734            break;
735        }
736        for &(y, x) in &newly_flagged {
737            mask[[y, x]] = true;
738        }
739    }
740    Ok(mask)
741}
742
743/// Detect all pipeline-corrupting pixels: dead ∪ hot over sample and
744/// (optionally) open beam.
745///
746/// This is the validating entry point that the GUI and Python bindings
747/// should use.  Deadness/hotness is per-acquisition — a pixel dead only in
748/// the open-beam run still corrupts every transmission ratio computed from
749/// it — so the masks of both stacks are unioned:
750///
751/// `mask = dead(sample) ∪ hot(sample) [∪ dead(open_beam) ∪ hot(open_beam)]`
752///
753/// The stacks' TOF axis lengths may differ (deadness is spatial); only the
754/// spatial dimensions must agree.
755///
756/// Both stacks must be **raw detected counts** (unscaled) — see the "Raw
757/// counts required" section of [`detect_hot_pixels`]: scaling distorts the
758/// Poisson floor of the hot screen.  The GUI satisfies this: all three of
759/// its raw-counts paths (TIFF pair, HDF5 with open beam, HDF5 without
760/// open beam) call this function on the raw sample/open-beam stacks,
761/// before any normalization.
762///
763/// # Arguments
764/// * `sample` — Raw sample counts, shape (n_tof, height, width).
765/// * `open_beam` — Optional raw open-beam counts, shape
766///   (n_tof', height, width).
767/// * `hot_k_mad` — `Some(k)` to include the [`detect_hot_pixels`] screen
768///   with multiplier `k` (use [`HOT_PIXEL_K_MAD`]); `None` for dead-only
769///   detection.
770///
771/// # Returns
772/// 2D boolean mask, shape (height, width). `true` = exclude pixel.
773///
774/// # Errors
775/// Returns `IoError::InvalidParameter` if either stack contains a
776/// non-finite or negative value or has an empty TOF axis (`shape[0] == 0`
777/// — the dead test over zero bins would vacuously mask every pixel) or
778/// `hot_k_mad` is `Some` of a non-finite/non-positive value, and
779/// `IoError::ShapeMismatch` if the spatial dimensions differ.
780pub fn detect_bad_pixels(
781    sample: &Array3<f64>,
782    open_beam: Option<&Array3<f64>>,
783    hot_k_mad: Option<f64>,
784) -> Result<Array2<bool>, IoError> {
785    // Validate everything up front (house rule), before any detector runs.
786    // The public detectors called below re-validate; that duplication is one
787    // O(n) sweep and keeps each entry point independently safe.
788    validate_counts(sample, "sample")?;
789    validate_n_tof(sample, "sample")?;
790    if let Some(ob) = open_beam {
791        validate_counts(ob, "open_beam")?;
792        validate_n_tof(ob, "open_beam")?;
793        let (ss, os) = (sample.shape(), ob.shape());
794        // n_tof may differ (deadness is spatial); spatial dims must agree.
795        if ss[1] != os[1] || ss[2] != os[2] {
796            return Err(IoError::ShapeMismatch(format!(
797                "sample spatial dims ({}, {}) != open_beam spatial dims ({}, {})",
798                ss[1], ss[2], os[1], os[2],
799            )));
800        }
801    }
802    if let Some(k) = hot_k_mad {
803        // NaN bypasses `>`, so pair the order comparison with is_finite().
804        if !(k.is_finite() && k > 0.0) {
805            return Err(IoError::InvalidParameter(format!(
806                "hot_k_mad must be finite and > 0, got {k}"
807            )));
808        }
809    }
810
811    let mut mask = detect_dead_pixels(sample);
812    if let Some(k) = hot_k_mad {
813        let hot = detect_hot_pixels(sample, k)?;
814        Zip::from(&mut mask)
815            .and(&hot)
816            .for_each(|m, &h| *m = *m || h);
817    }
818    if let Some(ob) = open_beam {
819        let ob_dead = detect_dead_pixels(ob);
820        Zip::from(&mut mask)
821            .and(&ob_dead)
822            .for_each(|m, &d| *m = *m || d);
823        if let Some(k) = hot_k_mad {
824            let ob_hot = detect_hot_pixels(ob, k)?;
825            Zip::from(&mut mask)
826                .and(&ob_hot)
827                .for_each(|m, &h| *m = *m || h);
828        }
829    }
830    Ok(mask)
831}
832
833#[cfg(test)]
834mod tests {
835    use super::*;
836    use ndarray::Array2;
837
838    #[test]
839    fn test_normalize_equal_charges() {
840        // Equal proton charges, PC ratio = 1
841        // C_s = 50, C_o = 100 → T = 0.5
842        let sample = Array3::from_elem((1, 1, 1), 50.0);
843        let ob = Array3::from_elem((1, 1, 1), 100.0);
844        let params = NormalizationParams {
845            proton_charge_sample: 1.0,
846            proton_charge_ob: 1.0,
847        };
848
849        let result = normalize(&sample, &ob, &params, None).unwrap();
850        assert!((result.transmission[[0, 0, 0]] - 0.5).abs() < 1e-10);
851
852        // Uncertainty: σ_T = T × √(1/50 + 1/100) = 0.5 × √(0.03) ≈ 0.0866
853        let expected_unc = 0.5 * (1.0 / 50.0 + 1.0 / 100.0_f64).sqrt();
854        assert!(
855            (result.uncertainty[[0, 0, 0]] - expected_unc).abs() < 1e-10,
856            "got {}, expected {}",
857            result.uncertainty[[0, 0, 0]],
858            expected_unc,
859        );
860    }
861
862    #[test]
863    fn test_normalize_proton_charge_correction() {
864        // PC_sample = 2, PC_ob = 1 → ratio = 0.5
865        // C_s = 100, C_o = 100 → T = 1.0 × 0.5 = 0.5
866        let sample = Array3::from_elem((1, 1, 1), 100.0);
867        let ob = Array3::from_elem((1, 1, 1), 100.0);
868        let params = NormalizationParams {
869            proton_charge_sample: 2.0,
870            proton_charge_ob: 1.0,
871        };
872
873        let result = normalize(&sample, &ob, &params, None).unwrap();
874        assert!((result.transmission[[0, 0, 0]] - 0.5).abs() < 1e-10);
875    }
876
877    #[test]
878    fn test_normalize_with_dark_current() {
879        // C_s_raw = 60, C_o_raw = 110, DC = 10
880        // C_s = 50, C_o = 100 → T = 0.5
881        let sample = Array3::from_elem((1, 1, 1), 60.0);
882        let ob = Array3::from_elem((1, 1, 1), 110.0);
883        let dc = Array2::from_elem((1, 1), 10.0);
884        let params = NormalizationParams {
885            proton_charge_sample: 1.0,
886            proton_charge_ob: 1.0,
887        };
888
889        let result = normalize(&sample, &ob, &params, Some(&dc)).unwrap();
890        assert!((result.transmission[[0, 0, 0]] - 0.5).abs() < 1e-10);
891    }
892
893    #[test]
894    fn test_normalize_zero_ob() {
895        // Zero open-beam counts → T = 0, uncertainty = INF
896        let sample = Array3::from_elem((1, 1, 1), 50.0);
897        let ob = Array3::from_elem((1, 1, 1), 0.0);
898        let params = NormalizationParams {
899            proton_charge_sample: 1.0,
900            proton_charge_ob: 1.0,
901        };
902
903        let result = normalize(&sample, &ob, &params, None).unwrap();
904        assert_eq!(result.transmission[[0, 0, 0]], 0.0);
905        assert!(result.uncertainty[[0, 0, 0]].is_infinite());
906    }
907
908    #[test]
909    fn test_normalize_shape_mismatch() {
910        let sample = Array3::from_elem((2, 3, 4), 1.0);
911        let ob = Array3::from_elem((2, 3, 5), 1.0);
912        let params = NormalizationParams {
913            proton_charge_sample: 1.0,
914            proton_charge_ob: 1.0,
915        };
916
917        let result = normalize(&sample, &ob, &params, None);
918        assert!(result.is_err());
919    }
920
921    #[test]
922    fn test_normalize_rejects_nan_sample() {
923        // A NaN in the sample frame used to be swallowed by
924        // `(NaN - 0).max(0.0) == 0.0`, silently producing T = 0 as if the
925        // bin had genuinely zero counts.  It must now be rejected up front.
926        let mut sample = Array3::from_elem((1, 1, 1), 50.0);
927        sample[[0, 0, 0]] = f64::NAN;
928        let ob = Array3::from_elem((1, 1, 1), 100.0);
929        let params = NormalizationParams {
930            proton_charge_sample: 1.0,
931            proton_charge_ob: 1.0,
932        };
933        let err = normalize(&sample, &ob, &params, None).unwrap_err();
934        assert!(
935            matches!(err, IoError::InvalidParameter(_)),
936            "expected InvalidParameter, got {err:?}"
937        );
938        assert!(err.to_string().contains("sample"));
939    }
940
941    #[test]
942    fn test_normalize_rejects_negative_sample() {
943        // Negative raw counts (loader bug) used to be clamped to 0.
944        let mut sample = Array3::from_elem((1, 1, 1), 50.0);
945        sample[[0, 0, 0]] = -5.0;
946        let ob = Array3::from_elem((1, 1, 1), 100.0);
947        let params = NormalizationParams {
948            proton_charge_sample: 1.0,
949            proton_charge_ob: 1.0,
950        };
951        let err = normalize(&sample, &ob, &params, None).unwrap_err();
952        assert!(err.to_string().contains("sample"));
953    }
954
955    #[test]
956    fn test_normalize_rejects_nan_open_beam() {
957        let sample = Array3::from_elem((1, 1, 1), 50.0);
958        let mut ob = Array3::from_elem((1, 1, 1), 100.0);
959        ob[[0, 0, 0]] = f64::INFINITY;
960        let params = NormalizationParams {
961            proton_charge_sample: 1.0,
962            proton_charge_ob: 1.0,
963        };
964        let err = normalize(&sample, &ob, &params, None).unwrap_err();
965        assert!(err.to_string().contains("open_beam"));
966    }
967
968    #[test]
969    fn test_normalize_rejects_negative_dark_current() {
970        let sample = Array3::from_elem((1, 1, 1), 60.0);
971        let ob = Array3::from_elem((1, 1, 1), 110.0);
972        let mut dc = Array2::from_elem((1, 1), 10.0);
973        dc[[0, 0]] = -1.0;
974        let params = NormalizationParams {
975            proton_charge_sample: 1.0,
976            proton_charge_ob: 1.0,
977        };
978        let err = normalize(&sample, &ob, &params, Some(&dc)).unwrap_err();
979        assert!(err.to_string().contains("dark_current"));
980    }
981
982    #[test]
983    fn test_extract_spectrum() {
984        // 3 TOF bins, 2×2 image
985        let mut data = Array3::<f64>::zeros((3, 2, 2));
986        data[[0, 1, 0]] = 10.0;
987        data[[1, 1, 0]] = 20.0;
988        data[[2, 1, 0]] = 30.0;
989
990        let spectrum = extract_spectrum(&data, 1, 0);
991        assert_eq!(spectrum.len(), 3);
992        assert_eq!(spectrum[0], 10.0);
993        assert_eq!(spectrum[1], 20.0);
994        assert_eq!(spectrum[2], 30.0);
995    }
996
997    #[test]
998    fn test_average_roi() {
999        // 2 TOF bins, 4×4 image. Set a 2×2 region to known values.
1000        let mut data = Array3::<f64>::zeros((2, 4, 4));
1001        // TOF bin 0: region [1..3, 1..3] = 100
1002        for y in 1..3 {
1003            for x in 1..3 {
1004                data[[0, y, x]] = 100.0;
1005                data[[1, y, x]] = 200.0;
1006            }
1007        }
1008
1009        let avg = average_roi(&data, 1..3, 1..3).unwrap();
1010        assert_eq!(avg.len(), 2);
1011        assert!((avg[0] - 100.0).abs() < 1e-10);
1012        assert!((avg[1] - 200.0).abs() < 1e-10);
1013    }
1014
1015    #[test]
1016    fn test_normalize_zero_sample_counts() {
1017        // Zero sample counts should produce finite (not NaN) uncertainty
1018        // thanks to the Bayesian floor of 0.5.
1019        let sample = Array3::from_elem((1, 1, 1), 0.0);
1020        let ob = Array3::from_elem((1, 1, 1), 100.0);
1021        let params = NormalizationParams {
1022            proton_charge_sample: 1.0,
1023            proton_charge_ob: 1.0,
1024        };
1025
1026        let result = normalize(&sample, &ob, &params, None).unwrap();
1027        assert_eq!(result.transmission[[0, 0, 0]], 0.0);
1028        assert!(
1029            result.uncertainty[[0, 0, 0]].is_finite(),
1030            "uncertainty should be finite for zero sample counts, got {}",
1031            result.uncertainty[[0, 0, 0]]
1032        );
1033        assert!(
1034            result.uncertainty[[0, 0, 0]] > 0.0,
1035            "uncertainty should be strictly positive for zero sample counts (Bayesian floor), got {}",
1036            result.uncertainty[[0, 0, 0]]
1037        );
1038    }
1039
1040    #[test]
1041    fn test_normalize_zero_open_beam() {
1042        // Zero OB counts should produce infinite uncertainty (marking
1043        // the pixel as invalid), and the uncertainty must not be NaN.
1044        let sample = Array3::from_elem((1, 1, 1), 50.0);
1045        let ob = Array3::from_elem((1, 1, 1), 0.0);
1046        let params = NormalizationParams {
1047            proton_charge_sample: 1.0,
1048            proton_charge_ob: 1.0,
1049        };
1050
1051        let result = normalize(&sample, &ob, &params, None).unwrap();
1052        assert_eq!(result.transmission[[0, 0, 0]], 0.0);
1053        assert!(
1054            !result.uncertainty[[0, 0, 0]].is_nan(),
1055            "uncertainty must not be NaN for zero OB counts"
1056        );
1057        assert!(
1058            result.uncertainty[[0, 0, 0]].is_infinite(),
1059            "uncertainty should be infinite for zero OB counts"
1060        );
1061    }
1062
1063    #[test]
1064    fn test_normalize_dark_current_shape_mismatch() {
1065        let sample = Array3::from_elem((2, 3, 4), 1.0);
1066        let ob = Array3::from_elem((2, 3, 4), 1.0);
1067        let dc = Array2::from_elem((2, 4), 0.0); // wrong shape
1068        let params = NormalizationParams {
1069            proton_charge_sample: 1.0,
1070            proton_charge_ob: 1.0,
1071        };
1072
1073        let result = normalize(&sample, &ob, &params, Some(&dc));
1074        assert!(
1075            result.is_err(),
1076            "should reject mismatched dark_current shape"
1077        );
1078    }
1079
1080    /// Verify that σ > 0 for zero sample counts ensures finite LM weight.
1081    /// This is the Bayesian floor guarantee: weight = 1/σ² must not be ∞.
1082    #[test]
1083    fn test_normalize_zero_sample_produces_finite_lm_weight() {
1084        let sample = Array3::from_elem((5, 1, 1), 0.0);
1085        let ob = Array3::from_elem((5, 1, 1), 500.0);
1086        let params = NormalizationParams {
1087            proton_charge_sample: 1.0,
1088            proton_charge_ob: 1.0,
1089        };
1090
1091        let result = normalize(&sample, &ob, &params, None).unwrap();
1092        for t in 0..5 {
1093            let sigma = result.uncertainty[[t, 0, 0]];
1094            assert!(
1095                sigma.is_finite() && sigma > 0.0,
1096                "σ must be finite and positive at T=0, got {sigma}"
1097            );
1098            let weight = 1.0 / (sigma * sigma);
1099            assert!(
1100                weight.is_finite(),
1101                "LM weight 1/σ² must be finite at T=0, got {weight}"
1102            );
1103        }
1104    }
1105
1106    /// Verify uncertainty at low OB counts is finite and well-behaved.
1107    #[test]
1108    fn test_normalize_low_ob_counts() {
1109        // OB = 2 counts: very low but nonzero
1110        let sample = Array3::from_elem((1, 1, 1), 1.0);
1111        let ob = Array3::from_elem((1, 1, 1), 2.0);
1112        let params = NormalizationParams {
1113            proton_charge_sample: 1.0,
1114            proton_charge_ob: 1.0,
1115        };
1116
1117        let result = normalize(&sample, &ob, &params, None).unwrap();
1118        let sigma = result.uncertainty[[0, 0, 0]];
1119        assert!(sigma.is_finite() && sigma > 0.0, "σ = {sigma}");
1120        // σ should be large relative to T (very noisy at low counts)
1121        let t = result.transmission[[0, 0, 0]];
1122        assert!(
1123            sigma > 0.1 * t,
1124            "σ should be a significant fraction of T at low OB counts: σ={sigma}, T={t}"
1125        );
1126    }
1127
1128    #[test]
1129    fn test_detect_dead_pixels() {
1130        let mut data = Array3::<f64>::zeros((3, 2, 2));
1131        // Pixel (0,0) is dead (all zeros)
1132        // Pixel (0,1) has a count in frame 1
1133        data[[1, 0, 1]] = 5.0;
1134        // Pixel (1,0) has counts
1135        data[[0, 1, 0]] = 10.0;
1136        // Pixel (1,1) is dead
1137
1138        let mask = detect_dead_pixels(&data);
1139        assert!(mask[[0, 0]]); // dead
1140        assert!(!mask[[0, 1]]); // alive
1141        assert!(!mask[[1, 0]]); // alive
1142        assert!(mask[[1, 1]]); // dead
1143    }
1144
1145    #[test]
1146    fn test_detect_dead_pixels_chunked_catches_intermittent() {
1147        // ACCEPTANCE (#643): pixel (0, 1) is dead throughout chunk 0 but
1148        // alive in chunk 1 — intermittent deadness that corrupts the
1149        // combined spectrum.
1150        let mut chunk0 = Array3::from_elem((3, 2, 2), 5.0);
1151        for t in 0..3 {
1152            chunk0[[t, 0, 1]] = 0.0;
1153        }
1154        let chunk1 = Array3::from_elem((3, 2, 2), 5.0);
1155
1156        let mask = detect_dead_pixels_chunked(&[chunk0.clone(), chunk1.clone()]).unwrap();
1157        assert!(mask[[0, 1]], "intermittently dead pixel must be flagged");
1158        assert!(!mask[[0, 0]]);
1159        assert!(!mask[[1, 0]]);
1160        assert!(!mask[[1, 1]]);
1161
1162        // The gap being closed: on the element-wise summed stack the pixel
1163        // has nonzero counts everywhere, so detect_dead_pixels misses it.
1164        let summed = &chunk0 + &chunk1;
1165        let summed_mask = detect_dead_pixels(&summed);
1166        assert!(
1167            !summed_mask[[0, 1]],
1168            "summed-stack detection cannot see intermittent deadness — \
1169             that is exactly why the chunked variant exists"
1170        );
1171    }
1172
1173    #[test]
1174    fn test_detect_dead_pixels_chunked_low_count_alive_not_flagged() {
1175        // ACCEPTANCE (#643): a pixel with a single count per chunk is alive
1176        // and must be kept — masks are pipeline-integrity only, never a
1177        // low-count screen.
1178        let mut chunk0 = Array3::from_elem((4, 2, 2), 5.0);
1179        let mut chunk1 = Array3::from_elem((4, 2, 2), 5.0);
1180        for t in 0..4 {
1181            chunk0[[t, 1, 1]] = 0.0;
1182            chunk1[[t, 1, 1]] = 0.0;
1183        }
1184        chunk0[[2, 1, 1]] = 1.0; // one lone count in chunk 0
1185        chunk1[[0, 1, 1]] = 1.0; // one lone count in chunk 1
1186
1187        let mask = detect_dead_pixels_chunked(&[chunk0, chunk1]).unwrap();
1188        assert!(!mask[[1, 1]], "low-count-alive pixel must not be flagged");
1189    }
1190
1191    #[test]
1192    fn test_detect_dead_pixels_chunked_empty_slice_err() {
1193        let err = detect_dead_pixels_chunked(&[]).unwrap_err();
1194        assert!(matches!(err, IoError::InvalidParameter(_)));
1195    }
1196
1197    #[test]
1198    fn test_detect_dead_pixels_chunked_empty_tof_chunk_err() {
1199        // A zero-frame chunk would vacuously mark the whole detector dead.
1200        let chunk0 = Array3::from_elem((3, 2, 2), 1.0);
1201        let chunk1 = Array3::<f64>::zeros((0, 2, 2));
1202        let err = detect_dead_pixels_chunked(&[chunk0, chunk1]).unwrap_err();
1203        assert!(matches!(err, IoError::InvalidParameter(_)));
1204        assert!(err.to_string().contains("chunks[1]"));
1205        assert!(err.to_string().contains("empty TOF axis"));
1206    }
1207
1208    #[test]
1209    fn test_detect_dead_pixels_chunked_spatial_mismatch_err() {
1210        let chunk0 = Array3::from_elem((3, 2, 2), 1.0);
1211        let chunk1 = Array3::from_elem((3, 2, 3), 1.0);
1212        let err = detect_dead_pixels_chunked(&[chunk0, chunk1]).unwrap_err();
1213        assert!(matches!(err, IoError::ShapeMismatch(_)));
1214    }
1215
1216    #[test]
1217    fn test_detect_dead_pixels_chunked_nan_err() {
1218        let chunk0 = Array3::from_elem((3, 2, 2), 1.0);
1219        let mut chunk1 = Array3::from_elem((3, 2, 2), 1.0);
1220        chunk1[[0, 0, 0]] = f64::NAN;
1221        let err = detect_dead_pixels_chunked(&[chunk0, chunk1]).unwrap_err();
1222        assert!(matches!(err, IoError::InvalidParameter(_)));
1223        assert!(err.to_string().contains("chunks[1]"));
1224    }
1225
1226    #[test]
1227    fn test_detect_dead_pixels_chunked_ragged_n_tof_ok() {
1228        // Ragged event re-histogramming: n_tof may differ between chunks.
1229        let chunk0 = Array3::from_elem((3, 2, 2), 1.0);
1230        let chunk1 = Array3::from_elem((7, 2, 2), 1.0);
1231        let mask = detect_dead_pixels_chunked(&[chunk0, chunk1]).unwrap();
1232        assert!(mask.iter().all(|&m| !m));
1233    }
1234
1235    #[test]
1236    fn test_detect_hot_pixels_catches_railed() {
1237        // ACCEPTANCE (#643): a railed pixel (65535 counts/bin) amid a
1238        // realistic slightly varying background (~100/bin) is flagged;
1239        // its neighbors are not.
1240        let mut data = Array3::<f64>::zeros((4, 3, 3));
1241        for t in 0..4 {
1242            for y in 0..3 {
1243                for x in 0..3 {
1244                    // Background 95..103 counts/bin (totals 380..412).
1245                    data[[t, y, x]] = 95.0 + (y * 3 + x) as f64;
1246                }
1247            }
1248        }
1249        for t in 0..4 {
1250            data[[t, 1, 1]] = 65535.0;
1251        }
1252
1253        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1254        assert!(mask[[1, 1]], "railed pixel must be flagged");
1255        for y in 0..3 {
1256            for x in 0..3 {
1257                if (y, x) != (1, 1) {
1258                    assert!(!mask[[y, x]], "neighbor ({y}, {x}) must not be flagged");
1259                }
1260            }
1261        }
1262    }
1263
1264    #[test]
1265    fn test_detect_hot_pixels_low_count_alive_not_flagged() {
1266        // ACCEPTANCE (#643) — the 13%-rejection regression guard: a pixel
1267        // with 1 total count amid ~300-count pixels is low-count-ALIVE and
1268        // must be kept.  The screen is upper-tail only.
1269        let mut data = Array3::from_elem((3, 3, 3), 100.0);
1270        for t in 0..3 {
1271            data[[t, 0, 2]] = 0.0;
1272        }
1273        data[[1, 0, 2]] = 1.0; // total 1 count
1274
1275        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1276        assert!(!mask[[0, 2]], "low-count-alive pixel must not be flagged");
1277        assert!(mask.iter().all(|&m| !m), "nothing here is hot");
1278    }
1279
1280    #[test]
1281    fn test_detect_hot_pixels_uniform_image_poisson_floor_path() {
1282        // Perfectly uniform background → MAD == 0 → the delta-method
1283        // Poisson floor exp(-med/2) is the active scale.  The railed pixel
1284        // must still be flagged, the uniform background must not.
1285        let mut data = Array3::from_elem((4, 3, 3), 100.0);
1286        for t in 0..4 {
1287            data[[t, 2, 0]] = 65535.0;
1288        }
1289
1290        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1291        assert!(mask[[2, 0]]);
1292        assert_eq!(mask.iter().filter(|&&m| m).count(), 1);
1293    }
1294
1295    #[test]
1296    fn test_detect_hot_pixels_quantized_low_counts_none_flagged() {
1297        // Worked check from the rustdoc: mostly 1-count totals with some
1298        // 2-count totals → mad = 0, floor = 1, threshold = e^6 ≈ 403× the
1299        // median — the 2-count pixels are NOT flagged.
1300        let mut data = Array3::from_elem((1, 3, 3), 1.0);
1301        data[[0, 0, 0]] = 2.0;
1302        data[[0, 2, 2]] = 2.0;
1303
1304        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1305        assert!(
1306            mask.iter().all(|&m| !m),
1307            "quantized low-count image must produce no hot flags"
1308        );
1309    }
1310
1311    /// Build the 8×8 single-TOF-bin two-moat fixture for the stage-1
1312    /// robust-scale tests (#646 review R4, P1-2): two 3×3 all-dead moats
1313    /// (rows 1–3 × cols 1–3 and rows 4–6 × cols 4–6) with a `probe` at
1314    /// (2,2) and a `control` at (5,5) in their centers, and the remaining
1315    /// 46 pixels filled row-major with `n_a` × `a` then (46 − n_a) × `b`.
1316    ///
1317    /// A dead moat makes a probe's stage-2 reference sample EMPTY (every
1318    /// neighbor dead → omitted), which keeps the global verdict — so a
1319    /// probe's flag outcome is decided purely by the stage-1 threshold
1320    /// under test, never vetoed by the local confirmation.  Dead pixels
1321    /// are excluded from the stage-1 statistics, so with `n_a = 24` the
1322    /// live sample is 24×`a`, 22×`b`, probe, control (n = 48, both
1323    /// probes above `b`): the two central order statistics are the 24th
1324    /// (= ln a) and 25th (= ln b), pinning `med = (ln a + ln b)/2`
1325    /// exactly; the deviation sample has 46 × |ln(b/a)|/2 below the two
1326    /// probe deviations, pinning `mad = |ln(b/a)|/2` exactly.
1327    fn stage1_two_moat_grid(a: f64, n_a: usize, b: f64, probe: f64, control: f64) -> Array3<f64> {
1328        let mut data = Array3::<f64>::zeros((1, 8, 8));
1329        data[[0, 2, 2]] = probe;
1330        data[[0, 5, 5]] = control;
1331        let mut filled = 0usize;
1332        for y in 0..8 {
1333            for x in 0..8 {
1334                let in_moat1 = (1..=3).contains(&y) && (1..=3).contains(&x);
1335                let in_moat2 = (4..=6).contains(&y) && (4..=6).contains(&x);
1336                if in_moat1 || in_moat2 {
1337                    // Moat cells stay 0.0 (dead); the probe/control
1338                    // assignments above are inside the moats and survive.
1339                    continue;
1340                }
1341                data[[0, y, x]] = if filled < n_a { a } else { b };
1342                filled += 1;
1343            }
1344        }
1345        assert_eq!(filled, 46, "8×8 minus two 3×3 moats is 46 background px");
1346        data
1347    }
1348
1349    /// #646 review R4, P1-2 (1/3): the robust-MAD branch of the stage-1
1350    /// scale `sigma = max(MAD_TO_SIGMA·mad, exp(−med/2))` DECIDES an
1351    /// outcome.  Every earlier test drove sigma through the Poisson
1352    /// floor (uniform/quantized backgrounds → mad = 0), so a mutation of
1353    /// the MAD branch survived the suite.
1354    ///
1355    /// Fixture (see [`stage1_two_moat_grid`]): 24 × A = 8000,
1356    /// 22 × B = 12500, probe P = 40 000, control C = 200 000.
1357    /// A·B = 10⁸ and B/A = 1.25², so ln A = 8.9871968 and
1358    /// ln B = 9.4334839 sit symmetrically about ln 10⁴.  Worked stage-1
1359    /// arithmetic over the 48 live pixels:
1360    ///
1361    ///   med = (ln A + ln B)/2 = ln 10⁴          = 9.2103404
1362    ///   mad = ln 1.25                            = 0.2231436
1363    ///   MAD term = 1.4826022 × 0.2231436         = 0.3308331
1364    ///   floor    = exp(−med/2) = 10⁻²            = 0.01
1365    ///   sigma = max(0.3308331, 0.01)             = 0.3308331   (MAD wins)
1366    ///   threshold = med + 6·sigma                = 11.1953391
1367    ///
1368    /// Probe: ln P = 10.5966347 < 11.1953391 (margin 0.60) → NOT a
1369    /// candidate → kept.  Under a floor-only mutation (sigma = 0.01) the
1370    /// threshold collapses to 9.2703404 < ln P and the dead moat flags
1371    /// the probe unconditionally — this assertion fails.
1372    /// Control: ln C = 12.2060726 > 11.1953391 (margin 1.01) → flagged;
1373    /// a mutation that INFLATES the MAD term (≥2× → threshold ≥ 13.18)
1374    /// unflags it.  The MAD value itself separates the two probes.
1375    #[test]
1376    fn test_detect_hot_pixels_mad_scale_decides_stage1_threshold() {
1377        let data = stage1_two_moat_grid(8000.0, 24, 12500.0, 40_000.0, 200_000.0);
1378        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1379        assert!(
1380            !mask[[2, 2]],
1381            "probe below the MAD-driven threshold must be kept"
1382        );
1383        assert!(
1384            mask[[5, 5]],
1385            "control above the MAD-driven threshold must be flagged"
1386        );
1387        assert_eq!(mask.iter().filter(|&&m| m).count(), 1);
1388    }
1389
1390    /// #646 review R4, P1-2 (2/3) — crossover pin, MAD term just ABOVE
1391    /// the Poisson floor: `max()` must pick the MAD branch.
1392    ///
1393    /// Fixture: 24 × A = 100/1.08, 22 × B = 108 (A·B = 10⁴ →
1394    /// med = (ln A + ln B)/2 = ln 100 = 4.6051702, floor =
1395    /// exp(−med/2) = 10⁻¹ = 0.1), probe P = 190, control C = 10 000.
1396    ///
1397    ///   mad = ln 1.08                            = 0.0769610
1398    ///   MAD term = 1.4826022 × 0.0769610         = 0.1141026
1399    ///     → 14 % ABOVE the 0.1 floor: MAD branch wins, barely.
1400    ///   correct threshold = 4.6051702 + 6×0.1141026 = 5.2897858
1401    ///   floor-mutant thr  = 4.6051702 + 6×0.1       = 5.2051702
1402    ///
1403    /// ln P = ln 190 = 5.2470241 sits BETWEEN the two thresholds
1404    /// (margins 0.042 above the mutant's, 0.043 below the correct one):
1405    /// correct code keeps the probe; a mutant that resolves the
1406    /// crossover the wrong way (max→min, dropped MAD branch, deflated
1407    /// MAD_TO_SIGMA) flags it via the dead moat.  Control:
1408    /// ln C = 9.2103404 ≫ 5.2897858 → flagged (liveness control).
1409    #[test]
1410    fn test_detect_hot_pixels_mad_term_just_above_poisson_floor_wins() {
1411        let data = stage1_two_moat_grid(100.0 / 1.08, 24, 108.0, 190.0, 10_000.0);
1412        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1413        assert!(
1414            !mask[[2, 2]],
1415            "probe between floor and MAD thresholds must be kept when the MAD branch wins"
1416        );
1417        assert!(mask[[5, 5]], "control must be flagged");
1418        assert_eq!(mask.iter().filter(|&&m| m).count(), 1);
1419    }
1420
1421    /// #646 review R4, P1-2 (3/3) — crossover pin, MAD term just BELOW
1422    /// the Poisson floor: `max()` must pick the floor.
1423    ///
1424    /// Fixture: 24 × A = 100/1.06, 22 × B = 106 (med = ln 100 =
1425    /// 4.6051702, floor = 0.1 as above), probe P = 175, control
1426    /// C = 10 000.
1427    ///
1428    ///   mad = ln 1.06                            = 0.0582689
1429    ///   MAD term = 1.4826022 × 0.0582689         = 0.0863896
1430    ///     → 14 % BELOW the 0.1 floor: the floor wins, barely.
1431    ///   correct threshold = 4.6051702 + 6×0.1        = 5.2051702
1432    ///   MAD-mutant thr    = 4.6051702 + 6×0.0863896  = 5.1235079
1433    ///
1434    /// ln P = ln 175 = 5.1647860 sits BETWEEN the two thresholds
1435    /// (margins 0.041 above the mutant's, 0.040 below the correct one):
1436    /// correct code keeps the probe; a mutant that drops the floor
1437    /// (sigma = MAD term unconditionally — here max→min picks the MAD
1438    /// term) flags it via the dead moat.  Together with the ABOVE case
1439    /// this pins both branches of the crossover and the `max()` itself
1440    /// from both sides.
1441    #[test]
1442    fn test_detect_hot_pixels_poisson_floor_just_above_mad_term_wins() {
1443        let data = stage1_two_moat_grid(100.0 / 1.06, 24, 106.0, 175.0, 10_000.0);
1444        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1445        assert!(
1446            !mask[[2, 2]],
1447            "probe between MAD and floor thresholds must be kept when the floor wins"
1448        );
1449        assert!(mask[[5, 5]], "control must be flagged");
1450        assert_eq!(mask.iter().filter(|&&m| m).count(), 1);
1451    }
1452
1453    #[test]
1454    fn test_detect_hot_pixels_majority_dead_live_not_flagged() {
1455        // A large dead population must not drag the median down and get the
1456        // few live pixels flagged: the statistics sample is live-only.
1457        let mut data = Array3::<f64>::zeros((1, 4, 4));
1458        data[[0, 0, 0]] = 49.0;
1459        data[[0, 1, 1]] = 50.0;
1460        data[[0, 2, 2]] = 51.0;
1461
1462        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1463        assert!(
1464            mask.iter().all(|&m| !m),
1465            "live pixels amid a dead majority must not be flagged"
1466        );
1467    }
1468
1469    #[test]
1470    fn test_detect_hot_pixels_all_dead_all_false() {
1471        let data = Array3::<f64>::zeros((3, 2, 2));
1472        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1473        assert!(mask.iter().all(|&m| !m));
1474    }
1475
1476    #[test]
1477    fn test_detect_hot_pixels_bimodal_bright_region_not_flagged() {
1478        // THE PR-#646 P0 regression guard.  Dark-majority bimodal scene:
1479        // 60 % of the FOV at 50 counts (sample), a contiguous 40 % bright
1480        // region at 5000 counts (open beam past the sample edge).  The dark
1481        // population holds the median (med = ln 50) and the MAD is 0 (both
1482        // populations are internally uniform), so sigma falls to the Poisson
1483        // floor 1/√50 ≈ 0.141 and the stage-1 threshold is
1484        // ln 50 + 6·0.141 ≈ 4.76 — EVERY bright pixel (ln 5000 ≈ 8.52)
1485        // passes the global cut.  The local confirmation must veto them
1486        // all: each bright pixel's neighbor median is 5000, and
1487        // 5000 > 10 × 5000 is false.  Bright scene is not a defect.
1488        let mut data = Array3::from_elem((1, 10, 10), 50.0);
1489        for y in 0..10 {
1490            for x in 6..10 {
1491                data[[0, y, x]] = 5000.0;
1492            }
1493        }
1494
1495        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1496        assert!(
1497            mask.iter().all(|&m| !m),
1498            "no pixel of a bimodal scene may be flagged — the bright \
1499             minority region is scene, not a defect"
1500        );
1501    }
1502
1503    #[test]
1504    fn test_detect_hot_pixels_railed_inside_bright_region_caught() {
1505        // Same bimodal scene, but with a genuinely railed pixel INSIDE the
1506        // bright region: 1e6 ≈ 200× its bright neighbors.  It must still be
1507        // caught (stage 2 compares against the LOCAL median of 5000, not
1508        // the global dark median).
1509        let mut data = Array3::from_elem((1, 10, 10), 50.0);
1510        for y in 0..10 {
1511            for x in 6..10 {
1512                data[[0, y, x]] = 5000.0;
1513            }
1514        }
1515        data[[0, 5, 8]] = 1.0e6;
1516
1517        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1518        assert!(
1519            mask[[5, 8]],
1520            "railed pixel inside bright region must be flagged"
1521        );
1522        assert_eq!(
1523            mask.iter().filter(|&&m| m).count(),
1524            1,
1525            "only the railed pixel may be flagged"
1526        );
1527    }
1528
1529    #[test]
1530    fn test_detect_hot_pixels_railed_column_segment_caught() {
1531        // Three adjacent railed pixels in a column: each has ≥5 normal
1532        // neighbors of 8 (the middle one has 6), so the neighbor MEDIAN
1533        // stays at the background level and the whole segment is caught.
1534        let mut data = Array3::from_elem((4, 7, 7), 100.0);
1535        for t in 0..4 {
1536            for y in 2..5 {
1537                data[[t, y, 3]] = 65535.0;
1538            }
1539        }
1540
1541        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1542        for y in 2..5 {
1543            assert!(mask[[y, 3]], "railed column pixel ({y}, 3) must be flagged");
1544        }
1545        assert_eq!(
1546            mask.iter().filter(|&&m| m).count(),
1547            3,
1548            "only the railed segment may be flagged"
1549        );
1550    }
1551
1552    #[test]
1553    fn test_detect_hot_pixels_2x2_railed_cluster_fully_caught() {
1554        // Fixpoint acceptance (#646 review R2, F1): a 2×2 railed cluster
1555        // has no interior — every pixel keeps 5 background neighbors of 8,
1556        // so the whole cluster is caught in the first pass.  Regression
1557        // guard for the smallest ≥2-px-wide cluster.
1558        let mut data = Array3::from_elem((4, 7, 7), 100.0);
1559        for t in 0..4 {
1560            for y in 2..4 {
1561                for x in 2..4 {
1562                    data[[t, y, x]] = 65535.0;
1563                }
1564            }
1565        }
1566
1567        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1568        for y in 2..4 {
1569            for x in 2..4 {
1570                assert!(mask[[y, x]], "2x2 cluster pixel ({y}, {x}) must be flagged");
1571            }
1572        }
1573        assert_eq!(
1574            mask.iter().filter(|&&m| m).count(),
1575            4,
1576            "only the railed cluster may be flagged"
1577        );
1578    }
1579
1580    #[test]
1581    fn test_detect_hot_pixels_3x3_railed_blob_fully_caught() {
1582        // Fixpoint acceptance (#646 review R2, F1) — THE erosion proof.
1583        // A 3×3 railed blob: pass 1 flags only the 4 corners (5 background
1584        // neighbors each); the edge centers (3 bg + 5 railed) and the
1585        // interior (8 railed) are refuted by a single pass — the
1586        // pre-fixpoint code missed them.  With flagged neighbors
1587        // contributing zero totals, pass 2 flags the edge centers
1588        // (sample [0, 0, bg, bg, bg, R, R, R] → median bg) and pass 3 the
1589        // interior (all-flagged neighborhood → median 0).
1590        let mut data = Array3::from_elem((4, 9, 9), 100.0);
1591        for t in 0..4 {
1592            for y in 3..6 {
1593                for x in 3..6 {
1594                    data[[t, y, x]] = 65535.0;
1595                }
1596            }
1597        }
1598
1599        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1600        for y in 3..6 {
1601            for x in 3..6 {
1602                assert!(
1603                    mask[[y, x]],
1604                    "3x3 blob pixel ({y}, {x}) must be flagged (interior included)"
1605                );
1606            }
1607        }
1608        assert_eq!(
1609            mask.iter().filter(|&&m| m).count(),
1610            9,
1611            "only the railed blob may be flagged"
1612        );
1613    }
1614
1615    #[test]
1616    fn test_detect_hot_pixels_2px_wide_railed_column_fully_caught() {
1617        // Fixpoint acceptance (#646 review R2, F1): the interior of a
1618        // 2-px-wide railed column (3 bg + 5 railed neighbors per interior
1619        // pixel) was invisible to the single pass — only the 4 end pixels
1620        // (5 bg neighbors each) flagged.  The fixpoint erodes the column
1621        // pairwise from both ends.
1622        let mut data = Array3::from_elem((4, 9, 9), 100.0);
1623        for t in 0..4 {
1624            for y in 2..7 {
1625                for x in 3..5 {
1626                    data[[t, y, x]] = 65535.0;
1627                }
1628            }
1629        }
1630
1631        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1632        for y in 2..7 {
1633            for x in 3..5 {
1634                assert!(
1635                    mask[[y, x]],
1636                    "2-px-wide column pixel ({y}, {x}) must be flagged"
1637                );
1638            }
1639        }
1640        assert_eq!(
1641            mask.iter().filter(|&&m| m).count(),
1642            10,
1643            "only the railed column may be flagged"
1644        );
1645    }
1646
1647    #[test]
1648    fn test_detect_hot_pixels_edge_to_edge_2px_band_not_flagged_by_design() {
1649        // Documented-limitation pin (#646 review R3, F1): an EDGE-TO-EDGE
1650        // railed band ≥2 px wide (both ends off-detector) exposes no end
1651        // cap or convex corner, so the erosion has no seed — interior
1652        // band pixels keep 3 bg + 5 railed neighbors (median railed) and
1653        // even the on-detector-border band ends keep 2 bg + 3 railed
1654        // (median railed).  Nothing flags, deliberately: a slit-aperture
1655        // open beam produces a genuine full-width bright SCENE band that
1656        // is pixel-for-pixel indistinguishable from this defect, and a
1657        // full-span row/column screen would mask it (the bimodal
1658        // failure).  Such detector pathologies belong in a declared/file
1659        // mask (see rustdoc, "Fixpoint erosion of railed clusters").
1660        let mut data = Array3::from_elem((4, 9, 9), 100.0);
1661        for t in 0..4 {
1662            for y in 3..5 {
1663                for x in 0..9 {
1664                    data[[t, y, x]] = 65535.0;
1665                }
1666            }
1667        }
1668
1669        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1670        assert!(
1671            mask.iter().all(|&m| !m),
1672            "an edge-to-edge ≥2-px railed band must NOT be flagged \
1673             (documented limitation — geometrically ambiguous with a \
1674             slit-aperture bright scene band)"
1675        );
1676    }
1677
1678    #[test]
1679    fn test_detect_hot_pixels_2px_band_one_end_on_detector_fully_caught() {
1680        // Companion to the edge-to-edge pin (#646 review R3, F1): the
1681        // same 2-row railed band, but with ONE end cap inside the
1682        // detector.  The two cap pixels keep 5 bg + 3 railed neighbors
1683        // (median bg) and seed in pass 1; the fixpoint then erodes the
1684        // band column-pair by column-pair all the way to the opposite
1685        // (detector-border) end.
1686        let mut data = Array3::from_elem((4, 9, 9), 100.0);
1687        for t in 0..4 {
1688            for y in 3..5 {
1689                for x in 0..7 {
1690                    data[[t, y, x]] = 65535.0;
1691                }
1692            }
1693        }
1694
1695        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1696        for y in 3..5 {
1697            for x in 0..7 {
1698                assert!(mask[[y, x]], "band pixel ({y}, {x}) must be flagged");
1699            }
1700        }
1701        assert_eq!(
1702            mask.iter().filter(|&&m| m).count(),
1703            14,
1704            "only the railed band may be flagged"
1705        );
1706    }
1707
1708    #[test]
1709    fn test_detect_hot_pixels_full_railed_column_caught() {
1710        // Regression (#646 review R2, F1 test 3): a full-height 1-px railed
1711        // column — every pixel keeps ≥4 background neighbors, so the whole
1712        // line is caught in pass 1, exactly as before the fixpoint.
1713        let mut data = Array3::from_elem((4, 7, 7), 100.0);
1714        for t in 0..4 {
1715            for y in 0..7 {
1716                data[[t, y, 3]] = 65535.0;
1717            }
1718        }
1719
1720        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1721        for y in 0..7 {
1722            assert!(mask[[y, 3]], "railed column pixel ({y}, 3) must be flagged");
1723        }
1724        assert_eq!(
1725            mask.iter().filter(|&&m| m).count(),
1726            7,
1727            "only the railed column may be flagged"
1728        );
1729    }
1730
1731    #[test]
1732    fn test_detect_hot_pixels_large_psf_bright_region_not_eroded() {
1733        // Fixpoint safety (#646 review R2, F1 test 5): a LARGE bright scene
1734        // region — 20×20 core at 100× background — with the ≥2-px PSF edge
1735        // blur that real VENUS scene features have (adjacent-pixel ratios
1736        // ≤5× through a 2-px transition ring: 100 → 400 → 2000 → 10000).
1737        // Every bright-layer pixel passes the stage-1 global cut (dark
1738        // majority: med = ln 100, mad = 0, Poisson-floor sigma = 0.1 →
1739        // threshold ≈ 5.21 < ln 400 ≈ 5.99), yet no pixel reaches 10× its
1740        // neighbor median, so the erosion never seeds and the fixpoint is
1741        // reached with ZERO flags — the region survives intact.
1742        let mut data = Array3::from_elem((1, 50, 50), 100.0);
1743        for y in 13..37 {
1744            for x in 13..37 {
1745                data[[0, y, x]] = 400.0; // outer transition ring
1746            }
1747        }
1748        for y in 14..36 {
1749            for x in 14..36 {
1750                data[[0, y, x]] = 2000.0; // inner transition ring
1751            }
1752        }
1753        for y in 15..35 {
1754            for x in 15..35 {
1755                data[[0, y, x]] = 10000.0; // 20×20 core at 100× background
1756            }
1757        }
1758
1759        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1760        assert!(
1761            mask.iter().all(|&m| !m),
1762            "a PSF-blurred bright scene region must not be eroded at all"
1763        );
1764    }
1765
1766    #[test]
1767    fn test_detect_hot_pixels_hard_edged_bright_rectangle_corners_only() {
1768        // Pins the documented convex-corner caveat AND the fixpoint
1769        // no-propagation property (#646 review R2).  A hard-edged (0-px
1770        // transition) 20×20 region at 100× background: each sharp convex
1771        // corner pixel sees only 3 same-side neighbors (median falls on
1772        // the dark side) and flags — pre-existing single-pass behavior,
1773        // physically rare in scene (PSF blurs real edges over ≥2 px).
1774        // Crucially, the erosion must NOT propagate past the corners: the
1775        // corner-adjacent edge pixels keep ≥4 bright unflagged neighbors,
1776        // so the fixpoint stops at exactly the 4 corner pixels.
1777        let mut data = Array3::from_elem((1, 50, 50), 100.0);
1778        for y in 15..35 {
1779            for x in 15..35 {
1780                data[[0, y, x]] = 10000.0;
1781            }
1782        }
1783
1784        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1785        for &(y, x) in &[(15, 15), (15, 34), (34, 15), (34, 34)] {
1786            assert!(
1787                mask[[y, x]],
1788                "sharp convex corner ({y}, {x}) flags (documented caveat)"
1789            );
1790        }
1791        assert_eq!(
1792            mask.iter().filter(|&&m| m).count(),
1793            4,
1794            "erosion must not propagate past the convex corners"
1795        );
1796    }
1797
1798    #[test]
1799    fn test_detect_hot_pixels_1px_bright_line_flagged_by_design() {
1800        // Width-1 limitation pin (#646 review R2, F3 — user-decided:
1801        // document + pin, no connected-component machinery).  A 1-px-wide
1802        // bright SCENE line at ≥10× local contrast is spatially
1803        // indistinguishable from a railed line and IS masked — the
1804        // accepted trade-off for catching railed rows/columns.  Real VENUS
1805        // scene features are PSF-blurred over ≥2 px, so this contrast is
1806        // physically rare in scene (see HOT_LOCAL_FACTOR rustdoc).
1807        let mut data = Array3::from_elem((1, 9, 9), 100.0);
1808        for y in 0..9 {
1809            data[[0, y, 4]] = 5000.0; // 50× the local background
1810        }
1811
1812        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1813        for y in 0..9 {
1814            assert!(
1815                mask[[y, 4]],
1816                "1-px bright line pixel ({y}, 4) is flagged BY DESIGN"
1817            );
1818        }
1819        assert_eq!(
1820            mask.iter().filter(|&&m| m).count(),
1821            9,
1822            "only the width-1 line may be flagged"
1823        );
1824    }
1825
1826    #[test]
1827    fn test_detect_hot_pixels_isolated_live_pixel_keeps_global_verdict() {
1828        // A stage-1 candidate whose 8-neighbors are ALL dead has no live
1829        // neighbor median to refute the global verdict — it stays flagged.
1830        // Scattered far pixels at 50 counts define the global statistics
1831        // (med = ln 50, threshold ≈ 4.76); the isolated 1e6 pixel passes.
1832        let mut data = Array3::<f64>::zeros((1, 5, 5));
1833        for &(y, x) in &[(0, 0), (0, 2), (0, 4), (4, 0), (4, 2), (4, 4)] {
1834            data[[0, y, x]] = 50.0;
1835        }
1836        data[[0, 2, 2]] = 1.0e6;
1837
1838        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1839        assert!(
1840            mask[[2, 2]],
1841            "isolated live candidate in a dead field keeps the global verdict"
1842        );
1843        assert_eq!(mask.iter().filter(|&&m| m).count(), 1);
1844    }
1845
1846    #[test]
1847    fn test_detect_hot_pixels_empty_tof_err() {
1848        // n_tof == 0: the totals image would be all-zero (vacuous sums) —
1849        // validating entry points must reject rather than return all-false.
1850        let data = Array3::<f64>::zeros((0, 2, 2));
1851        let err = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap_err();
1852        assert!(matches!(err, IoError::InvalidParameter(_)));
1853        assert!(err.to_string().contains("empty TOF axis"));
1854    }
1855
1856    #[test]
1857    fn test_detect_hot_pixels_nan_err() {
1858        let mut data = Array3::from_elem((2, 2, 2), 1.0);
1859        data[[1, 1, 0]] = f64::NAN;
1860        let err = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap_err();
1861        assert!(matches!(err, IoError::InvalidParameter(_)));
1862    }
1863
1864    #[test]
1865    fn test_detect_hot_pixels_bad_k_err() {
1866        let data = Array3::from_elem((2, 2, 2), 1.0);
1867        for bad_k in [0.0, -1.0, f64::NAN, f64::INFINITY] {
1868            let err = detect_hot_pixels(&data, bad_k).unwrap_err();
1869            assert!(
1870                matches!(err, IoError::InvalidParameter(_)),
1871                "k_mad = {bad_k} must be rejected"
1872            );
1873        }
1874    }
1875
1876    #[test]
1877    fn test_detect_bad_pixels_union() {
1878        // sample: (0,0) dead, (1,2) low-count-alive (1 total count),
1879        //         everything else 100 counts/bin.
1880        let mut sample = Array3::from_elem((3, 3, 3), 100.0);
1881        for t in 0..3 {
1882            sample[[t, 0, 0]] = 0.0;
1883            sample[[t, 1, 2]] = 0.0;
1884        }
1885        sample[[0, 1, 2]] = 1.0;
1886        // open beam: (0,1) dead, (2,2) railed, everything else 200/bin.
1887        let mut ob = Array3::from_elem((3, 3, 3), 200.0);
1888        for t in 0..3 {
1889            ob[[t, 0, 1]] = 0.0;
1890            ob[[t, 2, 2]] = 65535.0;
1891        }
1892
1893        let mask = detect_bad_pixels(&sample, Some(&ob), Some(HOT_PIXEL_K_MAD)).unwrap();
1894        assert!(mask[[0, 0]], "dead-in-sample-only must be flagged");
1895        assert!(mask[[0, 1]], "dead-in-OB-only must be flagged");
1896        assert!(mask[[2, 2]], "hot-in-OB-only must be flagged");
1897        assert!(!mask[[1, 2]], "low-count-alive must be kept");
1898        assert!(!mask[[1, 1]], "normal pixel must be kept");
1899        assert_eq!(mask.iter().filter(|&&m| m).count(), 3);
1900    }
1901
1902    #[test]
1903    fn test_detect_bad_pixels_spatial_mismatch_err() {
1904        let sample = Array3::from_elem((3, 2, 2), 1.0);
1905        let ob = Array3::from_elem((3, 2, 3), 1.0);
1906        let err = detect_bad_pixels(&sample, Some(&ob), None).unwrap_err();
1907        assert!(matches!(err, IoError::ShapeMismatch(_)));
1908    }
1909
1910    #[test]
1911    fn test_detect_bad_pixels_ragged_n_tof_ok() {
1912        // Deadness is spatial: sample and OB may have different TOF axes.
1913        let sample = Array3::from_elem((3, 2, 2), 1.0);
1914        let ob = Array3::from_elem((7, 2, 2), 1.0);
1915        let mask = detect_bad_pixels(&sample, Some(&ob), Some(HOT_PIXEL_K_MAD)).unwrap();
1916        assert!(mask.iter().all(|&m| !m));
1917    }
1918
1919    #[test]
1920    fn test_detect_bad_pixels_hot_k_mad_none_is_dead_only() {
1921        let mut sample = Array3::from_elem((3, 3, 3), 100.0);
1922        for t in 0..3 {
1923            sample[[t, 1, 1]] = 65535.0; // railed
1924            sample[[t, 0, 0]] = 0.0; // dead
1925        }
1926        let dead_only = detect_bad_pixels(&sample, None, None).unwrap();
1927        assert!(dead_only[[0, 0]]);
1928        assert!(
1929            !dead_only[[1, 1]],
1930            "hot_k_mad = None must disable the hot screen"
1931        );
1932
1933        let with_hot = detect_bad_pixels(&sample, None, Some(HOT_PIXEL_K_MAD)).unwrap();
1934        assert!(with_hot[[0, 0]]);
1935        assert!(with_hot[[1, 1]]);
1936    }
1937
1938    #[test]
1939    fn test_detect_bad_pixels_bad_k_err() {
1940        let sample = Array3::from_elem((2, 2, 2), 1.0);
1941        for bad_k in [0.0, -1.0, f64::NAN, f64::INFINITY] {
1942            let err = detect_bad_pixels(&sample, None, Some(bad_k)).unwrap_err();
1943            assert!(
1944                matches!(err, IoError::InvalidParameter(_)),
1945                "hot_k_mad = {bad_k} must be rejected"
1946            );
1947        }
1948    }
1949
1950    #[test]
1951    fn test_detect_bad_pixels_empty_tof_err() {
1952        // An empty stack's all-zero test passes vacuously — without this
1953        // guard the whole detector would be masked dead with no error.
1954        let empty = Array3::<f64>::zeros((0, 2, 2));
1955        let err = detect_bad_pixels(&empty, None, None).unwrap_err();
1956        assert!(matches!(err, IoError::InvalidParameter(_)));
1957        assert!(err.to_string().contains("sample"));
1958
1959        let sample = Array3::from_elem((3, 2, 2), 1.0);
1960        let err = detect_bad_pixels(&sample, Some(&empty), None).unwrap_err();
1961        assert!(matches!(err, IoError::InvalidParameter(_)));
1962        assert!(err.to_string().contains("open_beam"));
1963    }
1964
1965    #[test]
1966    fn test_detect_bad_pixels_nan_err() {
1967        let mut sample = Array3::from_elem((2, 2, 2), 1.0);
1968        sample[[0, 0, 1]] = f64::NAN;
1969        let err = detect_bad_pixels(&sample, None, None).unwrap_err();
1970        assert!(err.to_string().contains("sample"));
1971
1972        let sample = Array3::from_elem((2, 2, 2), 1.0);
1973        let mut ob = Array3::from_elem((2, 2, 2), 1.0);
1974        ob[[1, 0, 0]] = f64::NEG_INFINITY;
1975        let err = detect_bad_pixels(&sample, Some(&ob), None).unwrap_err();
1976        assert!(err.to_string().contains("open_beam"));
1977    }
1978}