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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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}