1use ndarray::{Array1, Array3, Axis};
30
31use crate::error::IoError;
32
33#[derive(Debug, Clone)]
35pub struct NormalizationParams {
36 pub proton_charge_sample: f64,
38 pub proton_charge_ob: f64,
40}
41
42#[derive(Debug)]
44pub struct NormalizedData {
45 pub transmission: Array3<f64>,
47 pub uncertainty: Array3<f64>,
49}
50
51pub 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 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 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 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
157pub fn extract_spectrum(data: &Array3<f64>, y: usize, x: usize) -> Array1<f64> {
167 data.slice(ndarray::s![.., y, x]).to_owned()
168}
169
170pub 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 Ok(roi.mean_axis(Axis(2)).unwrap().mean_axis(Axis(1)).unwrap())
208}
209
210pub 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 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, ¶ms, None).unwrap();
249 assert!((result.transmission[[0, 0, 0]] - 0.5).abs() < 1e-10);
250
251 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 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, ¶ms, 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 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, ¶ms, 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 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, ¶ms, 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, ¶ms, None);
317 assert!(result.is_err());
318 }
319
320 #[test]
321 fn test_extract_spectrum() {
322 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 let mut data = Array3::<f64>::zeros((2, 4, 4));
339 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 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, ¶ms, 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 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, ¶ms, 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); let params = NormalizationParams {
407 proton_charge_sample: 1.0,
408 proton_charge_ob: 1.0,
409 };
410
411 let result = normalize(&sample, &ob, ¶ms, Some(&dc));
412 assert!(
413 result.is_err(),
414 "should reject mismatched dark_current shape"
415 );
416 }
417
418 #[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, ¶ms, 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 #[test]
446 fn test_normalize_low_ob_counts() {
447 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, ¶ms, None).unwrap();
456 let sigma = result.uncertainty[[0, 0, 0]];
457 assert!(sigma.is_finite() && sigma > 0.0, "σ = {sigma}");
458 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 data[[1, 0, 1]] = 5.0;
472 data[[0, 1, 0]] = 10.0;
474 let mask = detect_dead_pixels(&data);
477 assert!(mask[[0, 0]]); assert!(!mask[[0, 1]]); assert!(!mask[[1, 0]]); assert!(mask[[1, 1]]); }
482}