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 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 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 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 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
180fn 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
206pub fn extract_spectrum(data: &Array3<f64>, y: usize, x: usize) -> Array1<f64> {
216 data.slice(ndarray::s![.., y, x]).to_owned()
217}
218
219pub 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 Ok(roi.mean_axis(Axis(2)).unwrap().mean_axis(Axis(1)).unwrap())
257}
258
259pub 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 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, ¶ms, None).unwrap();
298 assert!((result.transmission[[0, 0, 0]] - 0.5).abs() < 1e-10);
299
300 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 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, ¶ms, 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 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, ¶ms, 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 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, ¶ms, 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, ¶ms, None);
366 assert!(result.is_err());
367 }
368
369 #[test]
370 fn test_normalize_rejects_nan_sample() {
371 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, ¶ms, 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 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, ¶ms, 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, ¶ms, 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, ¶ms, Some(&dc)).unwrap_err();
427 assert!(err.to_string().contains("dark_current"));
428 }
429
430 #[test]
431 fn test_extract_spectrum() {
432 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 let mut data = Array3::<f64>::zeros((2, 4, 4));
449 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 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, ¶ms, 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 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, ¶ms, 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); let params = NormalizationParams {
517 proton_charge_sample: 1.0,
518 proton_charge_ob: 1.0,
519 };
520
521 let result = normalize(&sample, &ob, ¶ms, Some(&dc));
522 assert!(
523 result.is_err(),
524 "should reject mismatched dark_current shape"
525 );
526 }
527
528 #[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, ¶ms, 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 #[test]
556 fn test_normalize_low_ob_counts() {
557 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, ¶ms, None).unwrap();
566 let sigma = result.uncertainty[[0, 0, 0]];
567 assert!(sigma.is_finite() && sigma > 0.0, "σ = {sigma}");
568 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 data[[1, 0, 1]] = 5.0;
582 data[[0, 1, 0]] = 10.0;
584 let mask = detect_dead_pixels(&data);
587 assert!(mask[[0, 0]]); assert!(!mask[[0, 1]]); assert!(!mask[[1, 0]]); assert!(mask[[1, 1]]); }
592}