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    let shape = sample.shape();
100    let (n_tof, height, width) = (shape[0], shape[1], shape[2]);
101
102    let pc_ratio = params.proton_charge_ob / params.proton_charge_sample;
103
104    let mut transmission = Array3::<f64>::zeros((n_tof, height, width));
105    let mut uncertainty = Array3::<f64>::zeros((n_tof, height, width));
106
107    for t in 0..n_tof {
108        for y in 0..height {
109            for x in 0..width {
110                // Dark-current subtraction.  The DC noise contribution to
111                // the uncertainty is omitted — Var(DC) is not included in
112                // the error propagation below.  This is acceptable when DC
113                // is small relative to signal counts (typical for VENUS MCP
114                // detectors), but underestimates σ_T for very low-signal bins
115                // where DC is comparable to the sample or OB counts.
116                let dc = dark_current.map_or(0.0, |dc| dc[[y, x]]);
117                let c_s = (sample[[t, y, x]] - dc).max(0.0);
118                let c_o = (open_beam[[t, y, x]] - dc).max(0.0);
119
120                if c_o > 0.0 {
121                    let t_val = (c_s / c_o) * pc_ratio;
122                    transmission[[t, y, x]] = t_val;
123
124                    // Poisson uncertainty via absolute error propagation.
125                    //
126                    // σ_T = pc_ratio / c_o * √(c_s_eff + c_s² / c_o)
127                    //
128                    // where c_s_eff is the Bayesian floor (Jeffreys prior,
129                    // 0.5 counts) when c_s == 0.  This formula follows from
130                    // propagating Var(c_s)=c_s_eff and Var(c_o)=c_o through
131                    // T = (c_s / c_o) * pc_ratio.
132                    //
133                    // Unlike the relative-error form σ_T = T * √(1/c_s + 1/c_o),
134                    // this absolute form produces σ > 0 even when c_s == 0 (T == 0),
135                    // ensuring downstream weighted fits never see zero uncertainty.
136                    //
137                    // NOTE: c_o is always > 0 here (we are inside the if branch),
138                    // so the old `c_o_eff` dead-code branch is removed.
139                    let c_s_eff = if c_s > 0.0 { c_s } else { 0.5 };
140                    let abs_var_t = (pc_ratio / c_o).powi(2) * (c_s_eff + c_s * c_s / c_o);
141                    uncertainty[[t, y, x]] = abs_var_t.sqrt();
142                } else {
143                    // No open-beam counts: mark as invalid
144                    transmission[[t, y, x]] = 0.0;
145                    uncertainty[[t, y, x]] = f64::INFINITY;
146                }
147            }
148        }
149    }
150
151    Ok(NormalizedData {
152        transmission,
153        uncertainty,
154    })
155}
156
157/// Extract a single spectrum (all TOF bins) from a pixel in the 3D array.
158///
159/// # Arguments
160/// * `data` — 3D array with shape (n_tof, height, width).
161/// * `y` — Pixel row.
162/// * `x` — Pixel column.
163///
164/// # Returns
165/// 1D array of length n_tof.
166pub fn extract_spectrum(data: &Array3<f64>, y: usize, x: usize) -> Array1<f64> {
167    data.slice(ndarray::s![.., y, x]).to_owned()
168}
169
170/// Average spectra over a rectangular region of interest.
171///
172/// # Arguments
173/// * `data` — 3D array with shape (n_tof, height, width).
174/// * `y_range` — Row range (start..end).
175/// * `x_range` — Column range (start..end).
176///
177/// # Errors
178/// Returns `IoError::InvalidParameter` if the ROI is empty or exceeds the
179/// spatial dimensions of `data`.
180///
181/// # Returns
182/// Averaged 1D spectrum of length n_tof.
183pub fn average_roi(
184    data: &Array3<f64>,
185    y_range: std::ops::Range<usize>,
186    x_range: std::ops::Range<usize>,
187) -> Result<Array1<f64>, IoError> {
188    if y_range.is_empty() || x_range.is_empty() {
189        return Err(IoError::InvalidParameter(
190            "ROI ranges must be non-empty for average_roi".into(),
191        ));
192    }
193    if y_range.end > data.shape()[1] || x_range.end > data.shape()[2] {
194        return Err(IoError::InvalidParameter(format!(
195            "ROI range ({}..{}, {}..{}) exceeds data spatial dims ({}, {})",
196            y_range.start,
197            y_range.end,
198            x_range.start,
199            x_range.end,
200            data.shape()[1],
201            data.shape()[2],
202        )));
203    }
204    let roi = data.slice(ndarray::s![.., y_range, x_range]);
205    // Mean over spatial dimensions (axes 1 and 2).
206    // unwrap is safe here: the ROI is guaranteed non-empty by the check above.
207    Ok(roi.mean_axis(Axis(2)).unwrap().mean_axis(Axis(1)).unwrap())
208}
209
210/// Detect dead pixels (zero counts across all TOF bins).
211///
212/// # Arguments
213/// * `data` — 3D array with shape (n_tof, height, width).
214///
215/// # Returns
216/// 2D boolean mask, shape (height, width). `true` = dead pixel.
217pub fn detect_dead_pixels(data: &Array3<f64>) -> ndarray::Array2<bool> {
218    let shape = data.shape();
219    let (height, width) = (shape[1], shape[2]);
220    let mut mask = ndarray::Array2::from_elem((height, width), false);
221
222    for y in 0..height {
223        for x in 0..width {
224            let all_zero = (0..shape[0]).all(|t| data[[t, y, x]] == 0.0);
225            mask[[y, x]] = all_zero;
226        }
227    }
228
229    mask
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use ndarray::Array2;
236
237    #[test]
238    fn test_normalize_equal_charges() {
239        // Equal proton charges, PC ratio = 1
240        // C_s = 50, C_o = 100 → T = 0.5
241        let sample = Array3::from_elem((1, 1, 1), 50.0);
242        let ob = Array3::from_elem((1, 1, 1), 100.0);
243        let params = NormalizationParams {
244            proton_charge_sample: 1.0,
245            proton_charge_ob: 1.0,
246        };
247
248        let result = normalize(&sample, &ob, &params, None).unwrap();
249        assert!((result.transmission[[0, 0, 0]] - 0.5).abs() < 1e-10);
250
251        // Uncertainty: σ_T = T × √(1/50 + 1/100) = 0.5 × √(0.03) ≈ 0.0866
252        let expected_unc = 0.5 * (1.0 / 50.0 + 1.0 / 100.0_f64).sqrt();
253        assert!(
254            (result.uncertainty[[0, 0, 0]] - expected_unc).abs() < 1e-10,
255            "got {}, expected {}",
256            result.uncertainty[[0, 0, 0]],
257            expected_unc,
258        );
259    }
260
261    #[test]
262    fn test_normalize_proton_charge_correction() {
263        // PC_sample = 2, PC_ob = 1 → ratio = 0.5
264        // C_s = 100, C_o = 100 → T = 1.0 × 0.5 = 0.5
265        let sample = Array3::from_elem((1, 1, 1), 100.0);
266        let ob = Array3::from_elem((1, 1, 1), 100.0);
267        let params = NormalizationParams {
268            proton_charge_sample: 2.0,
269            proton_charge_ob: 1.0,
270        };
271
272        let result = normalize(&sample, &ob, &params, None).unwrap();
273        assert!((result.transmission[[0, 0, 0]] - 0.5).abs() < 1e-10);
274    }
275
276    #[test]
277    fn test_normalize_with_dark_current() {
278        // C_s_raw = 60, C_o_raw = 110, DC = 10
279        // C_s = 50, C_o = 100 → T = 0.5
280        let sample = Array3::from_elem((1, 1, 1), 60.0);
281        let ob = Array3::from_elem((1, 1, 1), 110.0);
282        let dc = Array2::from_elem((1, 1), 10.0);
283        let params = NormalizationParams {
284            proton_charge_sample: 1.0,
285            proton_charge_ob: 1.0,
286        };
287
288        let result = normalize(&sample, &ob, &params, Some(&dc)).unwrap();
289        assert!((result.transmission[[0, 0, 0]] - 0.5).abs() < 1e-10);
290    }
291
292    #[test]
293    fn test_normalize_zero_ob() {
294        // Zero open-beam counts → T = 0, uncertainty = INF
295        let sample = Array3::from_elem((1, 1, 1), 50.0);
296        let ob = Array3::from_elem((1, 1, 1), 0.0);
297        let params = NormalizationParams {
298            proton_charge_sample: 1.0,
299            proton_charge_ob: 1.0,
300        };
301
302        let result = normalize(&sample, &ob, &params, None).unwrap();
303        assert_eq!(result.transmission[[0, 0, 0]], 0.0);
304        assert!(result.uncertainty[[0, 0, 0]].is_infinite());
305    }
306
307    #[test]
308    fn test_normalize_shape_mismatch() {
309        let sample = Array3::from_elem((2, 3, 4), 1.0);
310        let ob = Array3::from_elem((2, 3, 5), 1.0);
311        let params = NormalizationParams {
312            proton_charge_sample: 1.0,
313            proton_charge_ob: 1.0,
314        };
315
316        let result = normalize(&sample, &ob, &params, None);
317        assert!(result.is_err());
318    }
319
320    #[test]
321    fn test_extract_spectrum() {
322        // 3 TOF bins, 2×2 image
323        let mut data = Array3::<f64>::zeros((3, 2, 2));
324        data[[0, 1, 0]] = 10.0;
325        data[[1, 1, 0]] = 20.0;
326        data[[2, 1, 0]] = 30.0;
327
328        let spectrum = extract_spectrum(&data, 1, 0);
329        assert_eq!(spectrum.len(), 3);
330        assert_eq!(spectrum[0], 10.0);
331        assert_eq!(spectrum[1], 20.0);
332        assert_eq!(spectrum[2], 30.0);
333    }
334
335    #[test]
336    fn test_average_roi() {
337        // 2 TOF bins, 4×4 image. Set a 2×2 region to known values.
338        let mut data = Array3::<f64>::zeros((2, 4, 4));
339        // TOF bin 0: region [1..3, 1..3] = 100
340        for y in 1..3 {
341            for x in 1..3 {
342                data[[0, y, x]] = 100.0;
343                data[[1, y, x]] = 200.0;
344            }
345        }
346
347        let avg = average_roi(&data, 1..3, 1..3).unwrap();
348        assert_eq!(avg.len(), 2);
349        assert!((avg[0] - 100.0).abs() < 1e-10);
350        assert!((avg[1] - 200.0).abs() < 1e-10);
351    }
352
353    #[test]
354    fn test_normalize_zero_sample_counts() {
355        // Zero sample counts should produce finite (not NaN) uncertainty
356        // thanks to the Bayesian floor of 0.5.
357        let sample = Array3::from_elem((1, 1, 1), 0.0);
358        let ob = Array3::from_elem((1, 1, 1), 100.0);
359        let params = NormalizationParams {
360            proton_charge_sample: 1.0,
361            proton_charge_ob: 1.0,
362        };
363
364        let result = normalize(&sample, &ob, &params, None).unwrap();
365        assert_eq!(result.transmission[[0, 0, 0]], 0.0);
366        assert!(
367            result.uncertainty[[0, 0, 0]].is_finite(),
368            "uncertainty should be finite for zero sample counts, got {}",
369            result.uncertainty[[0, 0, 0]]
370        );
371        assert!(
372            result.uncertainty[[0, 0, 0]] > 0.0,
373            "uncertainty should be strictly positive for zero sample counts (Bayesian floor), got {}",
374            result.uncertainty[[0, 0, 0]]
375        );
376    }
377
378    #[test]
379    fn test_normalize_zero_open_beam() {
380        // Zero OB counts should produce infinite uncertainty (marking
381        // the pixel as invalid), and the uncertainty must not be NaN.
382        let sample = Array3::from_elem((1, 1, 1), 50.0);
383        let ob = Array3::from_elem((1, 1, 1), 0.0);
384        let params = NormalizationParams {
385            proton_charge_sample: 1.0,
386            proton_charge_ob: 1.0,
387        };
388
389        let result = normalize(&sample, &ob, &params, None).unwrap();
390        assert_eq!(result.transmission[[0, 0, 0]], 0.0);
391        assert!(
392            !result.uncertainty[[0, 0, 0]].is_nan(),
393            "uncertainty must not be NaN for zero OB counts"
394        );
395        assert!(
396            result.uncertainty[[0, 0, 0]].is_infinite(),
397            "uncertainty should be infinite for zero OB counts"
398        );
399    }
400
401    #[test]
402    fn test_normalize_dark_current_shape_mismatch() {
403        let sample = Array3::from_elem((2, 3, 4), 1.0);
404        let ob = Array3::from_elem((2, 3, 4), 1.0);
405        let dc = Array2::from_elem((2, 4), 0.0); // wrong shape
406        let params = NormalizationParams {
407            proton_charge_sample: 1.0,
408            proton_charge_ob: 1.0,
409        };
410
411        let result = normalize(&sample, &ob, &params, Some(&dc));
412        assert!(
413            result.is_err(),
414            "should reject mismatched dark_current shape"
415        );
416    }
417
418    /// Verify that σ > 0 for zero sample counts ensures finite LM weight.
419    /// This is the Bayesian floor guarantee: weight = 1/σ² must not be ∞.
420    #[test]
421    fn test_normalize_zero_sample_produces_finite_lm_weight() {
422        let sample = Array3::from_elem((5, 1, 1), 0.0);
423        let ob = Array3::from_elem((5, 1, 1), 500.0);
424        let params = NormalizationParams {
425            proton_charge_sample: 1.0,
426            proton_charge_ob: 1.0,
427        };
428
429        let result = normalize(&sample, &ob, &params, None).unwrap();
430        for t in 0..5 {
431            let sigma = result.uncertainty[[t, 0, 0]];
432            assert!(
433                sigma.is_finite() && sigma > 0.0,
434                "σ must be finite and positive at T=0, got {sigma}"
435            );
436            let weight = 1.0 / (sigma * sigma);
437            assert!(
438                weight.is_finite(),
439                "LM weight 1/σ² must be finite at T=0, got {weight}"
440            );
441        }
442    }
443
444    /// Verify uncertainty at low OB counts is finite and well-behaved.
445    #[test]
446    fn test_normalize_low_ob_counts() {
447        // OB = 2 counts: very low but nonzero
448        let sample = Array3::from_elem((1, 1, 1), 1.0);
449        let ob = Array3::from_elem((1, 1, 1), 2.0);
450        let params = NormalizationParams {
451            proton_charge_sample: 1.0,
452            proton_charge_ob: 1.0,
453        };
454
455        let result = normalize(&sample, &ob, &params, None).unwrap();
456        let sigma = result.uncertainty[[0, 0, 0]];
457        assert!(sigma.is_finite() && sigma > 0.0, "σ = {sigma}");
458        // σ should be large relative to T (very noisy at low counts)
459        let t = result.transmission[[0, 0, 0]];
460        assert!(
461            sigma > 0.1 * t,
462            "σ should be a significant fraction of T at low OB counts: σ={sigma}, T={t}"
463        );
464    }
465
466    #[test]
467    fn test_detect_dead_pixels() {
468        let mut data = Array3::<f64>::zeros((3, 2, 2));
469        // Pixel (0,0) is dead (all zeros)
470        // Pixel (0,1) has a count in frame 1
471        data[[1, 0, 1]] = 5.0;
472        // Pixel (1,0) has counts
473        data[[0, 1, 0]] = 10.0;
474        // Pixel (1,1) is dead
475
476        let mask = detect_dead_pixels(&data);
477        assert!(mask[[0, 0]]); // dead
478        assert!(!mask[[0, 1]]); // alive
479        assert!(!mask[[1, 0]]); // alive
480        assert!(mask[[1, 1]]); // dead
481    }
482}