Skip to main content

nereids_io/
normalization.rs

1//! Transmission normalization from raw neutron counts.
2//!
3//! Converts raw sample and open-beam (OB) neutron counts into a transmission
4//! spectrum, following the ORNL Method 2 approach used in PLEIADES.
5//!
6//! ## Method 2 Normalization
7//!
8//! For each TOF bin and pixel:
9//!
10//!   T[tof, y, x] = (C_sample / C_ob) × (PC_ob / PC_sample)
11//!
12//! where:
13//! - C_sample = raw sample counts (dark-current subtracted)
14//! - C_ob = open-beam counts (dark-current subtracted)
15//! - PC_sample = proton charge for sample run
16//! - PC_ob = proton charge for open-beam run
17//!
18//! The proton charge ratio corrects for different beam exposures.
19//!
20//! ## Uncertainty
21//!
22//! Assuming Poisson counting statistics:
23//!
24//!   σ_T / T = √(1/C_sample + 1/C_ob)
25//!
26//! ## PLEIADES Reference
27//! - `processing/normalization_ornl.py` — Method 2 implementation
28
29use ndarray::{Array1, Array3, Axis};
30
31use crate::error::IoError;
32
33/// Parameters for transmission normalization.
34#[derive(Debug, Clone)]
35pub struct NormalizationParams {
36    /// Proton charge for the sample measurement.
37    pub proton_charge_sample: f64,
38    /// Proton charge for the open-beam measurement.
39    pub proton_charge_ob: f64,
40}
41
42/// Result of normalization: transmission and its uncertainty.
43#[derive(Debug)]
44pub struct NormalizedData {
45    /// Transmission values, shape (n_tof, height, width).
46    pub transmission: Array3<f64>,
47    /// Uncertainty on transmission, shape (n_tof, height, width).
48    pub uncertainty: Array3<f64>,
49}
50
51/// Normalize raw data to transmission using Method 2.
52///
53/// T = (C_sample / C_ob) × (PC_ob / PC_sample)
54///
55/// # Arguments
56/// * `sample` — Raw sample counts, shape (n_tof, height, width).
57/// * `open_beam` — Open-beam counts, shape (n_tof, height, width).
58/// * `params` — Normalization parameters (proton charges).
59/// * `dark_current` — Optional dark-current image to subtract, shape (height, width).
60///   If provided, it is subtracted from each TOF frame of both sample and OB.
61///
62/// # Returns
63/// Normalized transmission and uncertainty arrays.
64pub fn normalize(
65    sample: &Array3<f64>,
66    open_beam: &Array3<f64>,
67    params: &NormalizationParams,
68    dark_current: Option<&ndarray::Array2<f64>>,
69) -> Result<NormalizedData, IoError> {
70    if sample.shape() != open_beam.shape() {
71        return Err(IoError::ShapeMismatch(format!(
72            "Sample shape {:?} != open-beam shape {:?}",
73            sample.shape(),
74            open_beam.shape()
75        )));
76    }
77
78    if !(params.proton_charge_sample > 0.0
79        && params.proton_charge_sample.is_finite()
80        && params.proton_charge_ob > 0.0
81        && params.proton_charge_ob.is_finite())
82    {
83        return Err(IoError::InvalidParameter(
84            "Proton charges must be finite and positive".into(),
85        ));
86    }
87
88    if let Some(dc) = dark_current {
89        let dc_shape = dc.shape();
90        let s_shape = sample.shape();
91        if dc_shape[0] != s_shape[1] || dc_shape[1] != s_shape[2] {
92            return Err(IoError::ShapeMismatch(format!(
93                "dark_current shape {:?} != spatial dimensions ({}, {})",
94                dc_shape, s_shape[1], s_shape[2],
95            )));
96        }
97    }
98
99    // Reject non-finite / negative raw counts up front.  These are detector
100    // counts (and a dark-current estimate), so a NaN or a negative value
101    // signals an upstream loader / TOF-normalisation bug.  Validating here —
102    // rather than letting the per-bin `(x - dc).max(0.0)` clamp below absorb
103    // it — is the whole point of this guard: `NaN.max(0.0) == 0.0` would have
104    // silently turned a corrupt frame into a plausible "zero counts" bin,
105    // exactly the masking the sibling `nereids_fitting::joint_poisson`
106    // `validate_counts` exists to prevent.
107    validate_counts(sample, "sample")?;
108    validate_counts(open_beam, "open_beam")?;
109    if let Some(dc) = dark_current {
110        validate_counts(dc, "dark_current")?;
111    }
112
113    let shape = sample.shape();
114    let (n_tof, height, width) = (shape[0], shape[1], shape[2]);
115
116    let pc_ratio = params.proton_charge_ob / params.proton_charge_sample;
117
118    let mut transmission = Array3::<f64>::zeros((n_tof, height, width));
119    let mut uncertainty = Array3::<f64>::zeros((n_tof, height, width));
120
121    for t in 0..n_tof {
122        for y in 0..height {
123            for x in 0..width {
124                // Dark-current subtraction.  The DC noise contribution to
125                // the uncertainty is omitted — Var(DC) is not included in
126                // the error propagation below.  This is acceptable when DC
127                // is small relative to signal counts (typical for VENUS MCP
128                // detectors), but underestimates σ_T for very low-signal bins
129                // where DC is comparable to the sample or OB counts.
130                //
131                // Inputs are validated finite & non-negative above, so the
132                // subtraction is always finite here; the only way it can go
133                // negative is the legitimate `dc > counts` low-count noise
134                // case (the DC estimate overshoots the measured counts in a
135                // single bin).  Floor that physical edge at 0 — this is NOT
136                // masking bad input (a NaN / negative loader bug was already
137                // rejected), it is the Method-2 convention for a dark-frame
138                // estimate that exceeds the raw counts.
139                let dc = dark_current.map_or(0.0, |dc| dc[[y, x]]);
140                let c_s = (sample[[t, y, x]] - dc).max(0.0);
141                let c_o = (open_beam[[t, y, x]] - dc).max(0.0);
142
143                if c_o > 0.0 {
144                    let t_val = (c_s / c_o) * pc_ratio;
145                    transmission[[t, y, x]] = t_val;
146
147                    // Poisson uncertainty via absolute error propagation.
148                    //
149                    // σ_T = pc_ratio / c_o * √(c_s_eff + c_s² / c_o)
150                    //
151                    // where c_s_eff is the Bayesian floor (Jeffreys prior,
152                    // 0.5 counts) when c_s == 0.  This formula follows from
153                    // propagating Var(c_s)=c_s_eff and Var(c_o)=c_o through
154                    // T = (c_s / c_o) * pc_ratio.
155                    //
156                    // Unlike the relative-error form σ_T = T * √(1/c_s + 1/c_o),
157                    // this absolute form produces σ > 0 even when c_s == 0 (T == 0),
158                    // ensuring downstream weighted fits never see zero uncertainty.
159                    //
160                    // NOTE: c_o is always > 0 here (we are inside the if branch),
161                    // so the old `c_o_eff` dead-code branch is removed.
162                    let c_s_eff = if c_s > 0.0 { c_s } else { 0.5 };
163                    let abs_var_t = (pc_ratio / c_o).powi(2) * (c_s_eff + c_s * c_s / c_o);
164                    uncertainty[[t, y, x]] = abs_var_t.sqrt();
165                } else {
166                    // No open-beam counts: mark as invalid
167                    transmission[[t, y, x]] = 0.0;
168                    uncertainty[[t, y, x]] = f64::INFINITY;
169                }
170            }
171        }
172    }
173
174    Ok(NormalizedData {
175        transmission,
176        uncertainty,
177    })
178}
179
180/// Reject a raw-counts array that contains a non-finite or negative value.
181///
182/// Detector counts are non-negative by construction (zero is legitimate), so a
183/// NaN, ±∞, or negative entry signals an upstream loader / normalisation bug
184/// that must be surfaced rather than silently clamped.  Reports the first
185/// offending flat index and value.
186///
187/// The finite-&-non-negative invariant itself lives in
188/// [`nereids_core::validation::first_non_finite_or_negative`] so that the
189/// `nereids-fitting` joint-Poisson and this I/O loader enforce *identical*
190/// semantics (`NaN < 0.0` is `false`, so the check pairs `is_finite()` with
191/// the order comparison); this wrapper only maps the offending element onto
192/// the `IoError` message wording.
193fn validate_counts<D: ndarray::Dimension>(
194    counts: &ndarray::ArrayBase<impl ndarray::Data<Elem = f64>, D>,
195    field: &str,
196) -> Result<(), IoError> {
197    nereids_core::validation::first_non_finite_or_negative(counts.iter().copied()).map_err(
198        |(i, v)| {
199            IoError::InvalidParameter(format!(
200                "{field} counts at flat index {i} must be finite and >= 0, got {v}"
201            ))
202        },
203    )
204}
205
206/// Extract a single spectrum (all TOF bins) from a pixel in the 3D array.
207///
208/// # Arguments
209/// * `data` — 3D array with shape (n_tof, height, width).
210/// * `y` — Pixel row.
211/// * `x` — Pixel column.
212///
213/// # Returns
214/// 1D array of length n_tof.
215pub fn extract_spectrum(data: &Array3<f64>, y: usize, x: usize) -> Array1<f64> {
216    data.slice(ndarray::s![.., y, x]).to_owned()
217}
218
219/// Average spectra over a rectangular region of interest.
220///
221/// # Arguments
222/// * `data` — 3D array with shape (n_tof, height, width).
223/// * `y_range` — Row range (start..end).
224/// * `x_range` — Column range (start..end).
225///
226/// # Errors
227/// Returns `IoError::InvalidParameter` if the ROI is empty or exceeds the
228/// spatial dimensions of `data`.
229///
230/// # Returns
231/// Averaged 1D spectrum of length n_tof.
232pub fn average_roi(
233    data: &Array3<f64>,
234    y_range: std::ops::Range<usize>,
235    x_range: std::ops::Range<usize>,
236) -> Result<Array1<f64>, IoError> {
237    if y_range.is_empty() || x_range.is_empty() {
238        return Err(IoError::InvalidParameter(
239            "ROI ranges must be non-empty for average_roi".into(),
240        ));
241    }
242    if y_range.end > data.shape()[1] || x_range.end > data.shape()[2] {
243        return Err(IoError::InvalidParameter(format!(
244            "ROI range ({}..{}, {}..{}) exceeds data spatial dims ({}, {})",
245            y_range.start,
246            y_range.end,
247            x_range.start,
248            x_range.end,
249            data.shape()[1],
250            data.shape()[2],
251        )));
252    }
253    let roi = data.slice(ndarray::s![.., y_range, x_range]);
254    // Mean over spatial dimensions (axes 1 and 2).
255    // unwrap is safe here: the ROI is guaranteed non-empty by the check above.
256    Ok(roi.mean_axis(Axis(2)).unwrap().mean_axis(Axis(1)).unwrap())
257}
258
259/// Detect dead pixels (zero counts across all TOF bins).
260///
261/// # Arguments
262/// * `data` — 3D array with shape (n_tof, height, width).
263///
264/// # Returns
265/// 2D boolean mask, shape (height, width). `true` = dead pixel.
266pub fn detect_dead_pixels(data: &Array3<f64>) -> ndarray::Array2<bool> {
267    let shape = data.shape();
268    let (height, width) = (shape[1], shape[2]);
269    let mut mask = ndarray::Array2::from_elem((height, width), false);
270
271    for y in 0..height {
272        for x in 0..width {
273            let all_zero = (0..shape[0]).all(|t| data[[t, y, x]] == 0.0);
274            mask[[y, x]] = all_zero;
275        }
276    }
277
278    mask
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use ndarray::Array2;
285
286    #[test]
287    fn test_normalize_equal_charges() {
288        // Equal proton charges, PC ratio = 1
289        // C_s = 50, C_o = 100 → T = 0.5
290        let sample = Array3::from_elem((1, 1, 1), 50.0);
291        let ob = Array3::from_elem((1, 1, 1), 100.0);
292        let params = NormalizationParams {
293            proton_charge_sample: 1.0,
294            proton_charge_ob: 1.0,
295        };
296
297        let result = normalize(&sample, &ob, &params, None).unwrap();
298        assert!((result.transmission[[0, 0, 0]] - 0.5).abs() < 1e-10);
299
300        // Uncertainty: σ_T = T × √(1/50 + 1/100) = 0.5 × √(0.03) ≈ 0.0866
301        let expected_unc = 0.5 * (1.0 / 50.0 + 1.0 / 100.0_f64).sqrt();
302        assert!(
303            (result.uncertainty[[0, 0, 0]] - expected_unc).abs() < 1e-10,
304            "got {}, expected {}",
305            result.uncertainty[[0, 0, 0]],
306            expected_unc,
307        );
308    }
309
310    #[test]
311    fn test_normalize_proton_charge_correction() {
312        // PC_sample = 2, PC_ob = 1 → ratio = 0.5
313        // C_s = 100, C_o = 100 → T = 1.0 × 0.5 = 0.5
314        let sample = Array3::from_elem((1, 1, 1), 100.0);
315        let ob = Array3::from_elem((1, 1, 1), 100.0);
316        let params = NormalizationParams {
317            proton_charge_sample: 2.0,
318            proton_charge_ob: 1.0,
319        };
320
321        let result = normalize(&sample, &ob, &params, None).unwrap();
322        assert!((result.transmission[[0, 0, 0]] - 0.5).abs() < 1e-10);
323    }
324
325    #[test]
326    fn test_normalize_with_dark_current() {
327        // C_s_raw = 60, C_o_raw = 110, DC = 10
328        // C_s = 50, C_o = 100 → T = 0.5
329        let sample = Array3::from_elem((1, 1, 1), 60.0);
330        let ob = Array3::from_elem((1, 1, 1), 110.0);
331        let dc = Array2::from_elem((1, 1), 10.0);
332        let params = NormalizationParams {
333            proton_charge_sample: 1.0,
334            proton_charge_ob: 1.0,
335        };
336
337        let result = normalize(&sample, &ob, &params, Some(&dc)).unwrap();
338        assert!((result.transmission[[0, 0, 0]] - 0.5).abs() < 1e-10);
339    }
340
341    #[test]
342    fn test_normalize_zero_ob() {
343        // Zero open-beam counts → T = 0, uncertainty = INF
344        let sample = Array3::from_elem((1, 1, 1), 50.0);
345        let ob = Array3::from_elem((1, 1, 1), 0.0);
346        let params = NormalizationParams {
347            proton_charge_sample: 1.0,
348            proton_charge_ob: 1.0,
349        };
350
351        let result = normalize(&sample, &ob, &params, None).unwrap();
352        assert_eq!(result.transmission[[0, 0, 0]], 0.0);
353        assert!(result.uncertainty[[0, 0, 0]].is_infinite());
354    }
355
356    #[test]
357    fn test_normalize_shape_mismatch() {
358        let sample = Array3::from_elem((2, 3, 4), 1.0);
359        let ob = Array3::from_elem((2, 3, 5), 1.0);
360        let params = NormalizationParams {
361            proton_charge_sample: 1.0,
362            proton_charge_ob: 1.0,
363        };
364
365        let result = normalize(&sample, &ob, &params, None);
366        assert!(result.is_err());
367    }
368
369    #[test]
370    fn test_normalize_rejects_nan_sample() {
371        // A NaN in the sample frame used to be swallowed by
372        // `(NaN - 0).max(0.0) == 0.0`, silently producing T = 0 as if the
373        // bin had genuinely zero counts.  It must now be rejected up front.
374        let mut sample = Array3::from_elem((1, 1, 1), 50.0);
375        sample[[0, 0, 0]] = f64::NAN;
376        let ob = Array3::from_elem((1, 1, 1), 100.0);
377        let params = NormalizationParams {
378            proton_charge_sample: 1.0,
379            proton_charge_ob: 1.0,
380        };
381        let err = normalize(&sample, &ob, &params, None).unwrap_err();
382        assert!(
383            matches!(err, IoError::InvalidParameter(_)),
384            "expected InvalidParameter, got {err:?}"
385        );
386        assert!(err.to_string().contains("sample"));
387    }
388
389    #[test]
390    fn test_normalize_rejects_negative_sample() {
391        // Negative raw counts (loader bug) used to be clamped to 0.
392        let mut sample = Array3::from_elem((1, 1, 1), 50.0);
393        sample[[0, 0, 0]] = -5.0;
394        let ob = Array3::from_elem((1, 1, 1), 100.0);
395        let params = NormalizationParams {
396            proton_charge_sample: 1.0,
397            proton_charge_ob: 1.0,
398        };
399        let err = normalize(&sample, &ob, &params, None).unwrap_err();
400        assert!(err.to_string().contains("sample"));
401    }
402
403    #[test]
404    fn test_normalize_rejects_nan_open_beam() {
405        let sample = Array3::from_elem((1, 1, 1), 50.0);
406        let mut ob = Array3::from_elem((1, 1, 1), 100.0);
407        ob[[0, 0, 0]] = f64::INFINITY;
408        let params = NormalizationParams {
409            proton_charge_sample: 1.0,
410            proton_charge_ob: 1.0,
411        };
412        let err = normalize(&sample, &ob, &params, None).unwrap_err();
413        assert!(err.to_string().contains("open_beam"));
414    }
415
416    #[test]
417    fn test_normalize_rejects_negative_dark_current() {
418        let sample = Array3::from_elem((1, 1, 1), 60.0);
419        let ob = Array3::from_elem((1, 1, 1), 110.0);
420        let mut dc = Array2::from_elem((1, 1), 10.0);
421        dc[[0, 0]] = -1.0;
422        let params = NormalizationParams {
423            proton_charge_sample: 1.0,
424            proton_charge_ob: 1.0,
425        };
426        let err = normalize(&sample, &ob, &params, Some(&dc)).unwrap_err();
427        assert!(err.to_string().contains("dark_current"));
428    }
429
430    #[test]
431    fn test_extract_spectrum() {
432        // 3 TOF bins, 2×2 image
433        let mut data = Array3::<f64>::zeros((3, 2, 2));
434        data[[0, 1, 0]] = 10.0;
435        data[[1, 1, 0]] = 20.0;
436        data[[2, 1, 0]] = 30.0;
437
438        let spectrum = extract_spectrum(&data, 1, 0);
439        assert_eq!(spectrum.len(), 3);
440        assert_eq!(spectrum[0], 10.0);
441        assert_eq!(spectrum[1], 20.0);
442        assert_eq!(spectrum[2], 30.0);
443    }
444
445    #[test]
446    fn test_average_roi() {
447        // 2 TOF bins, 4×4 image. Set a 2×2 region to known values.
448        let mut data = Array3::<f64>::zeros((2, 4, 4));
449        // TOF bin 0: region [1..3, 1..3] = 100
450        for y in 1..3 {
451            for x in 1..3 {
452                data[[0, y, x]] = 100.0;
453                data[[1, y, x]] = 200.0;
454            }
455        }
456
457        let avg = average_roi(&data, 1..3, 1..3).unwrap();
458        assert_eq!(avg.len(), 2);
459        assert!((avg[0] - 100.0).abs() < 1e-10);
460        assert!((avg[1] - 200.0).abs() < 1e-10);
461    }
462
463    #[test]
464    fn test_normalize_zero_sample_counts() {
465        // Zero sample counts should produce finite (not NaN) uncertainty
466        // thanks to the Bayesian floor of 0.5.
467        let sample = Array3::from_elem((1, 1, 1), 0.0);
468        let ob = Array3::from_elem((1, 1, 1), 100.0);
469        let params = NormalizationParams {
470            proton_charge_sample: 1.0,
471            proton_charge_ob: 1.0,
472        };
473
474        let result = normalize(&sample, &ob, &params, None).unwrap();
475        assert_eq!(result.transmission[[0, 0, 0]], 0.0);
476        assert!(
477            result.uncertainty[[0, 0, 0]].is_finite(),
478            "uncertainty should be finite for zero sample counts, got {}",
479            result.uncertainty[[0, 0, 0]]
480        );
481        assert!(
482            result.uncertainty[[0, 0, 0]] > 0.0,
483            "uncertainty should be strictly positive for zero sample counts (Bayesian floor), got {}",
484            result.uncertainty[[0, 0, 0]]
485        );
486    }
487
488    #[test]
489    fn test_normalize_zero_open_beam() {
490        // Zero OB counts should produce infinite uncertainty (marking
491        // the pixel as invalid), and the uncertainty must not be NaN.
492        let sample = Array3::from_elem((1, 1, 1), 50.0);
493        let ob = Array3::from_elem((1, 1, 1), 0.0);
494        let params = NormalizationParams {
495            proton_charge_sample: 1.0,
496            proton_charge_ob: 1.0,
497        };
498
499        let result = normalize(&sample, &ob, &params, None).unwrap();
500        assert_eq!(result.transmission[[0, 0, 0]], 0.0);
501        assert!(
502            !result.uncertainty[[0, 0, 0]].is_nan(),
503            "uncertainty must not be NaN for zero OB counts"
504        );
505        assert!(
506            result.uncertainty[[0, 0, 0]].is_infinite(),
507            "uncertainty should be infinite for zero OB counts"
508        );
509    }
510
511    #[test]
512    fn test_normalize_dark_current_shape_mismatch() {
513        let sample = Array3::from_elem((2, 3, 4), 1.0);
514        let ob = Array3::from_elem((2, 3, 4), 1.0);
515        let dc = Array2::from_elem((2, 4), 0.0); // wrong shape
516        let params = NormalizationParams {
517            proton_charge_sample: 1.0,
518            proton_charge_ob: 1.0,
519        };
520
521        let result = normalize(&sample, &ob, &params, Some(&dc));
522        assert!(
523            result.is_err(),
524            "should reject mismatched dark_current shape"
525        );
526    }
527
528    /// Verify that σ > 0 for zero sample counts ensures finite LM weight.
529    /// This is the Bayesian floor guarantee: weight = 1/σ² must not be ∞.
530    #[test]
531    fn test_normalize_zero_sample_produces_finite_lm_weight() {
532        let sample = Array3::from_elem((5, 1, 1), 0.0);
533        let ob = Array3::from_elem((5, 1, 1), 500.0);
534        let params = NormalizationParams {
535            proton_charge_sample: 1.0,
536            proton_charge_ob: 1.0,
537        };
538
539        let result = normalize(&sample, &ob, &params, None).unwrap();
540        for t in 0..5 {
541            let sigma = result.uncertainty[[t, 0, 0]];
542            assert!(
543                sigma.is_finite() && sigma > 0.0,
544                "σ must be finite and positive at T=0, got {sigma}"
545            );
546            let weight = 1.0 / (sigma * sigma);
547            assert!(
548                weight.is_finite(),
549                "LM weight 1/σ² must be finite at T=0, got {weight}"
550            );
551        }
552    }
553
554    /// Verify uncertainty at low OB counts is finite and well-behaved.
555    #[test]
556    fn test_normalize_low_ob_counts() {
557        // OB = 2 counts: very low but nonzero
558        let sample = Array3::from_elem((1, 1, 1), 1.0);
559        let ob = Array3::from_elem((1, 1, 1), 2.0);
560        let params = NormalizationParams {
561            proton_charge_sample: 1.0,
562            proton_charge_ob: 1.0,
563        };
564
565        let result = normalize(&sample, &ob, &params, None).unwrap();
566        let sigma = result.uncertainty[[0, 0, 0]];
567        assert!(sigma.is_finite() && sigma > 0.0, "σ = {sigma}");
568        // σ should be large relative to T (very noisy at low counts)
569        let t = result.transmission[[0, 0, 0]];
570        assert!(
571            sigma > 0.1 * t,
572            "σ should be a significant fraction of T at low OB counts: σ={sigma}, T={t}"
573        );
574    }
575
576    #[test]
577    fn test_detect_dead_pixels() {
578        let mut data = Array3::<f64>::zeros((3, 2, 2));
579        // Pixel (0,0) is dead (all zeros)
580        // Pixel (0,1) has a count in frame 1
581        data[[1, 0, 1]] = 5.0;
582        // Pixel (1,0) has counts
583        data[[0, 1, 0]] = 10.0;
584        // Pixel (1,1) is dead
585
586        let mask = detect_dead_pixels(&data);
587        assert!(mask[[0, 0]]); // dead
588        assert!(!mask[[0, 1]]); // alive
589        assert!(!mask[[1, 0]]); // alive
590        assert!(mask[[1, 1]]); // dead
591    }
592}