Skip to main content

nereids_io/
pixels.rs

1//! Data-quality pixel masks and per-pixel spectrum extraction.
2//!
3//! The boolean masks exclude pixels whose data stream is broken, so they are
4//! never fitted.  They are not a coverage or low-count filter: low-count
5//! pixels are alive and the counts likelihood handles them.
6//!
7//! Deadness is per acquisition, so always union the sample and open-beam
8//! masks; [`detect_bad_pixels`] does this.
9
10use ndarray::{Array1, Array2, Array3, Axis, Zip};
11
12use crate::error::IoError;
13
14fn validate_counts<D: ndarray::Dimension>(
15    counts: &ndarray::ArrayBase<impl ndarray::Data<Elem = f64>, D>,
16    field: &str,
17) -> Result<(), IoError> {
18    nereids_core::validation::first_non_finite_or_negative(counts.iter().copied()).map_err(
19        |(i, v)| {
20            IoError::InvalidParameter(format!(
21                "{field} counts at flat index {i} must be finite and >= 0, got {v}"
22            ))
23        },
24    )
25}
26
27/// Extract a single spectrum (all TOF bins) from a pixel in the 3D array.
28///
29/// # Arguments
30/// * `data` — 3D array with shape (n_tof, height, width).
31/// * `y` — Pixel row.
32/// * `x` — Pixel column.
33///
34/// # Returns
35/// 1D array of length n_tof.
36pub fn extract_spectrum(data: &Array3<f64>, y: usize, x: usize) -> Array1<f64> {
37    data.slice(ndarray::s![.., y, x]).to_owned()
38}
39
40/// Average spectra over a rectangular region of interest.
41///
42/// # Arguments
43/// * `data` — 3D array with shape (n_tof, height, width).
44/// * `y_range` — Row range (start..end).
45/// * `x_range` — Column range (start..end).
46///
47/// # Errors
48/// Returns `IoError::InvalidParameter` if the ROI is empty or exceeds the
49/// spatial dimensions of `data`.
50///
51/// # Returns
52/// Averaged 1D spectrum of length n_tof.
53pub fn average_roi(
54    data: &Array3<f64>,
55    y_range: std::ops::Range<usize>,
56    x_range: std::ops::Range<usize>,
57) -> Result<Array1<f64>, IoError> {
58    if y_range.is_empty() || x_range.is_empty() {
59        return Err(IoError::InvalidParameter(
60            "ROI ranges must be non-empty for average_roi".into(),
61        ));
62    }
63    if y_range.end > data.shape()[1] || x_range.end > data.shape()[2] {
64        return Err(IoError::InvalidParameter(format!(
65            "ROI range ({}..{}, {}..{}) exceeds data spatial dims ({}, {})",
66            y_range.start,
67            y_range.end,
68            x_range.start,
69            x_range.end,
70            data.shape()[1],
71            data.shape()[2],
72        )));
73    }
74    let roi = data.slice(ndarray::s![.., y_range, x_range]);
75    // Mean over spatial dimensions (axes 1 and 2).
76    // unwrap is safe here: the ROI is guaranteed non-empty by the check above.
77    Ok(roi.mean_axis(Axis(2)).unwrap().mean_axis(Axis(1)).unwrap())
78}
79
80/// Detect dead pixels (zero counts across all TOF bins of one stack).
81///
82/// Pipeline-integrity screen only — see the module-level "Pixel masks —
83/// pipeline integrity only" section.  Prefer [`detect_bad_pixels`] as the
84/// validating entry point; this function performs no input validation of its
85/// own (backward compatibility: GUI, Python ABI, persisted masks).
86///
87/// Precondition: `data` is finite and non-negative.
88/// Under that invariant the exact `== 0.0` test is
89/// intentional:
90///
91/// - Counts are validated non-negative, and `0.0 × efficiency == 0.0` holds
92///   exactly in IEEE 754, so a `<= 0.0` test would be dead code.
93/// - A NaN bin makes a pixel appear *alive* (`NaN == 0.0` is `false`).  This
94///   is deliberate: corrupt input must be rejected upstream, never silently
95///   masked. [`detect_bad_pixels`] rejects such input up front.
96/// - An empty TOF axis (`shape[0] == 0`) makes the all-zero test vacuously
97///   true for *every* pixel — the whole detector would be reported dead.
98///   This non-validating function keeps that behaviour for backward
99///   compatibility; the validating entry points ([`detect_bad_pixels`],
100///   [`detect_dead_pixels_chunked`], [`detect_hot_pixels`]) reject empty
101///   stacks up front.
102///
103/// # Arguments
104/// * `data` — 3D array with shape (n_tof, height, width).
105///
106/// # Returns
107/// 2D boolean mask, shape (height, width). `true` = dead pixel.
108pub fn detect_dead_pixels(data: &Array3<f64>) -> ndarray::Array2<bool> {
109    let shape = data.shape();
110    let (height, width) = (shape[1], shape[2]);
111    let mut mask = ndarray::Array2::from_elem((height, width), false);
112
113    for y in 0..height {
114        for x in 0..width {
115            let all_zero = (0..shape[0]).all(|t| data[[t, y, x]] == 0.0);
116            mask[[y, x]] = all_zero;
117        }
118    }
119
120    mask
121}
122
123/// Default MAD multiplier for the global (stage-1) cut of
124/// [`detect_hot_pixels`].
125///
126/// The one-sided Gaussian tail at 6 robust σ is P(Z > 6) ≈ 9.9e-10, i.e.
127/// ~2.6e-4 expected false flags on a full 512×512 frame (262 144 pixels) —
128/// on a *unimodal* image the screen essentially never rejects a
129/// statistically plausible pixel, while a railed pixel sits tens of robust
130/// σ above any plausible median.
131///
132/// On a **bimodal** image the global cut alone is not trustworthy: when the
133/// darker population holds the median (a sample covering >50 % of the FOV,
134/// or an aperture-limited open beam), the MAD reflects only the dark
135/// population's internal spread, and *every* bright-region pixel lands
136/// above `med + k·MAD`.  [`detect_hot_pixels`] therefore never flags on the
137/// global cut alone — the local-neighborhood confirmation
138/// ([`HOT_LOCAL_FACTOR`]) must also pass.
139pub const HOT_PIXEL_K_MAD: f64 = 6.0;
140
141/// Local-neighborhood confirmation factor (stage 2) of
142/// [`detect_hot_pixels`].
143///
144/// A pixel that passes the global cut is flagged only if its total also
145/// exceeds `HOT_LOCAL_FACTOR ×` the median total of its available live
146/// 8-neighbors.  The factor separates detector *point defects* from scene
147/// structure:
148///
149/// - A railed/runaway pixel is spatially isolated and typically ≥100× its
150///   neighbors, so it clears 10× with a wide margin.
151/// - Adjacent-pixel scene gradients (beam profile, sample absorption) are
152///   ≤2–3×; even directly across a sharp sample edge only one ring of
153///   neighbors is mixed, and the neighbor *median* stays on the pixel's
154///   own side of the edge.
155/// - A fully-railed 1-px row/column still leaves each railed pixel with
156///   ≥5 normal neighbors of 8, so the neighbor median stays normal and the
157///   line IS caught in a single pass.  Railed CLUSTERS ≥2 px wide are
158///   caught by the stage-2 fixpoint erosion — see the
159///   "Fixpoint erosion of railed clusters" section of
160///   [`detect_hot_pixels`].
161///
162/// **Width-1 limitation (accepted trade-off)**: a 1-px-wide bright *scene*
163/// line at ≥`HOT_LOCAL_FACTOR`× local contrast is spatially
164/// indistinguishable from a railed line and IS masked.  Contiguous bright
165/// regions ≥2 px wide are safe (their boundary pixels keep a same-side
166/// neighbor median, so the erosion never seeds — see [`detect_hot_pixels`],
167/// "Why bright scene regions never erode").  Real scene features on VENUS
168/// are PSF-blurred over ≥2 px, so ≥10× single-pixel scene contrast is
169/// physically rare; masking it is the accepted price for catching railed
170/// rows/columns.
171pub const HOT_LOCAL_FACTOR: f64 = 10.0;
172
173/// Reject a stack with an empty TOF axis (`shape[0] == 0`).
174///
175/// Every-bin predicates are vacuously true over zero bins: an empty stack
176/// would mark the whole detector dead (and gives the hot screen an all-zero
177/// totals image) with no error.  Called up front by the validating detector
178/// entry points; [`detect_dead_pixels`] deliberately stays non-validating
179/// (see its rustdoc).
180fn validate_n_tof(data: &Array3<f64>, field: &str) -> Result<(), IoError> {
181    if data.shape()[0] == 0 {
182        return Err(IoError::InvalidParameter(format!(
183            "{field} has an empty TOF axis (shape[0] == 0) — dead/hot detection \
184             over zero bins is vacuous and would mask every pixel"
185        )));
186    }
187    Ok(())
188}
189
190/// Detect dead pixels across acquisition chunks (dead-in-any-chunk).
191///
192/// Catches *intermittent* deadness that [`detect_dead_pixels`] on the summed
193/// stack cannot see: a pixel that was dead for one acquisition chunk but
194/// alive in another has nonzero summed counts, yet its dead-chunk data
195/// corrupts the combined spectrum.  A pixel is flagged iff it is all-zero
196/// (exact `== 0.0` test, same rationale as [`detect_dead_pixels`]) in *any*
197/// chunk.
198///
199/// False-positive control: a live pixel with expected total counts λ within
200/// one chunk is all-zero in that chunk with probability P = e^(−λ) (Poisson);
201/// over m chunks, P(misflag) ≤ m·e^(−λ).  Guidance: chunk the acquisition so
202/// each live pixel has λ ≥ 20 expected counts per chunk — e^(−20) ≈ 2e-9,
203/// i.e. ~5e-4·m expected false flags on a 512² detector.
204///
205/// There is deliberately **no** per-TOF-block zero-run variant.  Within one
206/// TOF-summed stack, wall-clock-intermittent deadness is invisible — the
207/// pixel just shows uniformly reduced counts across all TOF bins, with no
208/// zeros to find.  And any within-stack zero-run cut is a statistical screen
209/// on low-count pixels (a pixel at 0.01 counts/bin normally has ~100-bin
210/// zero runs) — exactly the banned failure mode (see the module-level
211/// "Pixel masks — pipeline integrity only" section).
212///
213/// Chunks may have differing TOF axis lengths (`n_tof`) — ragged event-mode
214/// re-histogramming is fine; deadness is spatial, so only the spatial
215/// dimensions must agree.
216///
217/// # Arguments
218/// * `chunks` — One 3D counts array per acquisition chunk, each with shape
219///   (n_tof, height, width).  `n_tof` may differ between chunks; (height,
220///   width) must not.
221///
222/// # Returns
223/// 2D boolean mask, shape (height, width). `true` = dead in at least one
224/// chunk.
225///
226/// # Errors
227/// Returns `IoError::InvalidParameter` if `chunks` is empty, any chunk has
228/// an empty TOF axis (`shape[0] == 0` — its all-zero test would vacuously
229/// mark every pixel dead), or any chunk contains a non-finite or negative
230/// value, and `IoError::ShapeMismatch` if the chunks' spatial dimensions
231/// differ.
232pub fn detect_dead_pixels_chunked(chunks: &[Array3<f64>]) -> Result<Array2<bool>, IoError> {
233    if chunks.is_empty() {
234        return Err(IoError::InvalidParameter(
235            "detect_dead_pixels_chunked requires at least one chunk".into(),
236        ));
237    }
238
239    let first = chunks[0].shape();
240    let (height, width) = (first[1], first[2]);
241    for (i, chunk) in chunks.iter().enumerate() {
242        let s = chunk.shape();
243        // n_tof (s[0]) may differ between chunks; only spatial dims must agree.
244        if s[1] != height || s[2] != width {
245            return Err(IoError::ShapeMismatch(format!(
246                "chunks[{i}] spatial dims ({}, {}) != chunks[0] spatial dims ({height}, {width})",
247                s[1], s[2],
248            )));
249        }
250        validate_counts(chunk, &format!("chunks[{i}]"))?;
251        validate_n_tof(chunk, &format!("chunks[{i}]"))?;
252    }
253
254    let mut mask = Array2::from_elem((height, width), false);
255    for chunk in chunks {
256        let dead = detect_dead_pixels(chunk);
257        Zip::from(&mut mask)
258            .and(&dead)
259            .for_each(|m, &d| *m = *m || d);
260    }
261    Ok(mask)
262}
263
264/// Detect hot (railed / runaway) pixels via a two-stage criterion: a
265/// robust one-sided log-space median + k·MAD screen on per-pixel total
266/// counts (stage 1, global), confirmed by a local-neighborhood isolation
267/// test (stage 2).
268///
269/// Pipeline-integrity screen only — see the module-level "Pixel masks —
270/// pipeline integrity only" section.  The algorithm:
271///
272/// 1. `totals[y, x] = Σ_t data[t, y, x]`.
273/// 2. The statistics sample is `ln(totals)` over `totals > 0` pixels *only* —
274///    dead pixels are excluded *before* the median/MAD so `ln(0)` never
275///    enters and a large dead population cannot drag the median down.  If no
276///    live pixels exist, every pixel is unflagged (all-`false` mask).
277/// 3. `med = median(ln totals)`, `mad = median(|ln totals − med|)`.
278/// 4. `sigma = max(MAD_TO_SIGMA·mad, exp(−med/2))`.  The second term is the
279///    delta-method Poisson floor of `ln N`: `Var[ln N] ≈ 1/N` evaluated at
280///    `N = exp(med)` (medians commute with monotone maps, so `exp(med)` is
281///    the median total), giving `σ_floor = 1/√exp(med) = exp(−med/2)`.  The
282///    robust scale can never legitimately sit below counting noise; this
283///    guards `mad == 0` on quantized low-count images *without* becoming a
284///    low-count screen — it only ever raises the threshold.  Worked check:
285///    an image where most totals are 1.0 and some are 2.0 has `mad = 0` and
286///    floor `= 1`, so the threshold is `e^6 ≈ 403×` the median — the 2-count
287///    pixels are NOT flagged, while a railed pixel still is.
288/// 5. Stage 1 (global): a pixel is a *candidate* iff `totals > 0 &&
289///    ln(total) > med + k_mad·sigma` — **upper tail only**.  A stuck-low
290///    pixel is indistinguishable from a low-count-alive pixel and is
291///    deliberately kept (masking it would be the banned low-count screen).
292///    Railed/always-max pixels are subsumed by the upper tail: no fixed
293///    saturation value exists after efficiency correction, so a
294///    saturation-constant test would be wrong anyway.
295/// 6. Stage 2 (local confirmation), iterated to a **fixpoint**: a candidate
296///    is flagged iff its total also exceeds [`HOT_LOCAL_FACTOR`] × the
297///    median of its 8-neighborhood reference sample, where each neighbor
298///    contributes its total if live (`total > 0`) and not yet flagged,
299///    contributes `0.0` if already flagged (a known defect cannot vouch
300///    for its neighbors — see below), and is omitted if dead (a dead pixel
301///    carries no scene information).  Edge pixels use whatever neighbors
302///    exist.  A candidate whose reference sample is empty (every neighbor
303///    dead — isolated live pixel in a dead field) keeps the global verdict;
304///    a candidate whose neighbors are mostly flagged defects likewise stays
305///    flagged (its reference median is 0).  After each full pass over the
306///    fixed stage-1 candidate list, newly confirmed flags are applied and
307///    the pass repeats until a pass adds no new flag.
308///
309/// # Fixpoint erosion of railed clusters
310///
311/// A single stage-2 pass misses the INTERIOR of a railed cluster ≥2 px
312/// wide: an interior pixel's 8-neighbors are railed too, so its neighbor
313/// median is railed and the ratio test refutes the flag.  Iterating to a
314/// fixpoint erodes such clusters from the boundary inward — once the
315/// cluster's outermost pixels are flagged they stop vouching (each
316/// contributes `0.0` instead of its railed total), the reference median of
317/// the next ring drops back to the background level, and the next pass
318/// flags that ring.  Contributing `0.0` (rather than omitting the flagged
319/// neighbor) is load-bearing: with omission, a 3-background + 3-railed
320/// reference sample has an even-count [`nereids_core::stats::median`]
321/// midpoint mid-gap (≈ railed/2), the ratio test reads ~2× and the erosion
322/// stalls; with the zero contribution the median stays on the background
323/// side and erosion completes.  Erosion fully consumes clusters up to 3 px
324/// wide in their narrower dimension (point defects, 1-px lines, 2–3-px-wide
325/// blobs/segments — the physical shapes of railed detector defects)
326/// PROVIDED the cluster exposes at least one end cap or convex corner to
327/// normal-scene neighbors — erosion must seed somewhere.  An EDGE-TO-EDGE
328/// railed band ≥2 px wide (both ends off-detector — spanning the full
329/// detector width or height) exposes neither: every interior band pixel
330/// keeps ≥5 railed of 8 neighbors, and even the on-detector-border band
331/// ends keep 3 railed of 5, so every neighbor median stays railed, no
332/// pixel ever seeds, and the band is NOT caught
333/// (`test_detect_hot_pixels_edge_to_edge_2px_band_not_flagged_by_design`).
334/// This is deliberate, not an oversight: a slit-aperture open beam
335/// produces a genuine full-width bright SCENE band that is
336/// pixel-for-pixel indistinguishable from such a defect, so a full-span
337/// row/column screen would mask it — re-introducing the exact bimodal
338/// failure stage 2 exists to prevent.  Full-span detector pathologies of
339/// width ≥2 belong in a declared/file mask.  (A full-span width-1 railed
340/// line IS caught — each of its pixels keeps ≥4 normal neighbors,
341/// `test_detect_hot_pixels_full_railed_column_caught`; and a ≥2-px band
342/// with even one end cap inside the detector is fully consumed from that
343/// cap, `test_detect_hot_pixels_2px_band_one_end_on_detector_fully_caught`.)
344/// A hard-edged railed rectangle ≥4 px wide keeps its interior (only its
345/// convex corners flag): it is pixel-for-pixel indistinguishable from a
346/// hard-edged bright scene region, which must survive (below).
347///
348/// **Termination bound**: flags are only ever added, and every pass except
349/// the last adds at least one, so at most `height·width` passes can do
350/// work; the loop is additionally capped at `height·width` passes to make
351/// the bound structural rather than reasoned.  In practice the pass count
352/// is on the order of the defect-cluster radius (one pass for point
353/// defects and 1-px lines).
354///
355/// # Why bright scene regions never erode
356///
357/// Erosion must *seed* at a bright-region boundary pixel.  A boundary
358/// pixel of a contiguous bright scene region ≥2 px wide keeps ≥4 of its
359/// 8 neighbors on its own (bright) side for any straight or diagonal
360/// edge, so its reference median stays bright and its ratio is the scene
361/// gradient (≤2–3× across real edges) — far below the ≥10×
362/// [`HOT_LOCAL_FACTOR`].  Stage 1's global cut additionally gates which
363/// pixels can seed at all.  With no seed, the fixpoint is reached with
364/// zero flags in the region and it survives intact
365/// (`test_detect_hot_pixels_large_psf_bright_region_not_eroded`).  Two
366/// documented, test-pinned exceptions — both physically rare on VENUS,
367/// where the detector PSF blurs real scene features over ≥2 px so ≥10×
368/// single-pixel contrast steps do not occur in scene:
369///
370/// - a **width-1 bright line** at ≥10× local contrast is spatially
371///   indistinguishable from a railed line and IS masked — the accepted
372///   trade-off for catching railed rows/columns
373///   (`test_detect_hot_pixels_1px_bright_line_flagged_by_design`);
374/// - the single pixel at a **sharp convex (90°) corner** of a hard-edged
375///   ≥10× region sees only 3 same-side neighbors, its reference median
376///   falls on the dark side, and it is flagged (this predates the
377///   fixpoint); erosion does NOT propagate past it — the adjacent edge
378///   pixels keep ≥4 bright unflagged neighbors
379///   (`test_detect_hot_pixels_hard_edged_bright_rectangle_corners_only`).
380///
381/// The two stages encode complementary definitions of "hot": stage 1 says
382/// *statistically implausible for this image*, stage 2 says *spatially
383/// isolated*, and a railed/hot pixel is a detector **point defect** — it
384/// must be both.  Stage 2 exists because the global cut alone fails
385/// catastrophically on **bimodal** images: with a dark majority holding the
386/// median (a sample covering >50 % of the FOV, or an aperture-limited open
387/// beam), the MAD reflects only the dark population's internal spread and
388/// the ENTIRE bright minority exceeds `med + k·MAD`.  A contiguous bright
389/// REGION is scene, not a defect — masking it would reject statistically
390/// plausible pixels, the exact failure the module rules ban.  A true point
391/// defect beats its neighbor median by ≥100× and is caught by both stages
392/// even *inside* a bright region and even as part of a railed row/column
393/// or a small railed cluster (see [`HOT_LOCAL_FACTOR`]).
394///
395/// # Raw counts required
396///
397/// `data` must be **raw detected counts** (unscaled).  The Poisson floor in
398/// step 4 assumes `Var[N] = N`; any prior scaling silently breaks that
399/// identity — down-scaling (proton-charge-normalized rates ≪ 1, per-pixel
400/// gain division) inflates the floor and can suppress real flags, while
401/// up-scaling (event weights > 1) deflates it below true counting noise.
402/// Run the detectors on unscaled counts and normalize afterwards; the GUI
403/// does exactly this (all three of its raw-counts paths — TIFF pair,
404/// HDF5 with open beam, HDF5 without open beam — pass the raw
405/// sample/open-beam stacks, before any normalization).
406///
407/// # Arguments
408/// * `data` — 3D raw-counts array with shape (n_tof, height, width).
409/// * `k_mad` — Robust-σ multiplier for the stage-1 upper-tail cut; use
410///   [`HOT_PIXEL_K_MAD`] unless you have a reason not to.
411///
412/// # Returns
413/// 2D boolean mask, shape (height, width). `true` = hot pixel.
414///
415/// # Errors
416/// Returns `IoError::InvalidParameter` if `data` contains a non-finite or
417/// negative value or has an empty TOF axis (`shape[0] == 0`), or if `k_mad`
418/// is not finite and positive.
419pub fn detect_hot_pixels(data: &Array3<f64>, k_mad: f64) -> Result<Array2<bool>, IoError> {
420    validate_counts(data, "data")?;
421    // An empty TOF axis would yield an all-zero totals image (empty live
422    // set → all-false mask) rather than corrupt output, but the validating
423    // entry points reject it uniformly (see validate_n_tof).
424    validate_n_tof(data, "data")?;
425    // NaN bypasses `>`, so pair the order comparison with is_finite().
426    if !(k_mad.is_finite() && k_mad > 0.0) {
427        return Err(IoError::InvalidParameter(format!(
428            "k_mad must be finite and > 0, got {k_mad}"
429        )));
430    }
431
432    let totals = data.sum_axis(Axis(0));
433    let mut mask = Array2::from_elem(totals.raw_dim(), false);
434
435    // Statistics over live (totals > 0) pixels only: ln(0) never enters, and
436    // a large dead population cannot drag the median down.
437    let log_totals: Vec<f64> = totals
438        .iter()
439        .filter(|&&t| t > 0.0)
440        .map(|&t| t.ln())
441        .collect();
442    let Some(med) = nereids_core::stats::median(&log_totals) else {
443        // No live pixels at all: nothing to compare against — flag nothing.
444        return Ok(mask);
445    };
446    // unwrap is safe here: median() returned Some, so log_totals is non-empty.
447    let mad = nereids_core::stats::median_abs_deviation(&log_totals, med).unwrap();
448
449    // Delta-method Poisson floor of ln N at the median total (see rustdoc
450    // step 4): the robust scale can never sit below counting noise.
451    let sigma = f64::max(nereids_core::stats::MAD_TO_SIGMA * mad, (-med / 2.0).exp());
452    let threshold = med + k_mad * sigma;
453
454    // Stage 1 (global robust cut), upper tail only: the candidate list is
455    // fixed for the whole stage-2 fixpoint iteration below.
456    let (height, width) = totals.dim();
457    let mut candidates: Vec<(usize, usize)> = Vec::new();
458    for y in 0..height {
459        for x in 0..width {
460            let total = totals[[y, x]];
461            if total > 0.0 && total.ln() > threshold {
462                candidates.push((y, x));
463            }
464        }
465    }
466
467    // Stage 2 (local confirmation): only a spatially ISOLATED excess is a
468    // detector point defect — a contiguous bright region is scene.  This is
469    // what keeps the global cut honest on bimodal images (see rustdoc).
470    //
471    // Iterated to a FIXPOINT to erode railed clusters from their boundary
472    // inward (see rustdoc, "Fixpoint erosion of railed clusters"): each
473    // pass evaluates the not-yet-flagged candidates against the PREVIOUS
474    // pass's mask (batch update — order-independent within a pass), and
475    // the loop ends when a pass adds no new flag.
476    //
477    // Termination bound: flags are only ever added and every pass except
478    // the last adds at least one, so ≤ height·width passes can do work;
479    // the explicit cap makes that bound structural.  Pass 1 sees an
480    // all-false mask and is exactly the pre-fixpoint single pass.
481    let max_passes = height * width;
482    let mut neighbor_totals: Vec<f64> = Vec::with_capacity(8);
483    for _pass in 0..max_passes {
484        let mut newly_flagged: Vec<(usize, usize)> = Vec::new();
485        for &(y, x) in &candidates {
486            if mask[[y, x]] {
487                continue;
488            }
489            let total = totals[[y, x]];
490            // 8-neighborhood reference sample; edge pixels use what exists.
491            neighbor_totals.clear();
492            for ny in y.saturating_sub(1)..=(y + 1).min(height - 1) {
493                for nx in x.saturating_sub(1)..=(x + 1).min(width - 1) {
494                    if (ny, nx) == (y, x) {
495                        continue;
496                    }
497                    if mask[[ny, nx]] {
498                        // Already-flagged neighbor: a known defect cannot
499                        // vouch for the candidate.  It contributes the
500                        // lowest possible scene value (zero total) so the
501                        // reference median is not dragged up by the defect
502                        // itself — load-bearing for cluster erosion (see
503                        // rustdoc: an omitted neighbor leaves an even-count
504                        // sample whose midpoint median stalls the erosion).
505                        neighbor_totals.push(0.0);
506                    } else {
507                        // Live (total > 0) unflagged neighbors contribute
508                        // their totals; dead neighbors carry no scene
509                        // information and are omitted.
510                        let t = totals[[ny, nx]];
511                        if t > 0.0 {
512                            neighbor_totals.push(t);
513                        }
514                    }
515                }
516            }
517            let flagged = match nereids_core::stats::median(&neighbor_totals) {
518                Some(local_med) => total > HOT_LOCAL_FACTOR * local_med,
519                // Empty sample: every neighbor is dead (isolated live pixel
520                // in a dead field) — nothing local can refute the global
521                // verdict.  (A mostly-flagged neighborhood is NOT empty:
522                // the zeros give a reference median of 0 and the candidate
523                // stays flagged, the consistent generalization.)
524                None => true,
525            };
526            if flagged {
527                newly_flagged.push((y, x));
528            }
529        }
530        if newly_flagged.is_empty() {
531            break;
532        }
533        for &(y, x) in &newly_flagged {
534            mask[[y, x]] = true;
535        }
536    }
537    Ok(mask)
538}
539
540/// Detect all pipeline-corrupting pixels: dead ∪ hot over sample and
541/// (optionally) open beam.
542///
543/// This is the validating entry point that the GUI and Python bindings
544/// should use.  Deadness/hotness is per-acquisition — a pixel dead only in
545/// the open-beam run still corrupts every transmission ratio computed from
546/// it — so the masks of both stacks are unioned:
547///
548/// `mask = dead(sample) ∪ hot(sample) [∪ dead(open_beam) ∪ hot(open_beam)]`
549///
550/// The stacks' TOF axis lengths may differ (deadness is spatial); only the
551/// spatial dimensions must agree.
552///
553/// Both stacks must be **raw detected counts** (unscaled) — see the "Raw
554/// counts required" section of [`detect_hot_pixels`]: scaling distorts the
555/// Poisson floor of the hot screen.  The GUI satisfies this: all three of
556/// its raw-counts paths (TIFF pair, HDF5 with open beam, HDF5 without
557/// open beam) call this function on the raw sample/open-beam stacks,
558/// before any normalization.
559///
560/// # Arguments
561/// * `sample` — Raw sample counts, shape (n_tof, height, width).
562/// * `open_beam` — Optional raw open-beam counts, shape
563///   (n_tof', height, width).
564/// * `hot_k_mad` — `Some(k)` to include the [`detect_hot_pixels`] screen
565///   with multiplier `k` (use [`HOT_PIXEL_K_MAD`]); `None` for dead-only
566///   detection.
567///
568/// # Returns
569/// 2D boolean mask, shape (height, width). `true` = exclude pixel.
570///
571/// # Errors
572/// Returns `IoError::InvalidParameter` if either stack contains a
573/// non-finite or negative value or has an empty TOF axis (`shape[0] == 0`
574/// — the dead test over zero bins would vacuously mask every pixel) or
575/// `hot_k_mad` is `Some` of a non-finite/non-positive value, and
576/// `IoError::ShapeMismatch` if the spatial dimensions differ.
577pub fn detect_bad_pixels(
578    sample: &Array3<f64>,
579    open_beam: Option<&Array3<f64>>,
580    hot_k_mad: Option<f64>,
581) -> Result<Array2<bool>, IoError> {
582    // Validate everything up front (house rule), before any detector runs.
583    // The public detectors called below re-validate; that duplication is one
584    // O(n) sweep and keeps each entry point independently safe.
585    validate_counts(sample, "sample")?;
586    validate_n_tof(sample, "sample")?;
587    if let Some(ob) = open_beam {
588        validate_counts(ob, "open_beam")?;
589        validate_n_tof(ob, "open_beam")?;
590        let (ss, os) = (sample.shape(), ob.shape());
591        // n_tof may differ (deadness is spatial); spatial dims must agree.
592        if ss[1] != os[1] || ss[2] != os[2] {
593            return Err(IoError::ShapeMismatch(format!(
594                "sample spatial dims ({}, {}) != open_beam spatial dims ({}, {})",
595                ss[1], ss[2], os[1], os[2],
596            )));
597        }
598    }
599    if let Some(k) = hot_k_mad {
600        // NaN bypasses `>`, so pair the order comparison with is_finite().
601        if !(k.is_finite() && k > 0.0) {
602            return Err(IoError::InvalidParameter(format!(
603                "hot_k_mad must be finite and > 0, got {k}"
604            )));
605        }
606    }
607
608    let mut mask = detect_dead_pixels(sample);
609    if let Some(k) = hot_k_mad {
610        let hot = detect_hot_pixels(sample, k)?;
611        Zip::from(&mut mask)
612            .and(&hot)
613            .for_each(|m, &h| *m = *m || h);
614    }
615    if let Some(ob) = open_beam {
616        let ob_dead = detect_dead_pixels(ob);
617        Zip::from(&mut mask)
618            .and(&ob_dead)
619            .for_each(|m, &d| *m = *m || d);
620        if let Some(k) = hot_k_mad {
621            let ob_hot = detect_hot_pixels(ob, k)?;
622            Zip::from(&mut mask)
623                .and(&ob_hot)
624                .for_each(|m, &h| *m = *m || h);
625        }
626    }
627    Ok(mask)
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633
634    #[test]
635    fn test_extract_spectrum() {
636        // 3 TOF bins, 2×2 image
637        let mut data = Array3::<f64>::zeros((3, 2, 2));
638        data[[0, 1, 0]] = 10.0;
639        data[[1, 1, 0]] = 20.0;
640        data[[2, 1, 0]] = 30.0;
641
642        let spectrum = extract_spectrum(&data, 1, 0);
643        assert_eq!(spectrum.len(), 3);
644        assert_eq!(spectrum[0], 10.0);
645        assert_eq!(spectrum[1], 20.0);
646        assert_eq!(spectrum[2], 30.0);
647    }
648
649    #[test]
650    fn test_average_roi() {
651        // 2 TOF bins, 4×4 image. Set a 2×2 region to known values.
652        let mut data = Array3::<f64>::zeros((2, 4, 4));
653        // TOF bin 0: region [1..3, 1..3] = 100
654        for y in 1..3 {
655            for x in 1..3 {
656                data[[0, y, x]] = 100.0;
657                data[[1, y, x]] = 200.0;
658            }
659        }
660
661        let avg = average_roi(&data, 1..3, 1..3).unwrap();
662        assert_eq!(avg.len(), 2);
663        assert!((avg[0] - 100.0).abs() < 1e-10);
664        assert!((avg[1] - 200.0).abs() < 1e-10);
665    }
666
667    #[test]
668    fn test_detect_dead_pixels() {
669        let mut data = Array3::<f64>::zeros((3, 2, 2));
670        // Pixel (0,0) is dead (all zeros)
671        // Pixel (0,1) has a count in frame 1
672        data[[1, 0, 1]] = 5.0;
673        // Pixel (1,0) has counts
674        data[[0, 1, 0]] = 10.0;
675        // Pixel (1,1) is dead
676
677        let mask = detect_dead_pixels(&data);
678        assert!(mask[[0, 0]]); // dead
679        assert!(!mask[[0, 1]]); // alive
680        assert!(!mask[[1, 0]]); // alive
681        assert!(mask[[1, 1]]); // dead
682    }
683
684    #[test]
685    fn test_detect_dead_pixels_chunked_catches_intermittent() {
686        // ACCEPTANCE: pixel (0, 1) is dead throughout chunk 0 but
687        // alive in chunk 1 — intermittent deadness that corrupts the
688        // combined spectrum.
689        let mut chunk0 = Array3::from_elem((3, 2, 2), 5.0);
690        for t in 0..3 {
691            chunk0[[t, 0, 1]] = 0.0;
692        }
693        let chunk1 = Array3::from_elem((3, 2, 2), 5.0);
694
695        let mask = detect_dead_pixels_chunked(&[chunk0.clone(), chunk1.clone()]).unwrap();
696        assert!(mask[[0, 1]], "intermittently dead pixel must be flagged");
697        assert!(!mask[[0, 0]]);
698        assert!(!mask[[1, 0]]);
699        assert!(!mask[[1, 1]]);
700
701        // The gap being closed: on the element-wise summed stack the pixel
702        // has nonzero counts everywhere, so detect_dead_pixels misses it.
703        let summed = &chunk0 + &chunk1;
704        let summed_mask = detect_dead_pixels(&summed);
705        assert!(
706            !summed_mask[[0, 1]],
707            "summed-stack detection cannot see intermittent deadness — \
708             that is exactly why the chunked variant exists"
709        );
710    }
711
712    #[test]
713    fn test_detect_dead_pixels_chunked_low_count_alive_not_flagged() {
714        // ACCEPTANCE: a pixel with a single count per chunk is alive
715        // and must be kept — masks are pipeline-integrity only, never a
716        // low-count screen.
717        let mut chunk0 = Array3::from_elem((4, 2, 2), 5.0);
718        let mut chunk1 = Array3::from_elem((4, 2, 2), 5.0);
719        for t in 0..4 {
720            chunk0[[t, 1, 1]] = 0.0;
721            chunk1[[t, 1, 1]] = 0.0;
722        }
723        chunk0[[2, 1, 1]] = 1.0; // one lone count in chunk 0
724        chunk1[[0, 1, 1]] = 1.0; // one lone count in chunk 1
725
726        let mask = detect_dead_pixels_chunked(&[chunk0, chunk1]).unwrap();
727        assert!(!mask[[1, 1]], "low-count-alive pixel must not be flagged");
728    }
729
730    #[test]
731    fn test_detect_dead_pixels_chunked_empty_slice_err() {
732        let err = detect_dead_pixels_chunked(&[]).unwrap_err();
733        assert!(matches!(err, IoError::InvalidParameter(_)));
734    }
735
736    #[test]
737    fn test_detect_dead_pixels_chunked_empty_tof_chunk_err() {
738        // A zero-frame chunk would vacuously mark the whole detector dead.
739        let chunk0 = Array3::from_elem((3, 2, 2), 1.0);
740        let chunk1 = Array3::<f64>::zeros((0, 2, 2));
741        let err = detect_dead_pixels_chunked(&[chunk0, chunk1]).unwrap_err();
742        assert!(matches!(err, IoError::InvalidParameter(_)));
743        assert!(err.to_string().contains("chunks[1]"));
744        assert!(err.to_string().contains("empty TOF axis"));
745    }
746
747    #[test]
748    fn test_detect_dead_pixels_chunked_spatial_mismatch_err() {
749        let chunk0 = Array3::from_elem((3, 2, 2), 1.0);
750        let chunk1 = Array3::from_elem((3, 2, 3), 1.0);
751        let err = detect_dead_pixels_chunked(&[chunk0, chunk1]).unwrap_err();
752        assert!(matches!(err, IoError::ShapeMismatch(_)));
753    }
754
755    #[test]
756    fn test_detect_dead_pixels_chunked_nan_err() {
757        let chunk0 = Array3::from_elem((3, 2, 2), 1.0);
758        let mut chunk1 = Array3::from_elem((3, 2, 2), 1.0);
759        chunk1[[0, 0, 0]] = f64::NAN;
760        let err = detect_dead_pixels_chunked(&[chunk0, chunk1]).unwrap_err();
761        assert!(matches!(err, IoError::InvalidParameter(_)));
762        assert!(err.to_string().contains("chunks[1]"));
763    }
764
765    #[test]
766    fn test_detect_dead_pixels_chunked_ragged_n_tof_ok() {
767        // Ragged event re-histogramming: n_tof may differ between chunks.
768        let chunk0 = Array3::from_elem((3, 2, 2), 1.0);
769        let chunk1 = Array3::from_elem((7, 2, 2), 1.0);
770        let mask = detect_dead_pixels_chunked(&[chunk0, chunk1]).unwrap();
771        assert!(mask.iter().all(|&m| !m));
772    }
773
774    #[test]
775    fn test_detect_hot_pixels_catches_railed() {
776        // ACCEPTANCE: a railed pixel (65535 counts/bin) amid a
777        // realistic slightly varying background (~100/bin) is flagged;
778        // its neighbors are not.
779        let mut data = Array3::<f64>::zeros((4, 3, 3));
780        for t in 0..4 {
781            for y in 0..3 {
782                for x in 0..3 {
783                    // Background 95..103 counts/bin (totals 380..412).
784                    data[[t, y, x]] = 95.0 + (y * 3 + x) as f64;
785                }
786            }
787        }
788        for t in 0..4 {
789            data[[t, 1, 1]] = 65535.0;
790        }
791
792        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
793        assert!(mask[[1, 1]], "railed pixel must be flagged");
794        for y in 0..3 {
795            for x in 0..3 {
796                if (y, x) != (1, 1) {
797                    assert!(!mask[[y, x]], "neighbor ({y}, {x}) must not be flagged");
798                }
799            }
800        }
801    }
802
803    #[test]
804    fn test_detect_hot_pixels_low_count_alive_not_flagged() {
805        // ACCEPTANCE — the 13%-rejection regression guard: a pixel
806        // with 1 total count amid ~300-count pixels is low-count-ALIVE and
807        // must be kept.  The screen is upper-tail only.
808        let mut data = Array3::from_elem((3, 3, 3), 100.0);
809        for t in 0..3 {
810            data[[t, 0, 2]] = 0.0;
811        }
812        data[[1, 0, 2]] = 1.0; // total 1 count
813
814        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
815        assert!(!mask[[0, 2]], "low-count-alive pixel must not be flagged");
816        assert!(mask.iter().all(|&m| !m), "nothing here is hot");
817    }
818
819    #[test]
820    fn test_detect_hot_pixels_uniform_image_poisson_floor_path() {
821        // Perfectly uniform background → MAD == 0 → the delta-method
822        // Poisson floor exp(-med/2) is the active scale.  The railed pixel
823        // must still be flagged, the uniform background must not.
824        let mut data = Array3::from_elem((4, 3, 3), 100.0);
825        for t in 0..4 {
826            data[[t, 2, 0]] = 65535.0;
827        }
828
829        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
830        assert!(mask[[2, 0]]);
831        assert_eq!(mask.iter().filter(|&&m| m).count(), 1);
832    }
833
834    #[test]
835    fn test_detect_hot_pixels_quantized_low_counts_none_flagged() {
836        // Worked check from the rustdoc: mostly 1-count totals with some
837        // 2-count totals → mad = 0, floor = 1, threshold = e^6 ≈ 403× the
838        // median — the 2-count pixels are NOT flagged.
839        let mut data = Array3::from_elem((1, 3, 3), 1.0);
840        data[[0, 0, 0]] = 2.0;
841        data[[0, 2, 2]] = 2.0;
842
843        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
844        assert!(
845            mask.iter().all(|&m| !m),
846            "quantized low-count image must produce no hot flags"
847        );
848    }
849
850    /// Build the 8×8 single-TOF-bin two-moat fixture for the stage-1
851    /// robust-scale tests ( P1-2): two 3×3 all-dead moats
852    /// (rows 1–3 × cols 1–3 and rows 4–6 × cols 4–6) with a `probe` at
853    /// (2,2) and a `control` at (5,5) in their centers, and the remaining
854    /// 46 pixels filled row-major with `n_a` × `a` then (46 − n_a) × `b`.
855    ///
856    /// A dead moat makes a probe's stage-2 reference sample EMPTY (every
857    /// neighbor dead → omitted), which keeps the global verdict — so a
858    /// probe's flag outcome is decided purely by the stage-1 threshold
859    /// under test, never vetoed by the local confirmation.  Dead pixels
860    /// are excluded from the stage-1 statistics, so with `n_a = 24` the
861    /// live sample is 24×`a`, 22×`b`, probe, control (n = 48, both
862    /// probes above `b`): the two central order statistics are the 24th
863    /// (= ln a) and 25th (= ln b), pinning `med = (ln a + ln b)/2`
864    /// exactly; the deviation sample has 46 × |ln(b/a)|/2 below the two
865    /// probe deviations, pinning `mad = |ln(b/a)|/2` exactly.
866    fn stage1_two_moat_grid(a: f64, n_a: usize, b: f64, probe: f64, control: f64) -> Array3<f64> {
867        let mut data = Array3::<f64>::zeros((1, 8, 8));
868        data[[0, 2, 2]] = probe;
869        data[[0, 5, 5]] = control;
870        let mut filled = 0usize;
871        for y in 0..8 {
872            for x in 0..8 {
873                let in_moat1 = (1..=3).contains(&y) && (1..=3).contains(&x);
874                let in_moat2 = (4..=6).contains(&y) && (4..=6).contains(&x);
875                if in_moat1 || in_moat2 {
876                    // Moat cells stay 0.0 (dead); the probe/control
877                    // assignments above are inside the moats and survive.
878                    continue;
879                }
880                data[[0, y, x]] = if filled < n_a { a } else { b };
881                filled += 1;
882            }
883        }
884        assert_eq!(filled, 46, "8×8 minus two 3×3 moats is 46 background px");
885        data
886    }
887
888    /// P1-2 (1/3): the robust-MAD branch of the stage-1
889    /// scale `sigma = max(MAD_TO_SIGMA·mad, exp(−med/2))` DECIDES an
890    /// outcome.  Every earlier test drove sigma through the Poisson
891    /// floor (uniform/quantized backgrounds → mad = 0), so a mutation of
892    /// the MAD branch survived the suite.
893    ///
894    /// Fixture (see [`stage1_two_moat_grid`]): 24 × A = 8000,
895    /// 22 × B = 12500, probe P = 40 000, control C = 200 000.
896    /// A·B = 10⁸ and B/A = 1.25², so ln A = 8.9871968 and
897    /// ln B = 9.4334839 sit symmetrically about ln 10⁴.  Worked stage-1
898    /// arithmetic over the 48 live pixels:
899    ///
900    ///   med = (ln A + ln B)/2 = ln 10⁴          = 9.2103404
901    ///   mad = ln 1.25                            = 0.2231436
902    ///   MAD term = 1.4826022 × 0.2231436         = 0.3308331
903    ///   floor    = exp(−med/2) = 10⁻²            = 0.01
904    ///   sigma = max(0.3308331, 0.01)             = 0.3308331   (MAD wins)
905    ///   threshold = med + 6·sigma                = 11.1953391
906    ///
907    /// Probe: ln P = 10.5966347 < 11.1953391 (margin 0.60) → NOT a
908    /// candidate → kept.  Under a floor-only mutation (sigma = 0.01) the
909    /// threshold collapses to 9.2703404 < ln P and the dead moat flags
910    /// the probe unconditionally — this assertion fails.
911    /// Control: ln C = 12.2060726 > 11.1953391 (margin 1.01) → flagged;
912    /// a mutation that INFLATES the MAD term (≥2× → threshold ≥ 13.18)
913    /// unflags it.  The MAD value itself separates the two probes.
914    #[test]
915    fn test_detect_hot_pixels_mad_scale_decides_stage1_threshold() {
916        let data = stage1_two_moat_grid(8000.0, 24, 12500.0, 40_000.0, 200_000.0);
917        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
918        assert!(
919            !mask[[2, 2]],
920            "probe below the MAD-driven threshold must be kept"
921        );
922        assert!(
923            mask[[5, 5]],
924            "control above the MAD-driven threshold must be flagged"
925        );
926        assert_eq!(mask.iter().filter(|&&m| m).count(), 1);
927    }
928
929    /// P1-2 (2/3) — crossover pin, MAD term just ABOVE
930    /// the Poisson floor: `max()` must pick the MAD branch.
931    ///
932    /// Fixture: 24 × A = 100/1.08, 22 × B = 108 (A·B = 10⁴ →
933    /// med = (ln A + ln B)/2 = ln 100 = 4.6051702, floor =
934    /// exp(−med/2) = 10⁻¹ = 0.1), probe P = 190, control C = 10 000.
935    ///
936    ///   mad = ln 1.08                            = 0.0769610
937    ///   MAD term = 1.4826022 × 0.0769610         = 0.1141026
938    ///     → 14 % ABOVE the 0.1 floor: MAD branch wins, barely.
939    ///   correct threshold = 4.6051702 + 6×0.1141026 = 5.2897858
940    ///   floor-mutant thr  = 4.6051702 + 6×0.1       = 5.2051702
941    ///
942    /// ln P = ln 190 = 5.2470241 sits BETWEEN the two thresholds
943    /// (margins 0.042 above the mutant's, 0.043 below the correct one):
944    /// correct code keeps the probe; a mutant that resolves the
945    /// crossover the wrong way (max→min, dropped MAD branch, deflated
946    /// MAD_TO_SIGMA) flags it via the dead moat.  Control:
947    /// ln C = 9.2103404 ≫ 5.2897858 → flagged (liveness control).
948    #[test]
949    fn test_detect_hot_pixels_mad_term_just_above_poisson_floor_wins() {
950        let data = stage1_two_moat_grid(100.0 / 1.08, 24, 108.0, 190.0, 10_000.0);
951        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
952        assert!(
953            !mask[[2, 2]],
954            "probe between floor and MAD thresholds must be kept when the MAD branch wins"
955        );
956        assert!(mask[[5, 5]], "control must be flagged");
957        assert_eq!(mask.iter().filter(|&&m| m).count(), 1);
958    }
959
960    /// P1-2 (3/3) — crossover pin, MAD term just BELOW
961    /// the Poisson floor: `max()` must pick the floor.
962    ///
963    /// Fixture: 24 × A = 100/1.06, 22 × B = 106 (med = ln 100 =
964    /// 4.6051702, floor = 0.1 as above), probe P = 175, control
965    /// C = 10 000.
966    ///
967    ///   mad = ln 1.06                            = 0.0582689
968    ///   MAD term = 1.4826022 × 0.0582689         = 0.0863896
969    ///     → 14 % BELOW the 0.1 floor: the floor wins, barely.
970    ///   correct threshold = 4.6051702 + 6×0.1        = 5.2051702
971    ///   MAD-mutant thr    = 4.6051702 + 6×0.0863896  = 5.1235079
972    ///
973    /// ln P = ln 175 = 5.1647860 sits BETWEEN the two thresholds
974    /// (margins 0.041 above the mutant's, 0.040 below the correct one):
975    /// correct code keeps the probe; a mutant that drops the floor
976    /// (sigma = MAD term unconditionally — here max→min picks the MAD
977    /// term) flags it via the dead moat.  Together with the ABOVE case
978    /// this pins both branches of the crossover and the `max()` itself
979    /// from both sides.
980    #[test]
981    fn test_detect_hot_pixels_poisson_floor_just_above_mad_term_wins() {
982        let data = stage1_two_moat_grid(100.0 / 1.06, 24, 106.0, 175.0, 10_000.0);
983        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
984        assert!(
985            !mask[[2, 2]],
986            "probe between MAD and floor thresholds must be kept when the floor wins"
987        );
988        assert!(mask[[5, 5]], "control must be flagged");
989        assert_eq!(mask.iter().filter(|&&m| m).count(), 1);
990    }
991
992    #[test]
993    fn test_detect_hot_pixels_majority_dead_live_not_flagged() {
994        // A large dead population must not drag the median down and get the
995        // few live pixels flagged: the statistics sample is live-only.
996        let mut data = Array3::<f64>::zeros((1, 4, 4));
997        data[[0, 0, 0]] = 49.0;
998        data[[0, 1, 1]] = 50.0;
999        data[[0, 2, 2]] = 51.0;
1000
1001        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1002        assert!(
1003            mask.iter().all(|&m| !m),
1004            "live pixels amid a dead majority must not be flagged"
1005        );
1006    }
1007
1008    #[test]
1009    fn test_detect_hot_pixels_all_dead_all_false() {
1010        let data = Array3::<f64>::zeros((3, 2, 2));
1011        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1012        assert!(mask.iter().all(|&m| !m));
1013    }
1014
1015    #[test]
1016    fn test_detect_hot_pixels_bimodal_bright_region_not_flagged() {
1017        // THE PR- P0 regression guard. Dark-majority bimodal scene
1018        // 60 % of the FOV at 50 counts (sample), a contiguous 40 % bright
1019        // region at 5000 counts (open beam past the sample edge).  The dark
1020        // population holds the median (med = ln 50) and the MAD is 0 (both
1021        // populations are internally uniform), so sigma falls to the Poisson
1022        // floor 1/√50 ≈ 0.141 and the stage-1 threshold is
1023        // ln 50 + 6·0.141 ≈ 4.76 — EVERY bright pixel (ln 5000 ≈ 8.52)
1024        // passes the global cut.  The local confirmation must veto them
1025        // all: each bright pixel's neighbor median is 5000, and
1026        // 5000 > 10 × 5000 is false.  Bright scene is not a defect.
1027        let mut data = Array3::from_elem((1, 10, 10), 50.0);
1028        for y in 0..10 {
1029            for x in 6..10 {
1030                data[[0, y, x]] = 5000.0;
1031            }
1032        }
1033
1034        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1035        assert!(
1036            mask.iter().all(|&m| !m),
1037            "no pixel of a bimodal scene may be flagged — the bright \
1038             minority region is scene, not a defect"
1039        );
1040    }
1041
1042    #[test]
1043    fn test_detect_hot_pixels_railed_inside_bright_region_caught() {
1044        // Same bimodal scene, but with a genuinely railed pixel INSIDE the
1045        // bright region: 1e6 ≈ 200× its bright neighbors.  It must still be
1046        // caught (stage 2 compares against the LOCAL median of 5000, not
1047        // the global dark median).
1048        let mut data = Array3::from_elem((1, 10, 10), 50.0);
1049        for y in 0..10 {
1050            for x in 6..10 {
1051                data[[0, y, x]] = 5000.0;
1052            }
1053        }
1054        data[[0, 5, 8]] = 1.0e6;
1055
1056        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1057        assert!(
1058            mask[[5, 8]],
1059            "railed pixel inside bright region must be flagged"
1060        );
1061        assert_eq!(
1062            mask.iter().filter(|&&m| m).count(),
1063            1,
1064            "only the railed pixel may be flagged"
1065        );
1066    }
1067
1068    #[test]
1069    fn test_detect_hot_pixels_railed_column_segment_caught() {
1070        // Three adjacent railed pixels in a column: each has ≥5 normal
1071        // neighbors of 8 (the middle one has 6), so the neighbor MEDIAN
1072        // stays at the background level and the whole segment is caught.
1073        let mut data = Array3::from_elem((4, 7, 7), 100.0);
1074        for t in 0..4 {
1075            for y in 2..5 {
1076                data[[t, y, 3]] = 65535.0;
1077            }
1078        }
1079
1080        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1081        for y in 2..5 {
1082            assert!(mask[[y, 3]], "railed column pixel ({y}, 3) must be flagged");
1083        }
1084        assert_eq!(
1085            mask.iter().filter(|&&m| m).count(),
1086            3,
1087            "only the railed segment may be flagged"
1088        );
1089    }
1090
1091    #[test]
1092    fn test_detect_hot_pixels_2x2_railed_cluster_fully_caught() {
1093        // Fixpoint acceptance: a 2×2 railed cluster
1094        // has no interior — every pixel keeps 5 background neighbors of 8,
1095        // so the whole cluster is caught in the first pass.  Regression
1096        // guard for the smallest ≥2-px-wide cluster.
1097        let mut data = Array3::from_elem((4, 7, 7), 100.0);
1098        for t in 0..4 {
1099            for y in 2..4 {
1100                for x in 2..4 {
1101                    data[[t, y, x]] = 65535.0;
1102                }
1103            }
1104        }
1105
1106        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1107        for y in 2..4 {
1108            for x in 2..4 {
1109                assert!(mask[[y, x]], "2x2 cluster pixel ({y}, {x}) must be flagged");
1110            }
1111        }
1112        assert_eq!(
1113            mask.iter().filter(|&&m| m).count(),
1114            4,
1115            "only the railed cluster may be flagged"
1116        );
1117    }
1118
1119    #[test]
1120    fn test_detect_hot_pixels_3x3_railed_blob_fully_caught() {
1121        // Fixpoint acceptance — THE erosion proof.
1122        // A 3×3 railed blob: pass 1 flags only the 4 corners (5 background
1123        // neighbors each); the edge centers (3 bg + 5 railed) and the
1124        // interior (8 railed) are refuted by a single pass — the
1125        // pre-fixpoint code missed them.  With flagged neighbors
1126        // contributing zero totals, pass 2 flags the edge centers
1127        // (sample [0, 0, bg, bg, bg, R, R, R] → median bg) and pass 3 the
1128        // interior (all-flagged neighborhood → median 0).
1129        let mut data = Array3::from_elem((4, 9, 9), 100.0);
1130        for t in 0..4 {
1131            for y in 3..6 {
1132                for x in 3..6 {
1133                    data[[t, y, x]] = 65535.0;
1134                }
1135            }
1136        }
1137
1138        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1139        for y in 3..6 {
1140            for x in 3..6 {
1141                assert!(
1142                    mask[[y, x]],
1143                    "3x3 blob pixel ({y}, {x}) must be flagged (interior included)"
1144                );
1145            }
1146        }
1147        assert_eq!(
1148            mask.iter().filter(|&&m| m).count(),
1149            9,
1150            "only the railed blob may be flagged"
1151        );
1152    }
1153
1154    #[test]
1155    fn test_detect_hot_pixels_2px_wide_railed_column_fully_caught() {
1156        // Fixpoint acceptance: the interior of a
1157        // 2-px-wide railed column (3 bg + 5 railed neighbors per interior
1158        // pixel) was invisible to the single pass — only the 4 end pixels
1159        // (5 bg neighbors each) flagged.  The fixpoint erodes the column
1160        // pairwise from both ends.
1161        let mut data = Array3::from_elem((4, 9, 9), 100.0);
1162        for t in 0..4 {
1163            for y in 2..7 {
1164                for x in 3..5 {
1165                    data[[t, y, x]] = 65535.0;
1166                }
1167            }
1168        }
1169
1170        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1171        for y in 2..7 {
1172            for x in 3..5 {
1173                assert!(
1174                    mask[[y, x]],
1175                    "2-px-wide column pixel ({y}, {x}) must be flagged"
1176                );
1177            }
1178        }
1179        assert_eq!(
1180            mask.iter().filter(|&&m| m).count(),
1181            10,
1182            "only the railed column may be flagged"
1183        );
1184    }
1185
1186    #[test]
1187    fn test_detect_hot_pixels_edge_to_edge_2px_band_not_flagged_by_design() {
1188        // Documented-limitation pin: an EDGE-TO-EDGE
1189        // railed band ≥2 px wide (both ends off-detector) exposes no end
1190        // cap or convex corner, so the erosion has no seed — interior
1191        // band pixels keep 3 bg + 5 railed neighbors (median railed) and
1192        // even the on-detector-border band ends keep 2 bg + 3 railed
1193        // (median railed).  Nothing flags, deliberately: a slit-aperture
1194        // open beam produces a genuine full-width bright SCENE band that
1195        // is pixel-for-pixel indistinguishable from this defect, and a
1196        // full-span row/column screen would mask it (the bimodal
1197        // failure).  Such detector pathologies belong in a declared/file
1198        // mask (see rustdoc, "Fixpoint erosion of railed clusters").
1199        let mut data = Array3::from_elem((4, 9, 9), 100.0);
1200        for t in 0..4 {
1201            for y in 3..5 {
1202                for x in 0..9 {
1203                    data[[t, y, x]] = 65535.0;
1204                }
1205            }
1206        }
1207
1208        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1209        assert!(
1210            mask.iter().all(|&m| !m),
1211            "an edge-to-edge ≥2-px railed band must NOT be flagged \
1212             (documented limitation — geometrically ambiguous with a \
1213             slit-aperture bright scene band)"
1214        );
1215    }
1216
1217    #[test]
1218    fn test_detect_hot_pixels_2px_band_one_end_on_detector_fully_caught() {
1219        // Companion to the edge-to-edge pin: the
1220        // same 2-row railed band, but with ONE end cap inside the
1221        // detector.  The two cap pixels keep 5 bg + 3 railed neighbors
1222        // (median bg) and seed in pass 1; the fixpoint then erodes the
1223        // band column-pair by column-pair all the way to the opposite
1224        // (detector-border) end.
1225        let mut data = Array3::from_elem((4, 9, 9), 100.0);
1226        for t in 0..4 {
1227            for y in 3..5 {
1228                for x in 0..7 {
1229                    data[[t, y, x]] = 65535.0;
1230                }
1231            }
1232        }
1233
1234        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1235        for y in 3..5 {
1236            for x in 0..7 {
1237                assert!(mask[[y, x]], "band pixel ({y}, {x}) must be flagged");
1238            }
1239        }
1240        assert_eq!(
1241            mask.iter().filter(|&&m| m).count(),
1242            14,
1243            "only the railed band may be flagged"
1244        );
1245    }
1246
1247    #[test]
1248    fn test_detect_hot_pixels_full_railed_column_caught() {
1249        // Regression ( F1 test 3): a full-height 1-px railed
1250        // column — every pixel keeps ≥4 background neighbors, so the whole
1251        // line is caught in pass 1, exactly as before the fixpoint.
1252        let mut data = Array3::from_elem((4, 7, 7), 100.0);
1253        for t in 0..4 {
1254            for y in 0..7 {
1255                data[[t, y, 3]] = 65535.0;
1256            }
1257        }
1258
1259        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1260        for y in 0..7 {
1261            assert!(mask[[y, 3]], "railed column pixel ({y}, 3) must be flagged");
1262        }
1263        assert_eq!(
1264            mask.iter().filter(|&&m| m).count(),
1265            7,
1266            "only the railed column may be flagged"
1267        );
1268    }
1269
1270    #[test]
1271    fn test_detect_hot_pixels_large_psf_bright_region_not_eroded() {
1272        // Fixpoint safety ( F1 test 5): a LARGE bright scene
1273        // region — 20×20 core at 100× background — with the ≥2-px PSF edge
1274        // blur that real VENUS scene features have (adjacent-pixel ratios
1275        // ≤5× through a 2-px transition ring: 100 → 400 → 2000 → 10000).
1276        // Every bright-layer pixel passes the stage-1 global cut (dark
1277        // majority: med = ln 100, mad = 0, Poisson-floor sigma = 0.1 →
1278        // threshold ≈ 5.21 < ln 400 ≈ 5.99), yet no pixel reaches 10× its
1279        // neighbor median, so the erosion never seeds and the fixpoint is
1280        // reached with ZERO flags — the region survives intact.
1281        let mut data = Array3::from_elem((1, 50, 50), 100.0);
1282        for y in 13..37 {
1283            for x in 13..37 {
1284                data[[0, y, x]] = 400.0; // outer transition ring
1285            }
1286        }
1287        for y in 14..36 {
1288            for x in 14..36 {
1289                data[[0, y, x]] = 2000.0; // inner transition ring
1290            }
1291        }
1292        for y in 15..35 {
1293            for x in 15..35 {
1294                data[[0, y, x]] = 10000.0; // 20×20 core at 100× background
1295            }
1296        }
1297
1298        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1299        assert!(
1300            mask.iter().all(|&m| !m),
1301            "a PSF-blurred bright scene region must not be eroded at all"
1302        );
1303    }
1304
1305    #[test]
1306    fn test_detect_hot_pixels_hard_edged_bright_rectangle_corners_only() {
1307        // Pins the documented convex-corner caveat AND the fixpoint
1308        // no-propagation property. A hard-edged (0-px
1309        // transition) 20×20 region at 100× background: each sharp convex
1310        // corner pixel sees only 3 same-side neighbors (median falls on
1311        // the dark side) and flags — pre-existing single-pass behavior,
1312        // physically rare in scene (PSF blurs real edges over ≥2 px).
1313        // Crucially, the erosion must NOT propagate past the corners: the
1314        // corner-adjacent edge pixels keep ≥4 bright unflagged neighbors,
1315        // so the fixpoint stops at exactly the 4 corner pixels.
1316        let mut data = Array3::from_elem((1, 50, 50), 100.0);
1317        for y in 15..35 {
1318            for x in 15..35 {
1319                data[[0, y, x]] = 10000.0;
1320            }
1321        }
1322
1323        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1324        for &(y, x) in &[(15, 15), (15, 34), (34, 15), (34, 34)] {
1325            assert!(
1326                mask[[y, x]],
1327                "sharp convex corner ({y}, {x}) flags (documented caveat)"
1328            );
1329        }
1330        assert_eq!(
1331            mask.iter().filter(|&&m| m).count(),
1332            4,
1333            "erosion must not propagate past the convex corners"
1334        );
1335    }
1336
1337    #[test]
1338    fn test_detect_hot_pixels_1px_bright_line_flagged_by_design() {
1339        // Width-1 limitation pin ( F3 — user-decided
1340        // document + pin, no connected-component machinery).  A 1-px-wide
1341        // bright SCENE line at ≥10× local contrast is spatially
1342        // indistinguishable from a railed line and IS masked — the
1343        // accepted trade-off for catching railed rows/columns.  Real VENUS
1344        // scene features are PSF-blurred over ≥2 px, so this contrast is
1345        // physically rare in scene (see HOT_LOCAL_FACTOR rustdoc).
1346        let mut data = Array3::from_elem((1, 9, 9), 100.0);
1347        for y in 0..9 {
1348            data[[0, y, 4]] = 5000.0; // 50× the local background
1349        }
1350
1351        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1352        for y in 0..9 {
1353            assert!(
1354                mask[[y, 4]],
1355                "1-px bright line pixel ({y}, 4) is flagged BY DESIGN"
1356            );
1357        }
1358        assert_eq!(
1359            mask.iter().filter(|&&m| m).count(),
1360            9,
1361            "only the width-1 line may be flagged"
1362        );
1363    }
1364
1365    #[test]
1366    fn test_detect_hot_pixels_isolated_live_pixel_keeps_global_verdict() {
1367        // A stage-1 candidate whose 8-neighbors are ALL dead has no live
1368        // neighbor median to refute the global verdict — it stays flagged.
1369        // Scattered far pixels at 50 counts define the global statistics
1370        // (med = ln 50, threshold ≈ 4.76); the isolated 1e6 pixel passes.
1371        let mut data = Array3::<f64>::zeros((1, 5, 5));
1372        for &(y, x) in &[(0, 0), (0, 2), (0, 4), (4, 0), (4, 2), (4, 4)] {
1373            data[[0, y, x]] = 50.0;
1374        }
1375        data[[0, 2, 2]] = 1.0e6;
1376
1377        let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1378        assert!(
1379            mask[[2, 2]],
1380            "isolated live candidate in a dead field keeps the global verdict"
1381        );
1382        assert_eq!(mask.iter().filter(|&&m| m).count(), 1);
1383    }
1384
1385    #[test]
1386    fn test_detect_hot_pixels_empty_tof_err() {
1387        // n_tof == 0: the totals image would be all-zero (vacuous sums) —
1388        // validating entry points must reject rather than return all-false.
1389        let data = Array3::<f64>::zeros((0, 2, 2));
1390        let err = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap_err();
1391        assert!(matches!(err, IoError::InvalidParameter(_)));
1392        assert!(err.to_string().contains("empty TOF axis"));
1393    }
1394
1395    #[test]
1396    fn test_detect_hot_pixels_nan_err() {
1397        let mut data = Array3::from_elem((2, 2, 2), 1.0);
1398        data[[1, 1, 0]] = f64::NAN;
1399        let err = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap_err();
1400        assert!(matches!(err, IoError::InvalidParameter(_)));
1401    }
1402
1403    #[test]
1404    fn test_detect_hot_pixels_bad_k_err() {
1405        let data = Array3::from_elem((2, 2, 2), 1.0);
1406        for bad_k in [0.0, -1.0, f64::NAN, f64::INFINITY] {
1407            let err = detect_hot_pixels(&data, bad_k).unwrap_err();
1408            assert!(
1409                matches!(err, IoError::InvalidParameter(_)),
1410                "k_mad = {bad_k} must be rejected"
1411            );
1412        }
1413    }
1414
1415    #[test]
1416    fn test_detect_bad_pixels_union() {
1417        // sample: (0,0) dead, (1,2) low-count-alive (1 total count),
1418        //         everything else 100 counts/bin.
1419        let mut sample = Array3::from_elem((3, 3, 3), 100.0);
1420        for t in 0..3 {
1421            sample[[t, 0, 0]] = 0.0;
1422            sample[[t, 1, 2]] = 0.0;
1423        }
1424        sample[[0, 1, 2]] = 1.0;
1425        // open beam: (0,1) dead, (2,2) railed, everything else 200/bin.
1426        let mut ob = Array3::from_elem((3, 3, 3), 200.0);
1427        for t in 0..3 {
1428            ob[[t, 0, 1]] = 0.0;
1429            ob[[t, 2, 2]] = 65535.0;
1430        }
1431
1432        let mask = detect_bad_pixels(&sample, Some(&ob), Some(HOT_PIXEL_K_MAD)).unwrap();
1433        assert!(mask[[0, 0]], "dead-in-sample-only must be flagged");
1434        assert!(mask[[0, 1]], "dead-in-OB-only must be flagged");
1435        assert!(mask[[2, 2]], "hot-in-OB-only must be flagged");
1436        assert!(!mask[[1, 2]], "low-count-alive must be kept");
1437        assert!(!mask[[1, 1]], "normal pixel must be kept");
1438        assert_eq!(mask.iter().filter(|&&m| m).count(), 3);
1439    }
1440
1441    #[test]
1442    fn test_detect_bad_pixels_spatial_mismatch_err() {
1443        let sample = Array3::from_elem((3, 2, 2), 1.0);
1444        let ob = Array3::from_elem((3, 2, 3), 1.0);
1445        let err = detect_bad_pixels(&sample, Some(&ob), None).unwrap_err();
1446        assert!(matches!(err, IoError::ShapeMismatch(_)));
1447    }
1448
1449    #[test]
1450    fn test_detect_bad_pixels_ragged_n_tof_ok() {
1451        // Deadness is spatial: sample and OB may have different TOF axes.
1452        let sample = Array3::from_elem((3, 2, 2), 1.0);
1453        let ob = Array3::from_elem((7, 2, 2), 1.0);
1454        let mask = detect_bad_pixels(&sample, Some(&ob), Some(HOT_PIXEL_K_MAD)).unwrap();
1455        assert!(mask.iter().all(|&m| !m));
1456    }
1457
1458    #[test]
1459    fn test_detect_bad_pixels_hot_k_mad_none_is_dead_only() {
1460        let mut sample = Array3::from_elem((3, 3, 3), 100.0);
1461        for t in 0..3 {
1462            sample[[t, 1, 1]] = 65535.0; // railed
1463            sample[[t, 0, 0]] = 0.0; // dead
1464        }
1465        let dead_only = detect_bad_pixels(&sample, None, None).unwrap();
1466        assert!(dead_only[[0, 0]]);
1467        assert!(
1468            !dead_only[[1, 1]],
1469            "hot_k_mad = None must disable the hot screen"
1470        );
1471
1472        let with_hot = detect_bad_pixels(&sample, None, Some(HOT_PIXEL_K_MAD)).unwrap();
1473        assert!(with_hot[[0, 0]]);
1474        assert!(with_hot[[1, 1]]);
1475    }
1476
1477    #[test]
1478    fn test_detect_bad_pixels_bad_k_err() {
1479        let sample = Array3::from_elem((2, 2, 2), 1.0);
1480        for bad_k in [0.0, -1.0, f64::NAN, f64::INFINITY] {
1481            let err = detect_bad_pixels(&sample, None, Some(bad_k)).unwrap_err();
1482            assert!(
1483                matches!(err, IoError::InvalidParameter(_)),
1484                "hot_k_mad = {bad_k} must be rejected"
1485            );
1486        }
1487    }
1488
1489    #[test]
1490    fn test_detect_bad_pixels_empty_tof_err() {
1491        // An empty stack's all-zero test passes vacuously — without this
1492        // guard the whole detector would be masked dead with no error.
1493        let empty = Array3::<f64>::zeros((0, 2, 2));
1494        let err = detect_bad_pixels(&empty, None, None).unwrap_err();
1495        assert!(matches!(err, IoError::InvalidParameter(_)));
1496        assert!(err.to_string().contains("sample"));
1497
1498        let sample = Array3::from_elem((3, 2, 2), 1.0);
1499        let err = detect_bad_pixels(&sample, Some(&empty), None).unwrap_err();
1500        assert!(matches!(err, IoError::InvalidParameter(_)));
1501        assert!(err.to_string().contains("open_beam"));
1502    }
1503
1504    #[test]
1505    fn test_detect_bad_pixels_nan_err() {
1506        let mut sample = Array3::from_elem((2, 2, 2), 1.0);
1507        sample[[0, 0, 1]] = f64::NAN;
1508        let err = detect_bad_pixels(&sample, None, None).unwrap_err();
1509        assert!(err.to_string().contains("sample"));
1510
1511        let sample = Array3::from_elem((2, 2, 2), 1.0);
1512        let mut ob = Array3::from_elem((2, 2, 2), 1.0);
1513        ob[[1, 0, 0]] = f64::NEG_INFINITY;
1514        let err = detect_bad_pixels(&sample, Some(&ob), None).unwrap_err();
1515        assert!(err.to_string().contains("open_beam"));
1516    }
1517}