1use ndarray::{Array2, ArrayView3, s};
7use rayon::prelude::*;
8use std::sync::Arc;
9use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
10
11use nereids_physics::resolution::build_resolution_plan;
12use nereids_physics::transmission::{
13 InstrumentParams, broadened_cross_sections_on_working_grid, unbroadened_cross_sections,
14};
15
16use crate::error::PipelineError;
17use crate::pipeline::SpectrumFitResult;
18
19#[derive(Debug)]
34pub struct SpatialResult {
35 pub density_maps: Vec<Array2<f64>>,
39 pub uncertainty_maps: Vec<Array2<f64>>,
42 pub chi_squared_map: Array2<f64>,
48 pub deviance_per_dof_map: Option<Array2<f64>>,
54 pub converged_map: Array2<bool>,
56 pub temperature_map: Option<Array2<f64>>,
59 pub temperature_uncertainty_map: Option<Array2<f64>>,
63 pub isotope_labels: Vec<String>,
67 pub anorm_map: Option<Array2<f64>>,
70 pub background_maps: Option<[Array2<f64>; 3]>,
86 pub back_d_map: Option<Array2<f64>>,
94 pub back_f_map: Option<Array2<f64>>,
102 pub t0_us_map: Option<Array2<f64>>,
106 pub l_scale_map: Option<Array2<f64>>,
110 pub n_converged: usize,
112 pub n_total: usize,
114 pub n_failed: usize,
118}
119
120use crate::pipeline::{
123 InputData, SolverConfig, UnifiedFitConfig, count_free_params, fit_spectrum_typed,
124 required_active_bins, validate_transmission_background,
125};
126
127#[derive(Debug)]
132pub enum InputData3D<'a> {
133 Transmission {
135 transmission: ArrayView3<'a, f64>,
136 uncertainty: ArrayView3<'a, f64>,
137 },
138 Counts {
140 sample_counts: ArrayView3<'a, f64>,
141 open_beam_counts: ArrayView3<'a, f64>,
142 },
143 CountsWithNuisance {
145 sample_counts: ArrayView3<'a, f64>,
146 flux: ArrayView3<'a, f64>,
147 background: ArrayView3<'a, f64>,
148 },
149}
150
151impl InputData3D<'_> {
152 pub(crate) fn shape(&self) -> (usize, usize, usize) {
154 let s = match self {
155 Self::Transmission { transmission, .. } => transmission.shape(),
156 Self::Counts { sample_counts, .. } => sample_counts.shape(),
157 Self::CountsWithNuisance { sample_counts, .. } => sample_counts.shape(),
158 };
159 (s[0], s[1], s[2])
160 }
161
162 pub fn is_counts(&self) -> bool {
166 matches!(self, Self::Counts { .. } | Self::CountsWithNuisance { .. })
167 }
168}
169
170fn apply_spatial_polish_default(config: UnifiedFitConfig, n_pixels: usize) -> UnifiedFitConfig {
196 if n_pixels > 1 && config.counts_enable_polish().is_none() {
197 config.with_counts_enable_polish(Some(false))
198 } else {
199 config
200 }
201}
202
203fn validate_spatial_fit_preflight(
222 input: &InputData3D<'_>,
223 config: &UnifiedFitConfig,
224) -> Result<(), PipelineError> {
225 if config.fit_temperature() && config.temperature_k() < 1.0 {
231 return Err(PipelineError::InvalidParameter(format!(
232 "temperature must be >= 1.0 K when fit_temperature is true, got {}",
233 config.temperature_k(),
234 )));
235 }
236
237 let is_counts = input.is_counts();
242 let is_kl = matches!(config.solver(), SolverConfig::PoissonKL(_))
243 || (matches!(config.solver(), SolverConfig::Auto) && is_counts);
244
245 if !is_counts && is_kl && config.fit_energy_range().is_some() {
253 return Err(PipelineError::InvalidParameter(
254 "fit_energy_range is not supported for the transmission + \
255 Poisson-KL solver path. Use joint-Poisson (provide sample + \
256 open-beam counts) or switch to the LM transmission solver."
257 .into(),
258 ));
259 }
260
261 if let Some((e_min, e_max)) = config.fit_energy_range() {
278 let active_mask = nereids_fitting::active_mask::build_active_mask(
279 config.energies(),
280 config.fit_energy_range(),
281 );
282 let n_active = nereids_fitting::active_mask::active_count(
283 active_mask.as_deref(),
284 config.energies().len(),
285 );
286 let required = required_active_bins(config);
287 if n_active < required {
288 let path_msg = if is_counts && is_kl {
293 "joint-Poisson"
294 } else {
295 "LM transmission"
296 };
297 return Err(PipelineError::InvalidParameter(format!(
298 "fit_energy_range [{e_min}, {e_max}] eV selects {n_active} active bin(s) \
299 on the configured energy grid; at least {required} active bin(s) are \
300 required for {path_msg} fitting with {n_free} free parameter(s) \
301 (underdetermined when n_active < n_free)",
302 n_free = count_free_params(config),
303 )));
304 }
305 }
306
307 if is_counts && is_kl {
316 if let Some(bg) = config.counts_background() {
317 if bg.fit_alpha_1 || bg.fit_alpha_2 {
318 return Err(PipelineError::InvalidParameter(
319 "joint-Poisson solver does not support fit_alpha_1/fit_alpha_2: \
320 the profile lambda-hat absorbs the global flux scale (alpha_1 redundant); \
321 alpha_2 / B_det wiring is deferred to memo 35 §P3."
322 .into(),
323 ));
324 }
325 if !(bg.c.is_finite() && bg.c > 0.0) {
332 return Err(PipelineError::InvalidParameter(format!(
333 "joint-Poisson solver requires finite c > 0 in CountsBackgroundConfig, got {}",
334 bg.c,
335 )));
336 }
337 }
338 if let Some(bg) = config.transmission_background()
339 && (bg.fit_back_b || bg.fit_back_c)
340 && !bg.fit_back_a
341 {
342 return Err(PipelineError::InvalidParameter(
343 "joint-Poisson transmission_background: B_A (fit_back_a) must be \
344 enabled whenever any of B_B / B_C is enabled (memo 35 §P2.2 — \
345 A_n alone cannot absorb a constant offset; EG2 S2 C_An → −23% \
346 density bias)."
347 .into(),
348 ));
349 }
350 }
351
352 Ok(())
353}
354
355#[derive(Clone, Copy)]
360enum CubeDomain {
361 Finite,
366 FinitePositive,
372 FiniteNonNegative,
378}
379
380impl CubeDomain {
381 #[inline]
382 fn accepts(self, v: f64) -> bool {
383 match self {
384 CubeDomain::Finite => v.is_finite(),
385 CubeDomain::FinitePositive => v.is_finite() && v > 0.0,
386 CubeDomain::FiniteNonNegative => v.is_finite() && v >= 0.0,
387 }
388 }
389
390 fn describe(self) -> &'static str {
391 match self {
392 CubeDomain::Finite => "finite",
393 CubeDomain::FinitePositive => "finite and > 0",
394 CubeDomain::FiniteNonNegative => "finite and >= 0",
395 }
396 }
397}
398
399fn check_cube(
411 cube: &ArrayView3<'_, f64>,
412 field: &'static str,
413 domain: CubeDomain,
414 live_pixels: &[(usize, usize)],
415 active_mask: Option<&[bool]>,
416) -> Result<(), PipelineError> {
417 let n_energies = cube.shape()[0];
418 for e in 0..n_energies {
419 if active_mask.is_some_and(|m| !m[e]) {
420 continue;
421 }
422 for &(y, x) in live_pixels {
423 let v = cube[[e, y, x]];
424 if !domain.accepts(v) {
425 return Err(PipelineError::InvalidParameter(format!(
426 "{field} at (y={y}, x={x}, e={e}) must be {}, got {v}",
427 domain.describe(),
428 )));
429 }
430 }
431 }
432 Ok(())
433}
434
435fn validate_spatial_data_values(
464 input: &InputData3D<'_>,
465 live_pixels: &[(usize, usize)],
466 active_mask: Option<&[bool]>,
467) -> Result<(), PipelineError> {
468 match input {
469 InputData3D::Transmission {
470 transmission,
471 uncertainty,
472 } => {
473 check_cube(
474 transmission,
475 "transmission",
476 CubeDomain::Finite,
477 live_pixels,
478 active_mask,
479 )?;
480 check_cube(
481 uncertainty,
482 "uncertainty",
483 CubeDomain::FinitePositive,
484 live_pixels,
485 active_mask,
486 )?;
487 }
488 InputData3D::Counts {
489 sample_counts,
490 open_beam_counts,
491 } => {
492 check_cube(
493 sample_counts,
494 "sample_counts",
495 CubeDomain::FiniteNonNegative,
496 live_pixels,
497 None,
498 )?;
499 check_cube(
500 open_beam_counts,
501 "open_beam_counts",
502 CubeDomain::FiniteNonNegative,
503 live_pixels,
504 None,
505 )?;
506 }
507 InputData3D::CountsWithNuisance {
508 sample_counts,
509 flux,
510 background,
511 } => {
512 check_cube(
513 sample_counts,
514 "sample_counts",
515 CubeDomain::FiniteNonNegative,
516 live_pixels,
517 None,
518 )?;
519 check_cube(
520 flux,
521 "flux",
522 CubeDomain::FiniteNonNegative,
523 live_pixels,
524 None,
525 )?;
526 check_cube(
527 background,
528 "background",
529 CubeDomain::Finite,
530 live_pixels,
531 None,
532 )?;
533 }
534 }
535 Ok(())
536}
537
538pub fn spatial_map_typed(
539 input: &InputData3D<'_>,
540 config: &UnifiedFitConfig,
541 dead_pixels: Option<&Array2<bool>>,
542 cancel: Option<&AtomicBool>,
543 progress: Option<&AtomicUsize>,
544) -> Result<SpatialResult, PipelineError> {
545 let (n_energies, height, width) = input.shape();
546 let n_maps = config.n_density_params();
548
549 if n_energies != config.energies().len() {
551 return Err(PipelineError::ShapeMismatch(format!(
552 "input spectral axis ({n_energies}) != config.energies length ({})",
553 config.energies().len(),
554 )));
555 }
556 match input {
557 InputData3D::Transmission {
558 transmission,
559 uncertainty,
560 } => {
561 if uncertainty.shape() != transmission.shape() {
562 return Err(PipelineError::ShapeMismatch(format!(
563 "uncertainty shape {:?} != transmission shape {:?}",
564 uncertainty.shape(),
565 transmission.shape(),
566 )));
567 }
568 }
569 InputData3D::Counts {
570 sample_counts,
571 open_beam_counts,
572 } => {
573 if open_beam_counts.shape() != sample_counts.shape() {
574 return Err(PipelineError::ShapeMismatch(format!(
575 "open_beam shape {:?} != sample shape {:?}",
576 open_beam_counts.shape(),
577 sample_counts.shape(),
578 )));
579 }
580 }
581 InputData3D::CountsWithNuisance {
582 sample_counts,
583 flux,
584 background,
585 } => {
586 if flux.shape() != sample_counts.shape() {
587 return Err(PipelineError::ShapeMismatch(format!(
588 "flux shape {:?} != sample shape {:?}",
589 flux.shape(),
590 sample_counts.shape(),
591 )));
592 }
593 if background.shape() != sample_counts.shape() {
594 return Err(PipelineError::ShapeMismatch(format!(
595 "background shape {:?} != sample shape {:?}",
596 background.shape(),
597 sample_counts.shape(),
598 )));
599 }
600 }
601 }
602 if let Some(dp) = dead_pixels
603 && dp.shape() != [height, width]
604 {
605 return Err(PipelineError::ShapeMismatch(format!(
606 "dead_pixels shape {:?} != spatial dimensions ({height}, {width})",
607 dp.shape(),
608 )));
609 }
610
611 if input.is_counts()
627 && matches!(config.solver(), SolverConfig::LevenbergMarquardt(_))
628 && config.fit_energy_scale()
629 {
630 return Err(PipelineError::InvalidParameter(
631 "spatial_map_typed: solver='lm' + fit_energy_scale=true on counts input is \
632 numerically unstable per-pixel (issue #458 B3). Recommended workaround: fit \
633 TZERO once on the aggregated spectrum via fit_counts_spectrum_typed, then \
634 build the corrected energy grid and pass it to spatial_map_typed with \
635 fit_energy_scale=false. For counts data, solver='kl' (or 'auto') is robust \
636 with per-pixel TZERO fitting."
637 .into(),
638 ));
639 }
640
641 if config.fit_energy_scale() && config.fit_temperature() {
649 return Err(PipelineError::InvalidParameter(
650 "spatial_map_typed: fit_energy_scale=true and fit_temperature=true cannot \
651 both be set — EnergyScaleTransmissionModel does not support temperature \
652 fitting. Choose one: either calibrate TZERO with a fixed temperature, or \
653 fit temperature on the nominal energy grid."
654 .into(),
655 ));
656 }
657
658 if matches!(input, InputData3D::CountsWithNuisance { .. })
666 && matches!(config.solver(), SolverConfig::LevenbergMarquardt(_))
667 {
668 return Err(PipelineError::InvalidParameter(
669 "spatial_map_typed: InputData3D::CountsWithNuisance requires a counts-domain \
670 solver (joint-Poisson via SolverConfig::PoissonKL or SolverConfig::Auto); \
671 SolverConfig::LevenbergMarquardt cannot use the user-supplied nuisance \
672 parameters (alpha_1, alpha_2). Choose a counts-domain solver, or drop the \
673 nuisance arm by passing `InputData3D::Counts` instead."
674 .into(),
675 ));
676 }
677
678 if let Some(bg) = config.transmission_background() {
684 validate_transmission_background(bg)?;
688 if bg.fit_back_d && (!bg.back_d_init.is_finite() || bg.back_d_init <= 0.0) {
696 return Err(PipelineError::InvalidParameter(format!(
697 "transmission_background.back_d_init must be finite and strictly \
698 positive when fit_back_d=true (got {}). BackF's Jacobian column \
699 zeros out at BackD ≈ 0; non-finite or non-positive initial values \
700 produce a degenerate fit that LM cannot recover.",
701 bg.back_d_init,
702 )));
703 }
704 if bg.fit_back_f && (!bg.back_f_init.is_finite() || bg.back_f_init <= 0.0) {
705 return Err(PipelineError::InvalidParameter(format!(
706 "transmission_background.back_f_init must be finite and strictly \
707 positive when fit_back_f=true (got {}). BackD becomes a constant \
708 duplicate of BackA at BackF ≈ 0; non-finite or non-positive initial \
709 values produce a degenerate fit that LM cannot recover.",
710 bg.back_f_init,
711 )));
712 }
713 if (bg.fit_back_d || bg.fit_back_f)
718 && input.is_counts()
719 && !matches!(config.solver(), SolverConfig::LevenbergMarquardt(_))
720 {
721 return Err(PipelineError::InvalidParameter(
722 "spatial_map_typed: transmission_background with fit_back_d=true / \
723 fit_back_f=true cannot be combined with the counts-KL (joint-Poisson) \
724 dispatch. The joint-Poisson solver does not fit the SAMMY exponential \
725 tail. Either switch to SolverConfig::LevenbergMarquardt or disable the \
726 exponential tail (fit_back_d=false, fit_back_f=false)."
727 .into(),
728 ));
729 }
730 }
731
732 validate_spatial_fit_preflight(input, config)?;
748
749 crate::pipeline::validate_precomputed_cross_sections(config)?;
754
755 let mut pixel_coords: Vec<(usize, usize)> = Vec::new();
757 for y in 0..height {
758 for x in 0..width {
759 let is_dead = dead_pixels.is_some_and(|m| m[[y, x]]);
760 if !is_dead {
761 pixel_coords.push((y, x));
762 }
763 }
764 }
765
766 let isotope_labels = config.isotope_names().to_vec();
767 let has_background_outputs =
768 config.transmission_background().is_some() || config.counts_background().is_some();
769 let has_back_d_map = config
777 .transmission_background()
778 .is_some_and(|bg| bg.fit_back_d);
779 let has_back_f_map = config
780 .transmission_background()
781 .is_some_and(|bg| bg.fit_back_f);
782
783 let dispatches_to_counts_kl =
793 input.is_counts() && !matches!(config.solver(), SolverConfig::LevenbergMarquardt(_));
794
795 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
796 return Err(PipelineError::Cancelled);
797 }
798 if pixel_coords.is_empty() {
799 return Ok(SpatialResult {
806 density_maps: (0..n_maps)
807 .map(|_| Array2::from_elem((height, width), f64::NAN))
808 .collect(),
809 uncertainty_maps: (0..n_maps)
810 .map(|_| Array2::from_elem((height, width), f64::NAN))
811 .collect(),
812 chi_squared_map: Array2::from_elem((height, width), f64::NAN),
813 deviance_per_dof_map: if dispatches_to_counts_kl {
814 Some(Array2::from_elem((height, width), f64::NAN))
815 } else {
816 None
817 },
818 converged_map: Array2::from_elem((height, width), false),
819 temperature_map: if config.fit_temperature() {
820 Some(Array2::from_elem((height, width), f64::NAN))
821 } else {
822 None
823 },
824 temperature_uncertainty_map: if config.fit_temperature() {
825 Some(Array2::from_elem((height, width), f64::NAN))
826 } else {
827 None
828 },
829 isotope_labels,
830 anorm_map: if has_background_outputs {
831 Some(Array2::from_elem((height, width), f64::NAN))
832 } else {
833 None
834 },
835 background_maps: if has_background_outputs {
836 Some([
837 Array2::from_elem((height, width), f64::NAN),
838 Array2::from_elem((height, width), f64::NAN),
839 Array2::from_elem((height, width), f64::NAN),
840 ])
841 } else {
842 None
843 },
844 back_d_map: if has_back_d_map {
845 Some(Array2::from_elem((height, width), f64::NAN))
846 } else {
847 None
848 },
849 back_f_map: if has_back_f_map {
850 Some(Array2::from_elem((height, width), f64::NAN))
851 } else {
852 None
853 },
854 t0_us_map: if config.fit_energy_scale() {
855 Some(Array2::from_elem((height, width), f64::NAN))
856 } else {
857 None
858 },
859 l_scale_map: if config.fit_energy_scale() {
860 Some(Array2::from_elem((height, width), f64::NAN))
861 } else {
862 None
863 },
864 n_converged: 0,
865 n_total: 0,
866 n_failed: 0,
867 });
868 }
869
870 let value_active_mask = nereids_fitting::active_mask::build_active_mask(
879 config.energies(),
880 config.fit_energy_range(),
881 );
882 validate_spatial_data_values(input, &pixel_coords, value_active_mask.as_deref())?;
883
884 let (data_a, data_b, data_c) = match input {
886 InputData3D::Transmission {
887 transmission,
888 uncertainty,
889 } => {
890 let a = transmission
891 .permuted_axes([1, 2, 0])
892 .as_standard_layout()
893 .into_owned();
894 let b = uncertainty
895 .permuted_axes([1, 2, 0])
896 .as_standard_layout()
897 .into_owned();
898 (a, b, None)
899 }
900 InputData3D::Counts {
901 sample_counts,
902 open_beam_counts,
903 } => {
904 let a = sample_counts
905 .permuted_axes([1, 2, 0])
906 .as_standard_layout()
907 .into_owned();
908 let b = open_beam_counts
909 .permuted_axes([1, 2, 0])
910 .as_standard_layout()
911 .into_owned();
912 (a, b, None)
913 }
914 InputData3D::CountsWithNuisance {
915 sample_counts,
916 flux,
917 background,
918 } => {
919 let a = sample_counts
920 .permuted_axes([1, 2, 0])
921 .as_standard_layout()
922 .into_owned();
923 let b = flux
924 .permuted_axes([1, 2, 0])
925 .as_standard_layout()
926 .into_owned();
927 let c = background
928 .permuted_axes([1, 2, 0])
929 .as_standard_layout()
930 .into_owned();
931 (a, b, Some(c))
932 }
933 };
934
935 let instrument = config.resolution().map(|r| InstrumentParams {
948 resolution: r.clone(),
949 });
950
951 let rd_refs: Vec<&_> = config.resonance_data().iter().collect();
961 let layout = nereids_physics::transmission::resolution_working_grid(
962 config.energies(),
963 instrument.as_ref(),
964 &rd_refs,
965 )
966 .map_err(PipelineError::Transmission)?;
967 let aux_grid_active = !layout.is_identity();
968
969 let (xs, work_xs) = match config.precomputed_cross_sections().cloned() {
971 Some(cached) if !aux_grid_active => (cached, None),
977 Some(cached) => {
978 let working = broadened_cross_sections_on_working_grid(
979 config.energies(),
980 config.resonance_data(),
981 config.temperature_k(),
982 instrument.as_ref(),
983 cancel,
984 )?;
985 (cached, Some(Arc::new(working.sigma)))
986 }
987 None => {
988 let working = broadened_cross_sections_on_working_grid(
989 config.energies(),
990 config.resonance_data(),
991 config.temperature_k(),
992 instrument.as_ref(),
993 cancel,
994 )?;
995 if aux_grid_active {
996 let data_xs: Vec<Vec<f64>> = working
998 .sigma
999 .iter()
1000 .map(|s| working.layout.extract(s))
1001 .collect();
1002 (Arc::new(data_xs), Some(Arc::new(working.sigma)))
1003 } else {
1004 (Arc::new(working.sigma), None)
1006 }
1007 }
1008 };
1009
1010 let work_layout: Option<Arc<nereids_physics::transmission::WorkingGridLayout>> =
1015 if work_xs.is_some() {
1016 Some(Arc::new(layout))
1017 } else {
1018 None
1019 };
1020
1021 let collapse = |xs: &Arc<Vec<Vec<f64>>>| -> Arc<Vec<Vec<f64>>> {
1027 if !config.fit_temperature()
1028 && let (Some(di), Some(dr)) = (&config.density_indices, &config.density_ratios)
1029 && xs.len() == di.len()
1030 && di.len() == dr.len()
1031 {
1032 let n_e = xs[0].len();
1033 let mut eff = vec![vec![0.0f64; n_e]; n_maps];
1034 for ((&idx, &ratio), member_xs) in di.iter().zip(dr.iter()).zip(xs.iter()) {
1035 for (j, &sigma) in member_xs.iter().enumerate() {
1036 eff[idx][j] += ratio * sigma;
1037 }
1038 }
1039 Arc::new(eff)
1040 } else {
1041 Arc::clone(xs)
1042 }
1043 };
1044 let xs = collapse(&xs);
1045 let work_xs = work_xs.as_ref().map(collapse);
1046
1047 let resolution_plan: Option<Arc<nereids_physics::resolution::ResolutionPlan>> =
1067 if !config.fit_energy_scale() {
1068 match config.resolution() {
1069 Some(res) => build_resolution_plan(config.energies(), res)
1076 .map_err(|e| {
1077 PipelineError::Transmission(
1078 nereids_physics::transmission::TransmissionError::from(e),
1079 )
1080 })?
1081 .map(Arc::new),
1082 None => None,
1083 }
1084 } else {
1085 None
1086 };
1087
1088 let caller_cubature = config.precomputed_sparse_cubature_plan().cloned();
1108 let sparse_cubature_plan: Option<Arc<nereids_physics::surrogate::SparseEmpiricalCubaturePlan>> =
1109 if !config.fit_temperature()
1110 && !config.fit_energy_scale()
1111 && resolution_plan.is_some()
1112 && xs.len() >= 2
1113 {
1114 let plan = resolution_plan.as_deref().expect("guarded above");
1115 let matrix = plan.compile_to_matrix();
1116 let k = xs.len();
1117 let n_rows = matrix.len();
1118 let mut sigmas_flat = Vec::with_capacity(k * n_rows);
1122 for row in xs.iter() {
1123 if row.len() != n_rows {
1124 sigmas_flat.clear();
1126 break;
1127 }
1128 sigmas_flat.extend_from_slice(row);
1129 }
1130 if sigmas_flat.len() == k * n_rows {
1131 debug_assert_eq!(
1141 sigmas_flat.len(),
1142 k * n_rows,
1143 "cubature σ dimensions: expected {k} × {n_rows} = {}, got {}",
1144 k * n_rows,
1145 sigmas_flat.len(),
1146 );
1147 let train_max: Vec<f64> = config
1152 .initial_densities()
1153 .iter()
1154 .map(|&n0| 2.0 * n0.max(1e-6))
1155 .collect();
1156 let training =
1157 nereids_physics::surrogate::SparseEmpiricalCubaturePlan::default_training_points(
1158 &train_max,
1159 );
1160 let anchor =
1161 nereids_physics::surrogate::SparseEmpiricalCubaturePlan::default_jacobian_anchor(
1162 &train_max,
1163 );
1164 match nereids_physics::surrogate::SparseEmpiricalCubaturePlan::build(
1165 &matrix,
1166 &sigmas_flat,
1167 k,
1168 &training,
1169 &anchor,
1170 ) {
1171 Ok(plan) => {
1172 Some(Arc::new(plan.with_density_box(train_max.clone())))
1179 }
1180 Err(e) => {
1181 eprintln!(
1189 "spatial_map_typed: sparse cubature build failed ({e}); \
1190 falling back to exact ResolutionPlan path for this call",
1191 );
1192 None
1193 }
1194 }
1195 } else {
1196 None
1197 }
1198 } else {
1199 None
1200 };
1201
1202 let sparse_cubature_plan = sparse_cubature_plan.or_else(|| {
1210 caller_cubature.filter(|p| {
1211 p.len() == xs.first().map(|r| r.len()).unwrap_or(0)
1212 && p.k() == xs.len()
1213 && p.target_energies() == config.energies()
1214 })
1215 });
1216
1217 let caller_scalar = config.precomputed_sparse_scalar_plan().cloned();
1231 let sparse_scalar_plan: Option<Arc<nereids_physics::surrogate::ScalarSurrogatePlan>> =
1232 if let Some(plan) = resolution_plan.as_ref()
1233 && !config.fit_temperature()
1234 && !config.fit_energy_scale()
1235 && xs.len() == 1
1236 {
1237 let sigma_row = &xs[0];
1238 const CHEBYSHEV_NODES: usize = 16;
1252 let n_max: f64 = 2.0 * config.initial_densities()[0].max(1e-6);
1253 match nereids_physics::surrogate::ScalarChebyshevPlan::build(
1254 Arc::clone(plan),
1255 sigma_row,
1256 n_max,
1257 CHEBYSHEV_NODES,
1258 ) {
1259 Ok(plan) => Some(Arc::new(plan)),
1260 Err(e) => {
1261 eprintln!(
1262 "spatial_map_typed: scalar Chebyshev build failed ({e}); \
1263 falling back to exact ResolutionPlan path",
1264 );
1265 None
1266 }
1267 }
1268 } else {
1269 None
1270 };
1271 let sparse_scalar_plan = sparse_scalar_plan.or_else(|| {
1277 caller_scalar.filter(|p| {
1278 let expected_len = xs.first().map(|r| r.len()).unwrap_or(0);
1279 if p.len() != expected_len {
1280 return false;
1281 }
1282 let plan_grid = p.target_energies();
1283 let cfg_grid = config.energies();
1284 if plan_grid.len() != cfg_grid.len() {
1285 return false;
1286 }
1287 plan_grid
1288 .iter()
1289 .zip(cfg_grid)
1290 .all(|(a, b)| a.to_bits() == b.to_bits())
1291 })
1292 });
1293
1294 let fast_config = if config.fit_temperature() {
1298 let base_xs: Vec<Vec<f64>> =
1299 unbroadened_cross_sections(config.energies(), config.resonance_data(), cancel)
1300 .map_err(PipelineError::Transmission)?;
1301 let mut cfg = config
1302 .clone()
1303 .with_precomputed_cross_sections(xs)
1304 .with_precomputed_base_xs(Arc::new(base_xs))
1305 .with_compute_covariance(true);
1306 if let Some(plan) = resolution_plan.clone() {
1307 cfg = cfg.with_precomputed_resolution_plan(plan);
1308 }
1309 cfg
1313 } else {
1314 let mut cfg = config.clone();
1318 if cfg.density_indices.is_some() {
1319 cfg.density_indices = None;
1320 cfg.density_ratios = None;
1321 }
1322 let mut cfg = cfg
1323 .with_precomputed_cross_sections(xs)
1324 .with_compute_covariance(true);
1325 if let (Some(work_xs), Some(layout)) = (work_xs.clone(), work_layout.clone()) {
1332 cfg = cfg.with_precomputed_work_cross_sections(work_xs, layout);
1333 }
1334 if let Some(plan) = resolution_plan.clone() {
1335 cfg = cfg.with_precomputed_resolution_plan(plan);
1336 }
1337 if let Some(plan) = sparse_cubature_plan.clone() {
1338 cfg = cfg.with_precomputed_sparse_cubature_plan(plan);
1339 }
1340 if let Some(plan) = sparse_scalar_plan.clone() {
1341 cfg = cfg.with_precomputed_sparse_scalar_plan(plan);
1342 }
1343 cfg
1344 };
1345
1346 let fast_config = apply_spatial_polish_default(fast_config, pixel_coords.len());
1354
1355 let averaged_flux: Option<Vec<f64>> = if matches!(input, InputData3D::Counts { .. }) {
1380 let n_e = data_b.shape()[2]; let mut flux = vec![0.0f64; n_e];
1382 let n_live = pixel_coords.len() as f64;
1383 if n_live > 0.0 {
1384 for &(y, x) in &pixel_coords {
1385 let ob_spectrum = data_b.slice(s![y, x, ..]);
1386 for (e, &v) in ob_spectrum.iter().enumerate() {
1387 flux[e] += v;
1388 }
1389 }
1390 for v in &mut flux {
1391 *v /= n_live;
1392 }
1393 if let Some(e) = flux.iter().position(|v| !v.is_finite()) {
1399 return Err(PipelineError::InvalidParameter(format!(
1400 "spatially-averaged open-beam flux is non-finite at energy \
1401 bin e={e} (got {}); summed open-beam counts overflowed. \
1402 Check the open-beam cube magnitude.",
1403 flux[e],
1404 )));
1405 }
1406 }
1407 Some(flux)
1408 } else {
1409 None
1410 };
1411 let background_zeros: Vec<f64> = if matches!(input, InputData3D::Counts { .. }) {
1412 vec![0.0f64; data_b.shape()[2]]
1413 } else {
1414 Vec::new()
1415 };
1416
1417 let failed_count = AtomicUsize::new(0);
1419 let results: Vec<((usize, usize), SpectrumFitResult)> = pixel_coords
1420 .par_iter()
1421 .filter_map(|&(y, x)| {
1422 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
1423 return None;
1424 }
1425
1426 let spectrum_a: Vec<f64> = data_a.slice(s![y, x, ..]).to_vec();
1427
1428 let pixel_input = match input {
1430 InputData3D::Counts { .. } => {
1431 let ob_spectrum: Vec<f64> = data_b.slice(s![y, x, ..]).to_vec();
1432
1433 let effective = fast_config.effective_solver(&InputData::Counts {
1444 sample_counts: spectrum_a.clone(),
1445 open_beam_counts: ob_spectrum.clone(),
1446 });
1447 match effective {
1448 SolverConfig::PoissonKL(_) => InputData::CountsWithNuisance {
1449 sample_counts: spectrum_a,
1450 flux: averaged_flux.as_ref().unwrap().clone(),
1451 background: background_zeros.clone(),
1455 },
1456 _ => InputData::Counts {
1457 sample_counts: spectrum_a,
1458 open_beam_counts: ob_spectrum,
1459 },
1460 }
1461 }
1462 InputData3D::CountsWithNuisance { .. } => InputData::CountsWithNuisance {
1463 sample_counts: spectrum_a,
1466 flux: data_b.slice(s![y, x, ..]).to_vec(),
1467 background: data_c
1468 .as_ref()
1469 .expect("CountsWithNuisance requires background cube")
1470 .slice(s![y, x, ..])
1471 .to_vec(),
1472 },
1473 InputData3D::Transmission { .. } => {
1474 let spectrum_b: Vec<f64> = data_b.slice(s![y, x, ..]).to_vec();
1482 InputData::Transmission {
1483 transmission: spectrum_a,
1484 uncertainty: spectrum_b,
1485 }
1486 }
1487 };
1488
1489 let out = match fit_spectrum_typed(&pixel_input, &fast_config) {
1490 Ok(result) => Some(((y, x), result)),
1491 Err(_) => {
1492 failed_count.fetch_add(1, Ordering::Relaxed);
1493 None
1494 }
1495 };
1496 if let Some(p) = progress {
1497 p.fetch_add(1, Ordering::Relaxed);
1498 }
1499 out
1500 })
1501 .collect();
1502
1503 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
1514 return Err(PipelineError::Cancelled);
1515 }
1516
1517 let mut density_maps: Vec<Array2<f64>> = (0..n_maps)
1519 .map(|_| Array2::from_elem((height, width), f64::NAN))
1520 .collect();
1521 let mut uncertainty_maps: Vec<Array2<f64>> = (0..n_maps)
1522 .map(|_| Array2::from_elem((height, width), f64::NAN))
1523 .collect();
1524 let mut chi_squared_map = Array2::from_elem((height, width), f64::NAN);
1525 let mut deviance_per_dof_map: Option<Array2<f64>> = if dispatches_to_counts_kl {
1526 Some(Array2::from_elem((height, width), f64::NAN))
1527 } else {
1528 None
1529 };
1530 let mut converged_map = Array2::from_elem((height, width), false);
1531 let mut anorm_map: Option<Array2<f64>> = if has_background_outputs {
1532 Some(Array2::from_elem((height, width), f64::NAN))
1533 } else {
1534 None
1535 };
1536 let mut background_maps: Option<[Array2<f64>; 3]> = if has_background_outputs {
1537 Some([
1538 Array2::from_elem((height, width), f64::NAN),
1539 Array2::from_elem((height, width), f64::NAN),
1540 Array2::from_elem((height, width), f64::NAN),
1541 ])
1542 } else {
1543 None
1544 };
1545 let mut back_d_map: Option<Array2<f64>> = if has_back_d_map {
1546 Some(Array2::from_elem((height, width), f64::NAN))
1547 } else {
1548 None
1549 };
1550 let mut back_f_map: Option<Array2<f64>> = if has_back_f_map {
1551 Some(Array2::from_elem((height, width), f64::NAN))
1552 } else {
1553 None
1554 };
1555 let mut t0_us_map: Option<Array2<f64>> = if config.fit_energy_scale() {
1556 Some(Array2::from_elem((height, width), f64::NAN))
1557 } else {
1558 None
1559 };
1560 let mut l_scale_map: Option<Array2<f64>> = if config.fit_energy_scale() {
1561 Some(Array2::from_elem((height, width), f64::NAN))
1562 } else {
1563 None
1564 };
1565 let mut n_converged = 0;
1566 let mut temperature_map: Option<Array2<f64>> = if config.fit_temperature() {
1567 Some(Array2::from_elem((height, width), f64::NAN))
1568 } else {
1569 None
1570 };
1571 let mut temperature_uncertainty_map: Option<Array2<f64>> = if config.fit_temperature() {
1572 Some(Array2::from_elem((height, width), f64::NAN))
1573 } else {
1574 None
1575 };
1576
1577 for ((y, x), result) in &results {
1601 converged_map[[*y, *x]] = result.converged;
1604 if !result.converged {
1605 continue;
1606 }
1607
1608 n_converged += 1;
1609
1610 for i in 0..n_maps {
1611 density_maps[i][[*y, *x]] = result.densities[i];
1612 if let Some(ref unc) = result.uncertainties {
1613 uncertainty_maps[i][[*y, *x]] = unc[i];
1614 }
1615 }
1616 chi_squared_map[[*y, *x]] = result.reduced_chi_squared;
1617 if let (Some(dpd), Some(v)) = (&mut deviance_per_dof_map, result.deviance_per_dof) {
1618 dpd[[*y, *x]] = v;
1619 }
1620 if let (Some(t_map), Some(t)) = (&mut temperature_map, result.temperature_k) {
1621 t_map[[*y, *x]] = t;
1622 }
1623 if let (Some(tu_map), Some(tu)) =
1624 (&mut temperature_uncertainty_map, result.temperature_k_unc)
1625 {
1626 tu_map[[*y, *x]] = tu;
1627 }
1628 if let Some(ref mut a_map) = anorm_map {
1629 a_map[[*y, *x]] = result.anorm;
1630 }
1631 if let Some(ref mut bg_maps) = background_maps {
1632 bg_maps[0][[*y, *x]] = result.background[0];
1633 bg_maps[1][[*y, *x]] = result.background[1];
1634 bg_maps[2][[*y, *x]] = result.background[2];
1635 }
1636 if let Some(ref mut map) = back_d_map {
1646 map[[*y, *x]] = result.back_d.unwrap_or(f64::NAN);
1647 }
1648 if let Some(ref mut map) = back_f_map {
1649 map[[*y, *x]] = result.back_f.unwrap_or(f64::NAN);
1650 }
1651 if let (Some(map), Some(v)) = (&mut t0_us_map, result.t0_us) {
1652 map[[*y, *x]] = v;
1653 }
1654 if let (Some(map), Some(v)) = (&mut l_scale_map, result.l_scale) {
1655 map[[*y, *x]] = v;
1656 }
1657 }
1658
1659 Ok(SpatialResult {
1660 density_maps,
1661 uncertainty_maps,
1662 chi_squared_map,
1663 deviance_per_dof_map,
1664 converged_map,
1665 temperature_map,
1666 temperature_uncertainty_map,
1667 isotope_labels,
1668 anorm_map,
1669 background_maps,
1670 back_d_map,
1671 back_f_map,
1672 t0_us_map,
1673 l_scale_map,
1674 n_converged,
1675 n_total: pixel_coords.len(),
1676 n_failed: failed_count.load(Ordering::Relaxed),
1677 })
1678}
1679
1680#[cfg(test)]
1683mod tests {
1684 use super::*;
1685 use ndarray::{Array2, Array3};
1686 use nereids_fitting::lm::{FitModel, LmConfig};
1687 use nereids_fitting::poisson::PoissonConfig;
1688 use nereids_fitting::transmission_model::PrecomputedTransmissionModel;
1689
1690 use crate::pipeline::{SolverConfig, UnifiedFitConfig};
1691 use nereids_endf::resonance::test_support::{
1692 synthetic_single_resonance, u238_single_resonance,
1693 };
1694
1695 fn synthetic_grid_transmission(
1698 res_data: &nereids_endf::resonance::ResonanceData,
1699 true_density: f64,
1700 energies: &[f64],
1701 height: usize,
1702 width: usize,
1703 ) -> (Array3<f64>, Array3<f64>) {
1704 let n_e = energies.len();
1705 let xs = nereids_physics::transmission::broadened_cross_sections(
1706 energies,
1707 std::slice::from_ref(res_data),
1708 0.0,
1709 None,
1710 None,
1711 )
1712 .unwrap();
1713 let model = PrecomputedTransmissionModel {
1714 cross_sections: Arc::new(xs),
1715 density_indices: Arc::new(vec![0]),
1716 energies: None,
1717 instrument: None,
1718 resolution_plan: None,
1719 sparse_cubature_plan: None,
1720 sparse_scalar_plan: None,
1721 work_layout: None,
1722 };
1723 let t_1d = model.evaluate(&[true_density]).unwrap();
1724 let sigma_1d: Vec<f64> = t_1d.iter().map(|&v| 0.01 * v.max(0.01)).collect();
1725
1726 let mut t_3d = Array3::zeros((n_e, height, width));
1727 let mut u_3d = Array3::zeros((n_e, height, width));
1728 for y in 0..height {
1729 for x in 0..width {
1730 for (i, (&t, &s)) in t_1d.iter().zip(sigma_1d.iter()).enumerate() {
1731 t_3d[[i, y, x]] = t;
1732 u_3d[[i, y, x]] = s;
1733 }
1734 }
1735 }
1736 (t_3d, u_3d)
1737 }
1738
1739 fn synthetic_4x4_transmission(
1741 res_data: &nereids_endf::resonance::ResonanceData,
1742 true_density: f64,
1743 energies: &[f64],
1744 ) -> (Array3<f64>, Array3<f64>) {
1745 synthetic_grid_transmission(res_data, true_density, energies, 4, 4)
1746 }
1747
1748 fn synthetic_4x4_counts(
1750 res_data: &nereids_endf::resonance::ResonanceData,
1751 true_density: f64,
1752 energies: &[f64],
1753 i0: f64,
1754 ) -> (Array3<f64>, Array3<f64>) {
1755 let (t_3d, _) = synthetic_4x4_transmission(res_data, true_density, energies);
1756 let n_e = energies.len();
1757 let mut sample = Array3::zeros((n_e, 4, 4));
1758 let mut ob = Array3::zeros((n_e, 4, 4));
1759 for y in 0..4 {
1760 for x in 0..4 {
1761 for i in 0..n_e {
1762 ob[[i, y, x]] = i0;
1763 sample[[i, y, x]] = (t_3d[[i, y, x]] * i0).round().max(0.0);
1764 }
1765 }
1766 }
1767 (sample, ob)
1768 }
1769
1770 #[test]
1771 fn test_spatial_map_typed_transmission_lm() {
1772 let data = u238_single_resonance();
1773 let true_density = 0.0005;
1774 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
1775 let (t_3d, u_3d) = synthetic_4x4_transmission(&data, true_density, &energies);
1776
1777 let config = UnifiedFitConfig::new(
1778 energies,
1779 vec![data],
1780 vec!["U-238".into()],
1781 0.0,
1782 None,
1783 vec![0.001],
1784 )
1785 .unwrap()
1786 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
1787
1788 let input = InputData3D::Transmission {
1789 transmission: t_3d.view(),
1790 uncertainty: u_3d.view(),
1791 };
1792
1793 let result = spatial_map_typed(&input, &config, None, None, None).unwrap();
1794 assert_eq!(result.n_total, 16);
1795 assert!(result.n_converged >= 14, "Most pixels should converge");
1796
1797 let d = &result.density_maps[0];
1799 let conv = &result.converged_map;
1800 let mean: f64 = d
1801 .iter()
1802 .zip(conv.iter())
1803 .filter(|(_, c)| **c)
1804 .map(|(d, _)| *d)
1805 .sum::<f64>()
1806 / result.n_converged as f64;
1807 assert!(
1808 (mean - true_density).abs() / true_density < 0.05,
1809 "mean density: {mean}, true: {true_density}"
1810 );
1811 }
1812
1813 #[test]
1814 fn test_spatial_map_typed_counts_kl() {
1815 let data = u238_single_resonance();
1816 let true_density = 0.0005;
1817 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
1818 let (sample, ob) = synthetic_4x4_counts(&data, true_density, &energies, 1000.0);
1819
1820 let config = UnifiedFitConfig::new(
1821 energies,
1822 vec![data],
1823 vec!["U-238".into()],
1824 0.0,
1825 None,
1826 vec![0.001],
1827 )
1828 .unwrap()
1829 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
1830
1831 let input = InputData3D::Counts {
1832 sample_counts: sample.view(),
1833 open_beam_counts: ob.view(),
1834 };
1835
1836 let result = spatial_map_typed(&input, &config, None, None, None).unwrap();
1837 assert_eq!(result.n_total, 16);
1838 assert!(
1839 result.n_converged >= 14,
1840 "Most pixels should converge with KL"
1841 );
1842
1843 let d = &result.density_maps[0];
1844 let conv = &result.converged_map;
1845 let mean: f64 = d
1846 .iter()
1847 .zip(conv.iter())
1848 .filter(|(_, c)| **c)
1849 .map(|(d, _)| *d)
1850 .sum::<f64>()
1851 / result.n_converged.max(1) as f64;
1852 assert!(
1853 (mean - true_density).abs() / true_density < 0.10,
1854 "KL mean density: {mean}, true: {true_density}"
1855 );
1856 }
1857
1858 #[test]
1863 fn test_spatial_map_rejects_wrong_shape_precomputed_cross_sections() {
1864 let data = u238_single_resonance();
1865 let energies: Vec<f64> = (0..21).map(|i| 1.0 + (i as f64) * 0.1).collect();
1866 let (t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
1867
1868 let n_e = energies.len();
1870 let bad_xs = Arc::new(vec![vec![1.0; n_e], vec![1.0; n_e]]);
1871 let config = UnifiedFitConfig::new(
1872 energies,
1873 vec![data],
1874 vec!["U-238".into()],
1875 0.0,
1876 None,
1877 vec![0.001],
1878 )
1879 .unwrap()
1880 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
1881 .with_precomputed_cross_sections(bad_xs);
1882
1883 let input = InputData3D::Transmission {
1884 transmission: t_3d.view(),
1885 uncertainty: u_3d.view(),
1886 };
1887
1888 let err = spatial_map_typed(&input, &config, None, None, None)
1889 .expect_err("wrong-shape precomputed XS must be rejected up front");
1890 assert!(
1891 matches!(err, PipelineError::ShapeMismatch(_)),
1892 "expected ShapeMismatch, got {err:?}"
1893 );
1894 }
1895
1896 #[test]
1911 fn test_spatial_map_mid_run_cancellation_returns_err() {
1912 use std::sync::atomic::{AtomicBool, AtomicUsize};
1913
1914 let data = u238_single_resonance();
1915 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
1916 let (t_3d, u_3d) = synthetic_grid_transmission(&data, 0.0005, &energies, 1, 64);
1919
1920 let config = UnifiedFitConfig::new(
1921 energies,
1922 vec![data],
1923 vec!["U-238".into()],
1924 0.0,
1925 None,
1926 vec![0.001],
1927 )
1928 .unwrap()
1929 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
1930
1931 let input = InputData3D::Transmission {
1932 transmission: t_3d.view(),
1933 uncertainty: u_3d.view(),
1934 };
1935
1936 let cancel = AtomicBool::new(false);
1937 let progress = AtomicUsize::new(0);
1938
1939 let result = std::thread::scope(|s| {
1940 s.spawn(|| {
1943 while progress.load(Ordering::Relaxed) < 1 {
1944 std::hint::spin_loop();
1945 }
1946 cancel.store(true, Ordering::Relaxed);
1947 });
1948 spatial_map_typed(&input, &config, None, Some(&cancel), Some(&progress))
1949 });
1950
1951 assert!(
1952 matches!(result, Err(PipelineError::Cancelled)),
1953 "mid-run cancellation must return Err(Cancelled), got {result:?}"
1954 );
1955 }
1956
1957 fn synthetic_tabulated_text() -> String {
1968 "header\n---\n\
1974 5.0 0.0\n\
1975 -0.01 0.0\n\
1976 -0.005 0.5\n\
1977 0.0 1.0\n\
1978 0.005 0.5\n\
1979 0.01 0.0\n\
1980 \n\
1981 200.0 0.0\n\
1982 -0.02 0.0\n\
1983 -0.01 0.5\n\
1984 0.0 1.0\n\
1985 0.01 0.5\n\
1986 0.02 0.0\n"
1987 .to_string()
1988 }
1989
1990 #[test]
2003 fn test_spatial_map_typed_with_resolution_plan_converges_and_is_deterministic() {
2004 use nereids_physics::resolution::{ResolutionFunction, TabulatedResolution};
2005
2006 let data = u238_single_resonance();
2007 let true_density = 0.0005;
2008 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2009 let (t_3d, u_3d) = synthetic_4x4_transmission(&data, true_density, &energies);
2010
2011 let tab = TabulatedResolution::from_text(&synthetic_tabulated_text(), 25.0).unwrap();
2012 let resolution = ResolutionFunction::Tabulated(Arc::new(tab));
2013
2014 let config = UnifiedFitConfig::new(
2015 energies.clone(),
2016 vec![data.clone()],
2017 vec!["U-238".into()],
2018 0.0,
2019 Some(resolution),
2020 vec![0.001],
2021 )
2022 .unwrap()
2023 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2024
2025 let input = InputData3D::Transmission {
2026 transmission: t_3d.view(),
2027 uncertainty: u_3d.view(),
2028 };
2029
2030 let result_with_plan = spatial_map_typed(&input, &config, None, None, None).unwrap();
2031 assert_eq!(result_with_plan.n_total, 16);
2032 assert!(
2033 result_with_plan.n_converged >= 14,
2034 "plan path: {} / 16 pixels converged",
2035 result_with_plan.n_converged,
2036 );
2037
2038 let d = &result_with_plan.density_maps[0];
2039 let conv = &result_with_plan.converged_map;
2040 let mean: f64 = d
2041 .iter()
2042 .zip(conv.iter())
2043 .filter(|(_, c)| **c)
2044 .map(|(d, _)| *d)
2045 .sum::<f64>()
2046 / result_with_plan.n_converged.max(1) as f64;
2047 assert!(
2048 (mean - true_density).abs() / true_density < 0.10,
2049 "mean density with plan: {mean}, true: {true_density}"
2050 );
2051
2052 let reference = d
2058 .iter()
2059 .zip(conv.iter())
2060 .find(|(_, c)| **c)
2061 .map(|(d, _)| *d)
2062 .expect("at least one pixel converged");
2063 for (&cell, &c) in d.iter().zip(conv.iter()) {
2064 if c {
2065 assert_eq!(
2066 cell.to_bits(),
2067 reference.to_bits(),
2068 "plan cache leaked pixel-specific state: density cell {cell} != reference {reference}"
2069 );
2070 }
2071 }
2072 }
2073
2074 #[test]
2084 fn test_spatial_map_typed_gaussian_aux_grid_recovers_density() {
2085 use nereids_physics::resolution::{ResolutionFunction, ResolutionParams};
2086 use nereids_physics::transmission::{SampleParams, forward_model};
2087
2088 let data = u238_single_resonance(); let true_density = 0.0005;
2090 let temperature = 300.0;
2091 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2092 let inst = Arc::new(InstrumentParams {
2093 resolution: ResolutionFunction::Gaussian(
2094 ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2095 ),
2096 });
2097
2098 let sample = SampleParams::new(temperature, vec![(data.clone(), true_density)]).unwrap();
2101 let t_1d = forward_model(&energies, &sample, Some(&inst)).unwrap();
2102
2103 let t_none = forward_model(&energies, &sample, None).unwrap();
2106 let broaden = t_1d
2107 .iter()
2108 .zip(t_none.iter())
2109 .map(|(a, b)| (a - b).abs())
2110 .fold(0.0f64, f64::max);
2111 assert!(
2112 broaden > 1e-4,
2113 "Gaussian kernel must broaden the spectrum non-trivially (got {broaden:.3e})"
2114 );
2115
2116 let n_e = energies.len();
2118 let sigma_1d: Vec<f64> = t_1d.iter().map(|&v| 0.01 * v.max(0.01)).collect();
2119 let mut t_3d = Array3::zeros((n_e, 4, 4));
2120 let mut u_3d = Array3::zeros((n_e, 4, 4));
2121 for y in 0..4 {
2122 for x in 0..4 {
2123 for (i, (&t, &s)) in t_1d.iter().zip(sigma_1d.iter()).enumerate() {
2124 t_3d[[i, y, x]] = t;
2125 u_3d[[i, y, x]] = s;
2126 }
2127 }
2128 }
2129
2130 let config = UnifiedFitConfig::new(
2131 energies.clone(),
2132 vec![data],
2133 vec!["U-238".into()],
2134 temperature,
2135 Some(ResolutionFunction::Gaussian(
2136 ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2137 )),
2138 vec![0.001],
2139 )
2140 .unwrap()
2141 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2142
2143 let input = InputData3D::Transmission {
2144 transmission: t_3d.view(),
2145 uncertainty: u_3d.view(),
2146 };
2147 let result = spatial_map_typed(&input, &config, None, None, None).unwrap();
2148 assert_eq!(result.n_total, 16);
2149 assert!(
2150 result.n_converged >= 14,
2151 "Gaussian aux-grid path: {} / 16 pixels converged",
2152 result.n_converged,
2153 );
2154
2155 let d = &result.density_maps[0];
2157 let conv = &result.converged_map;
2158 let mean: f64 = d
2159 .iter()
2160 .zip(conv.iter())
2161 .filter(|(_, c)| **c)
2162 .map(|(d, _)| *d)
2163 .sum::<f64>()
2164 / result.n_converged.max(1) as f64;
2165 assert!(
2166 (mean - true_density).abs() / true_density < 0.10,
2167 "Gaussian aux-grid mean density: {mean}, true: {true_density}"
2168 );
2169
2170 let reference = d
2173 .iter()
2174 .zip(conv.iter())
2175 .find(|(_, c)| **c)
2176 .map(|(d, _)| *d)
2177 .expect("at least one pixel converged");
2178 for (&cell, &c) in d.iter().zip(conv.iter()) {
2179 if c {
2180 assert_eq!(
2181 cell.to_bits(),
2182 reference.to_bits(),
2183 "aux-grid path leaked pixel-specific state: density cell {cell} != reference {reference}"
2184 );
2185 }
2186 }
2187 }
2188
2189 #[test]
2196 fn test_spatial_map_typed_gaussian_aux_grid_with_precomputed_sigma() {
2197 use nereids_physics::resolution::{ResolutionFunction, ResolutionParams};
2198 use nereids_physics::transmission::{
2199 SampleParams, broadened_cross_sections, forward_model,
2200 };
2201
2202 let data = u238_single_resonance();
2203 let true_density = 0.0005;
2204 let temperature = 300.0;
2205 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2206 let inst = Arc::new(InstrumentParams {
2207 resolution: ResolutionFunction::Gaussian(
2208 ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2209 ),
2210 });
2211 let sample = SampleParams::new(temperature, vec![(data.clone(), true_density)]).unwrap();
2212 let t_1d = forward_model(&energies, &sample, Some(&inst)).unwrap();
2213 let n_e = energies.len();
2214 let sigma_1d: Vec<f64> = t_1d.iter().map(|&v| 0.01 * v.max(0.01)).collect();
2215 let mut t_3d = Array3::zeros((n_e, 4, 4));
2216 let mut u_3d = Array3::zeros((n_e, 4, 4));
2217 for y in 0..4 {
2218 for x in 0..4 {
2219 for (i, (&t, &s)) in t_1d.iter().zip(sigma_1d.iter()).enumerate() {
2220 t_3d[[i, y, x]] = t;
2221 u_3d[[i, y, x]] = s;
2222 }
2223 }
2224 }
2225 let data_sigma = broadened_cross_sections(
2227 &energies,
2228 std::slice::from_ref(&data),
2229 temperature,
2230 None,
2231 None,
2232 )
2233 .unwrap();
2234 let config = UnifiedFitConfig::new(
2235 energies,
2236 vec![data],
2237 vec!["U-238".into()],
2238 temperature,
2239 Some(ResolutionFunction::Gaussian(
2240 ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2241 )),
2242 vec![0.001],
2243 )
2244 .unwrap()
2245 .with_precomputed_cross_sections(Arc::new(data_sigma))
2246 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2247 let input = InputData3D::Transmission {
2248 transmission: t_3d.view(),
2249 uncertainty: u_3d.view(),
2250 };
2251 let result = spatial_map_typed(&input, &config, None, None, None).unwrap();
2252 assert_eq!(result.n_total, 16);
2253 assert!(
2254 result.n_converged >= 14,
2255 "Some(cached)+aux path: {} / 16 pixels converged",
2256 result.n_converged,
2257 );
2258 let d = &result.density_maps[0];
2259 let conv = &result.converged_map;
2260 let mean: f64 = d
2261 .iter()
2262 .zip(conv.iter())
2263 .filter(|(_, c)| **c)
2264 .map(|(d, _)| *d)
2265 .sum::<f64>()
2266 / result.n_converged.max(1) as f64;
2267 assert!(
2268 (mean - true_density).abs() / true_density < 0.10,
2269 "Some(cached)+aux mean density: {mean}, true: {true_density}"
2270 );
2271 }
2272
2273 #[test]
2274 fn test_spatial_map_typed_counts_kl_low_counts() {
2275 let data = u238_single_resonance();
2277 let true_density = 0.0005;
2278 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2279 let (sample, ob) = synthetic_4x4_counts(&data, true_density, &energies, 10.0);
2280
2281 let config = UnifiedFitConfig::new(
2282 energies,
2283 vec![data],
2284 vec!["U-238".into()],
2285 0.0,
2286 None,
2287 vec![0.001],
2288 )
2289 .unwrap(); let input = InputData3D::Counts {
2292 sample_counts: sample.view(),
2293 open_beam_counts: ob.view(),
2294 };
2295
2296 let result = spatial_map_typed(&input, &config, None, None, None).unwrap();
2297 assert_eq!(result.n_total, 16);
2298 assert!(
2300 result.n_converged >= 10,
2301 "KL at I0=10: only {}/{} converged",
2302 result.n_converged,
2303 result.n_total
2304 );
2305 }
2306
2307 #[test]
2308 fn test_spatial_map_typed_dead_pixels() {
2309 let data = u238_single_resonance();
2310 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
2311 let (t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
2312
2313 let config = UnifiedFitConfig::new(
2314 energies,
2315 vec![data],
2316 vec!["U-238".into()],
2317 0.0,
2318 None,
2319 vec![0.001],
2320 )
2321 .unwrap();
2322
2323 let mut dead = Array2::from_elem((4, 4), false);
2325 for y in 0..2 {
2326 for x in 0..4 {
2327 dead[[y, x]] = true;
2328 }
2329 }
2330
2331 let input = InputData3D::Transmission {
2332 transmission: t_3d.view(),
2333 uncertainty: u_3d.view(),
2334 };
2335
2336 let result = spatial_map_typed(&input, &config, Some(&dead), None, None).unwrap();
2337 assert_eq!(result.n_total, 8, "Only 8 live pixels");
2338 }
2339
2340 #[test]
2350 fn test_spatial_map_rejects_counts_kl_alpha_up_front() {
2351 let data = u238_single_resonance();
2352 let true_density = 0.0005;
2353 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
2354 let (sample, ob) = synthetic_4x4_counts(&data, true_density, &energies, 1000.0);
2355
2356 let config = UnifiedFitConfig::new(
2357 energies,
2358 vec![data],
2359 vec!["U-238".into()],
2360 0.0,
2361 None,
2362 vec![0.001],
2363 )
2364 .unwrap()
2365 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
2366 .with_counts_background(crate::pipeline::CountsBackgroundConfig {
2367 alpha_1_init: 1.0,
2368 alpha_2_init: 1.0,
2369 fit_alpha_1: false,
2370 fit_alpha_2: true,
2371 c: 1.0,
2372 });
2373
2374 let input = InputData3D::Counts {
2375 sample_counts: sample.view(),
2376 open_beam_counts: ob.view(),
2377 };
2378
2379 let err = spatial_map_typed(&input, &config, None, None, None)
2380 .expect_err("counts-KL with fit_alpha_2 must be rejected up-front");
2381 let msg = err.to_string();
2382 assert!(
2383 matches!(err, PipelineError::InvalidParameter(_)),
2384 "expected InvalidParameter, got {err:?}"
2385 );
2386 assert!(
2387 msg.contains("fit_alpha_1") || msg.contains("fit_alpha_2"),
2388 "error must name the offending flag, got: {msg}"
2389 );
2390 }
2391
2392 #[test]
2395 fn test_spatial_map_grouped() {
2396 let rd1 = synthetic_single_resonance(92, 235, 233.025, 5.0);
2397 let rd2 = synthetic_single_resonance(92, 238, 236.006, 7.0);
2398
2399 let iso1 = nereids_core::types::Isotope::new(92, 235).unwrap();
2400 let iso2 = nereids_core::types::Isotope::new(92, 238).unwrap();
2401 let group = nereids_core::types::IsotopeGroup::custom(
2402 "U (60/40)".into(),
2403 vec![(iso1, 0.6), (iso2, 0.4)],
2404 )
2405 .unwrap();
2406
2407 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
2408 let n_e = energies.len();
2409 let true_density = 0.0005;
2410
2411 let sample = nereids_physics::transmission::SampleParams::new(
2413 0.0,
2414 vec![
2415 (rd1.clone(), true_density * 0.6),
2416 (rd2.clone(), true_density * 0.4),
2417 ],
2418 )
2419 .unwrap();
2420 let t_1d = nereids_physics::transmission::forward_model(&energies, &sample, None).unwrap();
2421 let s_1d: Vec<f64> = t_1d.iter().map(|&v| 0.01 * v.max(0.01)).collect();
2422
2423 let mut t_3d = Array3::zeros((n_e, 2, 2));
2425 let mut u_3d = Array3::zeros((n_e, 2, 2));
2426 for y in 0..2 {
2427 for x in 0..2 {
2428 for (i, (&t, &s)) in t_1d.iter().zip(s_1d.iter()).enumerate() {
2429 t_3d[[i, y, x]] = t;
2430 u_3d[[i, y, x]] = s;
2431 }
2432 }
2433 }
2434
2435 let config = UnifiedFitConfig::new(
2436 energies,
2437 vec![rd1.clone()],
2438 vec!["placeholder".into()],
2439 0.0,
2440 None,
2441 vec![0.001],
2442 )
2443 .unwrap()
2444 .with_groups(&[(&group, &[rd1, rd2])], vec![0.001])
2445 .unwrap()
2446 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2447
2448 let input = InputData3D::Transmission {
2449 transmission: t_3d.view(),
2450 uncertainty: u_3d.view(),
2451 };
2452
2453 let result = spatial_map_typed(&input, &config, None, None, None).unwrap();
2454
2455 assert_eq!(
2457 result.density_maps.len(),
2458 1,
2459 "should have 1 group density map"
2460 );
2461 assert_eq!(result.isotope_labels, vec!["U (60/40)"]);
2462 assert_eq!(result.n_total, 4);
2463
2464 for y in 0..2 {
2466 for x in 0..2 {
2467 let fitted = result.density_maps[0][[y, x]];
2468 let rel_error = (fitted - true_density).abs() / true_density;
2469 assert!(
2470 rel_error < 0.05,
2471 "pixel ({y},{x}): fitted={fitted}, true={true_density}, rel_error={rel_error}"
2472 );
2473 }
2474 }
2475 }
2476
2477 #[test]
2481 fn test_spatial_lm_populates_density_uncertainty() {
2482 let rd = u238_single_resonance();
2483 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2484 let (mut t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
2485 for y in 0..4 {
2488 for x in 0..4 {
2489 for e in 0..energies.len() {
2490 let noise = 0.002 * ((e * 7 + y * 13 + x * 29) % 17) as f64 / 17.0 - 0.001;
2491 t_3d[[e, y, x]] = (t_3d[[e, y, x]] + noise).max(0.001);
2492 }
2493 }
2494 }
2495 let data = InputData3D::Transmission {
2496 transmission: t_3d.view(),
2497 uncertainty: u_3d.view(),
2498 };
2499 let config = UnifiedFitConfig::new(
2500 energies,
2501 vec![rd],
2502 vec!["U-238".into()],
2503 0.0,
2504 None,
2505 vec![0.0005],
2506 )
2507 .unwrap()
2508 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2509
2510 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
2511 assert!(result.n_converged > 0, "some pixels should converge");
2512 let unc_map = &result.uncertainty_maps[0];
2514 let conv_map = &result.converged_map;
2515 let mut n_finite = 0;
2516 for y in 0..4 {
2517 for x in 0..4 {
2518 if conv_map[[y, x]] {
2519 let u = unc_map[[y, x]];
2520 assert!(
2521 u.is_finite() && u > 0.0,
2522 "LM density unc at ({y},{x}) should be finite+positive, got {u}"
2523 );
2524 n_finite += 1;
2525 }
2526 }
2527 }
2528 assert!(
2529 n_finite > 0,
2530 "at least one converged pixel should have finite unc"
2531 );
2532 }
2533
2534 #[test]
2536 fn test_spatial_kl_populates_density_uncertainty() {
2537 let rd = u238_single_resonance();
2538 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2539 let (t_3d, _) = synthetic_4x4_transmission(&rd, 0.001, &energies);
2540 let ob_3d = Array3::from_elem(t_3d.raw_dim(), 1000.0);
2542 let sample_3d = &t_3d * &ob_3d;
2543 let data = InputData3D::Counts {
2544 sample_counts: sample_3d.view(),
2545 open_beam_counts: ob_3d.view(),
2546 };
2547 let config = UnifiedFitConfig::new(
2548 energies,
2549 vec![rd],
2550 vec!["U-238".into()],
2551 0.0,
2552 None,
2553 vec![0.0005],
2554 )
2555 .unwrap()
2556 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
2557
2558 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
2559 assert!(result.n_converged > 0);
2560 let unc_map = &result.uncertainty_maps[0];
2561 let conv_map = &result.converged_map;
2562 let mut n_finite = 0;
2563 for y in 0..4 {
2564 for x in 0..4 {
2565 if conv_map[[y, x]] {
2566 let u = unc_map[[y, x]];
2567 assert!(
2568 u.is_finite() && u > 0.0,
2569 "KL density unc at ({y},{x}) should be finite+positive, got {u}"
2570 );
2571 n_finite += 1;
2572 }
2573 }
2574 }
2575 assert!(n_finite > 0);
2576 }
2577
2578 #[test]
2580 fn test_spatial_temperature_uncertainty_map() {
2581 let rd = u238_single_resonance();
2582 let energies: Vec<f64> = (0..101).map(|i| 4.0 + (i as f64) * 0.05).collect();
2583 let (mut t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
2584 for y in 0..4 {
2586 for x in 0..4 {
2587 for e in 0..energies.len() {
2588 let noise = 0.002 * ((e * 7 + y * 13 + x * 29) % 17) as f64 / 17.0 - 0.001;
2589 t_3d[[e, y, x]] = (t_3d[[e, y, x]] + noise).max(0.001);
2590 }
2591 }
2592 }
2593 let data = InputData3D::Transmission {
2594 transmission: t_3d.view(),
2595 uncertainty: u_3d.view(),
2596 };
2597 let config = UnifiedFitConfig::new(
2598 energies,
2599 vec![rd],
2600 vec!["U-238".into()],
2601 300.0,
2602 None,
2603 vec![0.0005],
2604 )
2605 .unwrap()
2606 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
2607 .with_fit_temperature(true);
2608
2609 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
2610 assert!(result.temperature_map.is_some());
2611 let tu_map = result
2612 .temperature_uncertainty_map
2613 .as_ref()
2614 .expect("temperature_uncertainty_map should be Some when fit_temperature=true");
2615 assert_eq!(tu_map.shape(), [4, 4]);
2616 let mut n_finite = 0;
2618 for y in 0..4 {
2619 for x in 0..4 {
2620 if result.converged_map[[y, x]] {
2621 let tu = tu_map[[y, x]];
2622 if tu.is_finite() && tu > 0.0 {
2623 n_finite += 1;
2624 }
2625 }
2626 }
2627 }
2628 assert!(
2629 n_finite > 0,
2630 "at least one converged pixel should have finite temperature uncertainty"
2631 );
2632 }
2633
2634 #[test]
2643 fn test_spatial_unconverged_pixels_are_nan() {
2644 let rd = u238_single_resonance();
2645 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2646 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
2653 let data = InputData3D::Transmission {
2654 transmission: t_3d.view(),
2655 uncertainty: u_3d.view(),
2656 };
2657 let config = UnifiedFitConfig::new(
2658 energies,
2659 vec![rd],
2660 vec!["U-238".into()],
2661 0.0,
2662 None,
2663 vec![0.1], )
2665 .unwrap()
2666 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig {
2667 max_iter: 1,
2668 ..Default::default()
2669 }))
2670 .with_transmission_background(crate::pipeline::BackgroundConfig::default());
2671
2672 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
2673
2674 let unconverged_pixel = (0..4)
2679 .flat_map(|y| (0..4).map(move |x| (y, x)))
2680 .find(|(y, x)| !result.converged_map[[*y, *x]]);
2681 let (uy, ux) = match unconverged_pixel {
2682 Some(p) => p,
2683 None => panic!(
2684 "every pixel converged in max_iter=1 + 100×-off initial density setup — \
2685 test is no longer exercising the un-converged aggregation path; \
2686 tighten the setup (larger offset or fewer iterations)"
2687 ),
2688 };
2689
2690 for (i, m) in result.density_maps.iter().enumerate() {
2692 let v = m[[uy, ux]];
2693 assert!(
2694 v.is_nan(),
2695 "density_maps[{i}] at unconverged pixel ({uy},{ux}) must be NaN, got {v}"
2696 );
2697 }
2698 for (i, m) in result.uncertainty_maps.iter().enumerate() {
2699 let v = m[[uy, ux]];
2700 assert!(
2701 v.is_nan(),
2702 "uncertainty_maps[{i}] at unconverged pixel ({uy},{ux}) must be NaN, got {v}"
2703 );
2704 }
2705 let chi2 = result.chi_squared_map[[uy, ux]];
2706 assert!(
2707 chi2.is_nan(),
2708 "chi_squared_map at unconverged pixel ({uy},{ux}) must be NaN, got {chi2}"
2709 );
2710 if let Some(ref a_map) = result.anorm_map {
2711 let v = a_map[[uy, ux]];
2712 assert!(
2713 v.is_nan(),
2714 "anorm_map at unconverged pixel ({uy},{ux}) must be NaN, got {v}"
2715 );
2716 }
2717 if let Some(ref bg) = result.background_maps {
2718 for (i, m) in bg.iter().enumerate() {
2719 let v = m[[uy, ux]];
2720 assert!(
2721 v.is_nan(),
2722 "background_maps[{i}] at unconverged pixel ({uy},{ux}) must be NaN, got {v}"
2723 );
2724 }
2725 }
2726 if let Some(ref m) = result.back_d_map {
2727 let v = m[[uy, ux]];
2728 assert!(
2729 v.is_nan(),
2730 "back_d_map at unconverged pixel ({uy},{ux}) must be NaN, got {v}"
2731 );
2732 }
2733 if let Some(ref m) = result.back_f_map {
2734 let v = m[[uy, ux]];
2735 assert!(
2736 v.is_nan(),
2737 "back_f_map at unconverged pixel ({uy},{ux}) must be NaN, got {v}"
2738 );
2739 }
2740 }
2741
2742 #[test]
2747 fn test_spatial_map_back_d_f_maps_none_when_fit_disabled() {
2748 let rd = u238_single_resonance();
2749 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
2750 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
2751 let data = InputData3D::Transmission {
2752 transmission: t_3d.view(),
2753 uncertainty: u_3d.view(),
2754 };
2755 let config = UnifiedFitConfig::new(
2756 energies,
2757 vec![rd],
2758 vec!["U-238".into()],
2759 0.0,
2760 None,
2761 vec![0.001],
2762 )
2763 .unwrap()
2764 .with_transmission_background(crate::pipeline::BackgroundConfig::default());
2767
2768 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
2769 assert!(
2770 result.background_maps.is_some(),
2771 "background_maps should be Some when transmission_background is attached"
2772 );
2773 assert!(
2774 result.back_d_map.is_none(),
2775 "back_d_map must be None when fit_back_d=false"
2776 );
2777 assert!(
2778 result.back_f_map.is_none(),
2779 "back_f_map must be None when fit_back_f=false"
2780 );
2781 }
2782
2783 #[test]
2794 fn test_spatial_map_back_d_f_maps_some_when_fit_enabled() {
2795 let rd = u238_single_resonance();
2796 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2798 let true_density = 0.0005;
2799 let true_back_d = 0.03;
2800 let true_back_f = 2.0;
2801 let (mut t_3d, u_3d) = synthetic_4x4_transmission(&rd, true_density, &energies);
2807 for (i, &e) in energies.iter().enumerate() {
2808 let inv_sqrt_e = 1.0 / e.sqrt();
2809 let tail = true_back_d * (-true_back_f * inv_sqrt_e).exp();
2810 for y in 0..4 {
2811 for x in 0..4 {
2812 t_3d[[i, y, x]] += tail;
2813 }
2814 }
2815 }
2816 let data = InputData3D::Transmission {
2817 transmission: t_3d.view(),
2818 uncertainty: u_3d.view(),
2819 };
2820 let bg = crate::pipeline::BackgroundConfig {
2825 fit_back_d: true,
2826 fit_back_f: true,
2827 back_d_init: 0.01,
2828 back_f_init: 1.0,
2829 ..crate::pipeline::BackgroundConfig::default()
2830 };
2831 let config = UnifiedFitConfig::new(
2832 energies,
2833 vec![rd],
2834 vec!["U-238".into()],
2835 0.0,
2836 None,
2837 vec![true_density],
2838 )
2839 .unwrap()
2840 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig {
2841 max_iter: 500,
2842 ..LmConfig::default()
2843 }))
2844 .with_transmission_background(bg);
2845
2846 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
2847 let bd = result
2848 .back_d_map
2849 .as_ref()
2850 .expect("back_d_map should be Some when fit_back_d=true");
2851 let bf = result
2852 .back_f_map
2853 .as_ref()
2854 .expect("back_f_map should be Some when fit_back_f=true");
2855 assert_eq!(bd.shape(), [4, 4]);
2856 assert_eq!(bf.shape(), [4, 4]);
2857 assert!(
2858 result.n_converged > 0,
2859 "no pixels converged with LM + 7-param transmission background \
2860 on synthetic data carrying an exponential tail — test fixture \
2861 is no longer exercising the gating contract"
2862 );
2863 let mut n_finite_d = 0;
2866 let mut n_finite_f = 0;
2867 for y in 0..4 {
2868 for x in 0..4 {
2869 if result.converged_map[[y, x]] {
2870 if bd[[y, x]].is_finite() {
2871 n_finite_d += 1;
2872 }
2873 if bf[[y, x]].is_finite() {
2874 n_finite_f += 1;
2875 }
2876 } else {
2877 assert!(
2878 bd[[y, x]].is_nan(),
2879 "back_d_map at unconverged ({y},{x}) must be NaN"
2880 );
2881 assert!(
2882 bf[[y, x]].is_nan(),
2883 "back_f_map at unconverged ({y},{x}) must be NaN"
2884 );
2885 }
2886 }
2887 }
2888 assert!(
2891 n_finite_d > 0 && n_finite_f > 0,
2892 "at least one converged pixel must produce finite back_d/back_f \
2893 (n_converged={}, n_finite_d={n_finite_d}, n_finite_f={n_finite_f})",
2894 result.n_converged
2895 );
2896 }
2897
2898 #[test]
2903 fn test_spatial_map_counts_kl_back_d_f_maps_are_none() {
2904 let rd = u238_single_resonance();
2905 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2906 let (sample, ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
2907 let data = InputData3D::Counts {
2908 sample_counts: sample.view(),
2909 open_beam_counts: ob.view(),
2910 };
2911 let config = UnifiedFitConfig::new(
2912 energies,
2913 vec![rd],
2914 vec!["U-238".into()],
2915 0.0,
2916 None,
2917 vec![0.001],
2918 )
2919 .unwrap()
2920 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
2921 .with_counts_background(crate::pipeline::CountsBackgroundConfig::default());
2922 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
2923 assert!(
2924 result.back_d_map.is_none(),
2925 "back_d_map must be None on the counts-KL path"
2926 );
2927 assert!(
2928 result.back_f_map.is_none(),
2929 "back_f_map must be None on the counts-KL path"
2930 );
2931 }
2932
2933 #[test]
2938 fn test_spatial_map_back_d_f_unpaired_rejected_up_front() {
2939 let rd = u238_single_resonance();
2940 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
2941 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
2942 let data = InputData3D::Transmission {
2943 transmission: t_3d.view(),
2944 uncertainty: u_3d.view(),
2945 };
2946 let bg = crate::pipeline::BackgroundConfig {
2947 fit_back_d: true,
2948 fit_back_f: false, back_d_init: 0.01,
2950 back_f_init: 1.0,
2951 ..crate::pipeline::BackgroundConfig::default()
2952 };
2953 let config = UnifiedFitConfig::new(
2954 energies,
2955 vec![rd],
2956 vec!["U-238".into()],
2957 0.0,
2958 None,
2959 vec![0.001],
2960 )
2961 .unwrap()
2962 .with_transmission_background(bg);
2963 let err = spatial_map_typed(&data, &config, None, None, None)
2964 .expect_err("unpaired fit_back_d/fit_back_f must be rejected up-front");
2965 let msg = err.to_string();
2966 assert!(
2967 msg.contains("fit_back_d") && msg.contains("fit_back_f"),
2968 "error message must reference both fit flags, got: {msg}"
2969 );
2970 }
2971
2972 #[test]
2976 fn test_spatial_map_back_d_init_non_positive_rejected() {
2977 let rd = u238_single_resonance();
2978 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
2979 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
2980 let data = InputData3D::Transmission {
2981 transmission: t_3d.view(),
2982 uncertainty: u_3d.view(),
2983 };
2984 let bg = crate::pipeline::BackgroundConfig {
2985 fit_back_d: true,
2986 fit_back_f: true,
2987 back_d_init: 0.0, back_f_init: 1.0,
2989 ..crate::pipeline::BackgroundConfig::default()
2990 };
2991 let config = UnifiedFitConfig::new(
2992 energies,
2993 vec![rd],
2994 vec!["U-238".into()],
2995 0.0,
2996 None,
2997 vec![0.001],
2998 )
2999 .unwrap()
3000 .with_transmission_background(bg);
3001 let err = spatial_map_typed(&data, &config, None, None, None)
3002 .expect_err("back_d_init=0.0 with fit_back_d=true must be rejected up-front");
3003 assert!(
3004 err.to_string().contains("back_d_init"),
3005 "error must reference back_d_init, got: {err}"
3006 );
3007 }
3008
3009 #[test]
3013 fn test_spatial_map_back_f_init_non_positive_rejected() {
3014 let rd = u238_single_resonance();
3015 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3016 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3017 let data = InputData3D::Transmission {
3018 transmission: t_3d.view(),
3019 uncertainty: u_3d.view(),
3020 };
3021 let bg = crate::pipeline::BackgroundConfig {
3022 fit_back_d: true,
3023 fit_back_f: true,
3024 back_d_init: 0.01,
3025 back_f_init: -1.0, ..crate::pipeline::BackgroundConfig::default()
3027 };
3028 let config = UnifiedFitConfig::new(
3029 energies,
3030 vec![rd],
3031 vec!["U-238".into()],
3032 0.0,
3033 None,
3034 vec![0.001],
3035 )
3036 .unwrap()
3037 .with_transmission_background(bg);
3038 let err = spatial_map_typed(&data, &config, None, None, None)
3039 .expect_err("back_f_init=-1.0 with fit_back_f=true must be rejected up-front");
3040 assert!(
3041 err.to_string().contains("back_f_init"),
3042 "error must reference back_f_init, got: {err}"
3043 );
3044 }
3045
3046 #[test]
3051 fn test_spatial_map_back_d_init_nan_rejected() {
3052 let rd = u238_single_resonance();
3053 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3054 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3055 let data = InputData3D::Transmission {
3056 transmission: t_3d.view(),
3057 uncertainty: u_3d.view(),
3058 };
3059 let bg = crate::pipeline::BackgroundConfig {
3060 fit_back_d: true,
3061 fit_back_f: true,
3062 back_d_init: f64::NAN, back_f_init: 1.0,
3064 ..crate::pipeline::BackgroundConfig::default()
3065 };
3066 let config = UnifiedFitConfig::new(
3067 energies,
3068 vec![rd],
3069 vec!["U-238".into()],
3070 0.0,
3071 None,
3072 vec![0.001],
3073 )
3074 .unwrap()
3075 .with_transmission_background(bg);
3076 let err = spatial_map_typed(&data, &config, None, None, None)
3077 .expect_err("NaN back_d_init must be rejected up-front");
3078 let msg = err.to_string();
3079 assert!(
3080 msg.contains("back_d_init") && (msg.contains("finite") || msg.contains("NaN")),
3081 "error must mention finite/NaN for back_d_init, got: {msg}"
3082 );
3083 }
3084
3085 #[test]
3089 fn test_spatial_map_back_f_init_inf_rejected() {
3090 let rd = u238_single_resonance();
3091 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3092 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3093 let data = InputData3D::Transmission {
3094 transmission: t_3d.view(),
3095 uncertainty: u_3d.view(),
3096 };
3097 let bg = crate::pipeline::BackgroundConfig {
3098 fit_back_d: true,
3099 fit_back_f: true,
3100 back_d_init: 0.01,
3101 back_f_init: f64::INFINITY, ..crate::pipeline::BackgroundConfig::default()
3103 };
3104 let config = UnifiedFitConfig::new(
3105 energies,
3106 vec![rd],
3107 vec!["U-238".into()],
3108 0.0,
3109 None,
3110 vec![0.001],
3111 )
3112 .unwrap()
3113 .with_transmission_background(bg);
3114 let err = spatial_map_typed(&data, &config, None, None, None)
3115 .expect_err("+inf back_f_init must be rejected up-front");
3116 let msg = err.to_string();
3117 assert!(
3118 msg.contains("back_f_init") && (msg.contains("finite") || msg.contains("inf")),
3119 "error must mention finite/inf for back_f_init, got: {msg}"
3120 );
3121 }
3122
3123 #[test]
3129 fn test_spatial_map_counts_kl_plus_back_d_rejected_up_front() {
3130 let rd = u238_single_resonance();
3131 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3132 let (sample, ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
3133 let data = InputData3D::Counts {
3134 sample_counts: sample.view(),
3135 open_beam_counts: ob.view(),
3136 };
3137 let bg = crate::pipeline::BackgroundConfig {
3138 fit_back_d: true,
3139 fit_back_f: true,
3140 back_d_init: 0.01,
3141 back_f_init: 1.0,
3142 ..crate::pipeline::BackgroundConfig::default()
3143 };
3144 let config = UnifiedFitConfig::new(
3145 energies,
3146 vec![rd],
3147 vec!["U-238".into()],
3148 0.0,
3149 None,
3150 vec![0.001],
3151 )
3152 .unwrap()
3153 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
3154 .with_transmission_background(bg);
3155 let err = spatial_map_typed(&data, &config, None, None, None)
3156 .expect_err("counts-KL + fit_back_d/fit_back_f must be rejected up-front");
3157 let msg = err.to_string();
3158 assert!(
3159 msg.contains("counts-KL") || msg.contains("joint-Poisson"),
3160 "error must reference the counts-KL incompatibility, got: {msg}"
3161 );
3162 }
3163
3164 #[test]
3170 fn test_spatial_map_counts_with_nuisance_plus_lm_rejected_up_front() {
3171 use ndarray::Array3;
3172 let rd = u238_single_resonance();
3173 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3174 let (sample, _ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
3175 let flux: Array3<f64> = Array3::from_elem((energies.len(), 4, 4), 1000.0);
3179 let background: Array3<f64> = Array3::from_elem((energies.len(), 4, 4), 0.0);
3180 let data = InputData3D::CountsWithNuisance {
3181 sample_counts: sample.view(),
3182 flux: flux.view(),
3183 background: background.view(),
3184 };
3185 let config = UnifiedFitConfig::new(
3186 energies,
3187 vec![rd],
3188 vec!["U-238".into()],
3189 0.0,
3190 None,
3191 vec![0.001],
3192 )
3193 .unwrap()
3194 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
3195 let err = spatial_map_typed(&data, &config, None, None, None)
3196 .expect_err("CountsWithNuisance + LM must be rejected up-front");
3197 let msg = err.to_string();
3198 assert!(
3199 msg.contains("CountsWithNuisance") && msg.contains("counts-domain"),
3200 "error must mention CountsWithNuisance + counts-domain requirement, got: {msg}"
3201 );
3202 }
3203
3204 #[test]
3214 fn test_spatial_map_reports_solver_mismatch_before_fit_range_gate() {
3215 use ndarray::Array3;
3216 let rd = u238_single_resonance();
3217 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3218 let (sample, _ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
3219 let flux: Array3<f64> = Array3::from_elem((energies.len(), 4, 4), 1000.0);
3220 let background: Array3<f64> = Array3::from_elem((energies.len(), 4, 4), 0.0);
3221 let data = InputData3D::CountsWithNuisance {
3222 sample_counts: sample.view(),
3223 flux: flux.view(),
3224 background: background.view(),
3225 };
3226 let config = UnifiedFitConfig::new(
3234 energies,
3235 vec![rd],
3236 vec!["U-238".into()],
3237 0.0,
3238 None,
3239 vec![0.001],
3240 )
3241 .unwrap()
3242 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
3243 .with_fit_energy_range(Some((5.0, 5.05)))
3244 .unwrap();
3245
3246 let err = spatial_map_typed(&data, &config, None, None, None)
3247 .expect_err("CountsWithNuisance + LM + narrow fit_energy_range must be rejected");
3248 let msg = err.to_string();
3249 assert!(
3250 matches!(err, PipelineError::InvalidParameter(_)),
3251 "expected InvalidParameter, got {err:?}"
3252 );
3253 assert!(
3254 msg.contains("CountsWithNuisance") && msg.contains("counts-domain"),
3255 "error must surface the solver mismatch (not the fit-range gate), got: {msg}"
3256 );
3257 assert!(
3258 !msg.contains("active bin"),
3259 "error must not be the downstream fit-range diagnostic, got: {msg}"
3260 );
3261 }
3262
3263 #[test]
3270 fn test_spatial_map_typed_counts_kl_populates_deviance_per_dof_map() {
3271 let data = u238_single_resonance();
3272 let true_density = 0.0005;
3273 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
3274 let (t_3d, _) = synthetic_4x4_transmission(&data, true_density, &energies);
3275 let n_e = energies.len();
3276
3277 let c_val = 2.0_f64;
3279 let lam_ob = 500.0_f64;
3280 let mut sample = Array3::zeros((n_e, 4, 4));
3281 let mut open_beam = Array3::from_elem((n_e, 4, 4), lam_ob);
3282 for y in 0..4 {
3283 for x in 0..4 {
3284 for (i, _) in energies.iter().enumerate() {
3285 open_beam[[i, y, x]] = lam_ob;
3286 sample[[i, y, x]] = c_val * lam_ob * t_3d[[i, y, x]];
3287 }
3288 }
3289 }
3290
3291 let config = UnifiedFitConfig::new(
3292 energies,
3293 vec![data],
3294 vec!["U-238".into()],
3295 0.0,
3296 None,
3297 vec![0.001],
3298 )
3299 .unwrap()
3300 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
3301 .with_counts_background(crate::pipeline::CountsBackgroundConfig {
3302 c: c_val,
3303 ..Default::default()
3304 });
3305
3306 let input = InputData3D::Counts {
3307 sample_counts: sample.view(),
3308 open_beam_counts: open_beam.view(),
3309 };
3310 let r = spatial_map_typed(&input, &config, None, None, None).unwrap();
3311 let dpd = r
3313 .deviance_per_dof_map
3314 .as_ref()
3315 .expect("counts-KL spatial should populate deviance_per_dof_map");
3316 assert_eq!(dpd.shape(), &[4, 4]);
3317 let sample_val = dpd[[0, 0]];
3318 assert!(
3319 sample_val.is_finite(),
3320 "deviance_per_dof_map[0,0] = {sample_val} (should be finite)"
3321 );
3322 let density_mean: f64 = r.density_maps[0].iter().copied().sum::<f64>() / 16.0;
3324 assert!(
3325 (density_mean - true_density).abs() / true_density < 0.05,
3326 "mean density {density_mean} vs truth {true_density}",
3327 );
3328 }
3329
3330 #[test]
3336 fn test_apply_spatial_polish_default_multi_pixel_auto_disables() {
3337 let data = u238_single_resonance();
3340 let energies: Vec<f64> = (0..10).map(|i| 1.0 + i as f64).collect();
3341 let cfg = UnifiedFitConfig::new(
3342 energies,
3343 vec![data],
3344 vec!["U-238".into()],
3345 0.0,
3346 None,
3347 vec![0.001],
3348 )
3349 .unwrap();
3350
3351 assert_eq!(cfg.counts_enable_polish(), None);
3353 let resolved = apply_spatial_polish_default(cfg.clone(), 16);
3354 assert_eq!(
3355 resolved.counts_enable_polish(),
3356 Some(false),
3357 "multi-pixel with no override should auto-disable polish"
3358 );
3359
3360 let resolved = apply_spatial_polish_default(cfg.clone(), 1);
3362 assert_eq!(
3363 resolved.counts_enable_polish(),
3364 None,
3365 "single-pixel should preserve the caller's unset state"
3366 );
3367
3368 let cfg_forced_on = cfg.clone().with_counts_enable_polish(Some(true));
3370 let resolved = apply_spatial_polish_default(cfg_forced_on, 16);
3371 assert_eq!(
3372 resolved.counts_enable_polish(),
3373 Some(true),
3374 "caller override Some(true) must be preserved for multi-pixel"
3375 );
3376
3377 let cfg_forced_off = cfg.with_counts_enable_polish(Some(false));
3379 let resolved = apply_spatial_polish_default(cfg_forced_off, 16);
3380 assert_eq!(resolved.counts_enable_polish(), Some(false));
3381 }
3382
3383 #[test]
3388 fn test_spatial_map_typed_counts_kl_populates_map_without_polish_regression() {
3389 let data = u238_single_resonance();
3390 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.1).collect();
3391 let (t_3d, _) = synthetic_4x4_transmission(&data, 0.0005, &energies);
3392 let n_e = energies.len();
3393
3394 let mut sample = Array3::zeros((n_e, 4, 4));
3395 let open_beam = Array3::from_elem((n_e, 4, 4), 500.0);
3396 for y in 0..4 {
3397 for x in 0..4 {
3398 for i in 0..n_e {
3399 sample[[i, y, x]] = 500.0 * t_3d[[i, y, x]];
3400 }
3401 }
3402 }
3403
3404 let config = UnifiedFitConfig::new(
3405 energies,
3406 vec![data],
3407 vec!["U-238".into()],
3408 0.0,
3409 None,
3410 vec![0.001],
3411 )
3412 .unwrap()
3413 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
3414
3415 let input = InputData3D::Counts {
3416 sample_counts: sample.view(),
3417 open_beam_counts: open_beam.view(),
3418 };
3419 let r = spatial_map_typed(&input, &config, None, None, None).unwrap();
3420 assert!(r.deviance_per_dof_map.is_some());
3421 let dpd = r.deviance_per_dof_map.as_ref().unwrap();
3423 assert!(dpd.iter().all(|v| v.is_finite()));
3424 }
3425
3426 #[test]
3431 fn test_spatial_map_typed_counts_lm_no_deviance_map() {
3432 let data = u238_single_resonance();
3433 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.1).collect();
3434 let (t_3d, _) = synthetic_4x4_transmission(&data, 0.0005, &energies);
3435 let n_e = energies.len();
3436 let mut sample = Array3::zeros((n_e, 4, 4));
3437 let open_beam = Array3::from_elem((n_e, 4, 4), 500.0);
3438 for y in 0..4 {
3439 for x in 0..4 {
3440 for i in 0..n_e {
3441 sample[[i, y, x]] = 500.0 * t_3d[[i, y, x]];
3442 }
3443 }
3444 }
3445
3446 let config = UnifiedFitConfig::new(
3447 energies,
3448 vec![data],
3449 vec!["U-238".into()],
3450 0.0,
3451 None,
3452 vec![0.001],
3453 )
3454 .unwrap()
3455 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
3458
3459 let input = InputData3D::Counts {
3460 sample_counts: sample.view(),
3461 open_beam_counts: open_beam.view(),
3462 };
3463 let r = spatial_map_typed(&input, &config, None, None, None).unwrap();
3464 assert!(
3465 r.deviance_per_dof_map.is_none(),
3466 "(Counts, LM) must not allocate deviance_per_dof_map (would mislabel GOF in GUI)"
3467 );
3468 assert!(r.chi_squared_map.iter().any(|v| v.is_finite()));
3470 }
3471
3472 #[test]
3475 fn test_spatial_map_typed_transmission_no_deviance_map() {
3476 let data = u238_single_resonance();
3477 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.1).collect();
3478 let (t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
3479
3480 let config = UnifiedFitConfig::new(
3481 energies,
3482 vec![data],
3483 vec!["U-238".into()],
3484 0.0,
3485 None,
3486 vec![0.001],
3487 )
3488 .unwrap();
3489 let input = InputData3D::Transmission {
3490 transmission: t_3d.view(),
3491 uncertainty: u_3d.view(),
3492 };
3493 let r = spatial_map_typed(&input, &config, None, None, None).unwrap();
3494 assert!(r.deviance_per_dof_map.is_none());
3495 }
3496
3497 #[test]
3504 fn test_spatial_map_typed_fit_energy_scale_populates_maps() {
3505 let rd = u238_single_resonance();
3506 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
3507 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3508 let data = InputData3D::Transmission {
3509 transmission: t_3d.view(),
3510 uncertainty: u_3d.view(),
3511 };
3512 let config = UnifiedFitConfig::new(
3513 energies,
3514 vec![rd],
3515 vec!["U-238".into()],
3516 0.0,
3517 None,
3518 vec![0.0005],
3519 )
3520 .unwrap()
3521 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
3522 .with_energy_scale(0.0, 1.0, 25.0);
3523
3524 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
3525 let t0_map = result
3526 .t0_us_map
3527 .as_ref()
3528 .expect("t0_us_map must be Some when fit_energy_scale=true");
3529 let l_map = result
3530 .l_scale_map
3531 .as_ref()
3532 .expect("l_scale_map must be Some when fit_energy_scale=true");
3533 assert_eq!(t0_map.shape(), [4, 4]);
3534 assert_eq!(l_map.shape(), [4, 4]);
3535 for y in 0..4 {
3543 for x in 0..4 {
3544 let converged = result.converged_map[[y, x]];
3545 let t0 = t0_map[[y, x]];
3546 let ls = l_map[[y, x]];
3547 if converged {
3548 assert!(
3549 t0.is_finite() && ls.is_finite(),
3550 "converged pixel ({y},{x}) must have finite t0/L, got t0={t0}, L={ls}"
3551 );
3552 } else {
3553 assert!(
3554 t0.is_nan() && ls.is_nan(),
3555 "un-converged pixel ({y},{x}) must have NaN t0/L (B1 gating), got t0={t0}, L={ls}"
3556 );
3557 }
3558 }
3559 }
3560 }
3561
3562 #[test]
3564 fn test_spatial_map_typed_no_energy_scale_no_maps() {
3565 let rd = u238_single_resonance();
3566 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3567 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3568 let data = InputData3D::Transmission {
3569 transmission: t_3d.view(),
3570 uncertainty: u_3d.view(),
3571 };
3572 let config = UnifiedFitConfig::new(
3573 energies,
3574 vec![rd],
3575 vec!["U-238".into()],
3576 0.0,
3577 None,
3578 vec![0.0005],
3579 )
3580 .unwrap()
3581 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
3582
3583 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
3584 assert!(result.t0_us_map.is_none());
3585 assert!(result.l_scale_map.is_none());
3586 }
3587
3588 #[test]
3593 fn test_spatial_map_typed_rejects_counts_lm_with_energy_scale() {
3594 let rd = u238_single_resonance();
3595 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3596 let (sample, ob) = synthetic_4x4_counts(&rd, 0.001, &energies, 1000.0);
3597 let data = InputData3D::Counts {
3598 sample_counts: sample.view(),
3599 open_beam_counts: ob.view(),
3600 };
3601 let config = UnifiedFitConfig::new(
3602 energies,
3603 vec![rd],
3604 vec!["U-238".into()],
3605 0.0,
3606 None,
3607 vec![0.0005],
3608 )
3609 .unwrap()
3610 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
3611 .with_energy_scale(0.0, 1.0, 25.0);
3612
3613 let err = spatial_map_typed(&data, &config, None, None, None)
3614 .expect_err("LM + counts + fit_energy_scale must be rejected");
3615 let msg = err.to_string();
3616 assert!(
3617 msg.contains("fit_energy_scale") && msg.contains("lm"),
3618 "error message should name both culprits, got: {msg}"
3619 );
3620 assert!(
3621 msg.contains("#458"),
3622 "error message should reference the tracking issue, got: {msg}"
3623 );
3624 }
3625
3626 #[test]
3629 fn test_spatial_map_typed_allows_counts_kl_with_energy_scale() {
3630 let rd = u238_single_resonance();
3631 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3632 let (sample, ob) = synthetic_4x4_counts(&rd, 0.001, &energies, 1000.0);
3633 let data = InputData3D::Counts {
3634 sample_counts: sample.view(),
3635 open_beam_counts: ob.view(),
3636 };
3637 let config = UnifiedFitConfig::new(
3638 energies,
3639 vec![rd],
3640 vec!["U-238".into()],
3641 0.0,
3642 None,
3643 vec![0.0005],
3644 )
3645 .unwrap()
3646 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
3647 .with_energy_scale(0.0, 1.0, 25.0);
3648
3649 let result = spatial_map_typed(&data, &config, None, None, None)
3650 .expect("KL + counts + fit_energy_scale must be allowed");
3651 assert!(result.t0_us_map.is_some());
3652 }
3653
3654 #[test]
3661 fn test_spatial_map_typed_rejects_energy_scale_with_temperature() {
3662 let rd = u238_single_resonance();
3663 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3664 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3665 let data = InputData3D::Transmission {
3666 transmission: t_3d.view(),
3667 uncertainty: u_3d.view(),
3668 };
3669 let config = UnifiedFitConfig::new(
3670 energies,
3671 vec![rd],
3672 vec!["U-238".into()],
3673 300.0,
3674 None,
3675 vec![0.0005],
3676 )
3677 .unwrap()
3678 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
3679 .with_fit_temperature(true)
3680 .with_energy_scale(0.0, 1.0, 25.0);
3681
3682 let err = spatial_map_typed(&data, &config, None, None, None)
3683 .expect_err("fit_energy_scale + fit_temperature must be rejected");
3684 let msg = err.to_string();
3685 assert!(
3686 msg.contains("fit_energy_scale") && msg.contains("fit_temperature"),
3687 "error message should name both culprits, got: {msg}"
3688 );
3689 }
3690
3691 #[test]
3697 fn test_spatial_map_typed_allows_transmission_lm_with_energy_scale() {
3698 let rd = u238_single_resonance();
3699 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3700 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3701 let data = InputData3D::Transmission {
3702 transmission: t_3d.view(),
3703 uncertainty: u_3d.view(),
3704 };
3705 let config = UnifiedFitConfig::new(
3706 energies,
3707 vec![rd],
3708 vec!["U-238".into()],
3709 0.0,
3710 None,
3711 vec![0.0005],
3712 )
3713 .unwrap()
3714 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
3715 .with_energy_scale(0.0, 1.0, 25.0);
3716
3717 let result = spatial_map_typed(&data, &config, None, None, None)
3718 .expect("LM + transmission + fit_energy_scale must be allowed");
3719 assert!(result.t0_us_map.is_some());
3720 }
3721
3722 #[test]
3734 fn test_spatial_map_rejects_fit_temperature_below_one_up_front() {
3735 let rd = u238_single_resonance();
3736 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3737 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3738 let data = InputData3D::Transmission {
3739 transmission: t_3d.view(),
3740 uncertainty: u_3d.view(),
3741 };
3742 let config = UnifiedFitConfig::new(
3746 energies,
3747 vec![rd],
3748 vec!["U-238".into()],
3749 0.5,
3750 None,
3751 vec![0.001],
3752 )
3753 .unwrap()
3754 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
3755 .with_fit_temperature(true);
3756
3757 let err = spatial_map_typed(&data, &config, None, None, None)
3758 .expect_err("fit_temperature with temperature_k < 1.0 must be rejected up-front");
3759 let msg = err.to_string();
3760 assert!(
3761 matches!(err, PipelineError::InvalidParameter(_)),
3762 "expected InvalidParameter, got {err:?}"
3763 );
3764 assert!(
3765 msg.contains("temperature") && msg.contains("1.0"),
3766 "error must mention the 1.0 K floor, got: {msg}"
3767 );
3768 }
3769
3770 #[test]
3771 fn test_spatial_map_transmission_poisson_rejects_fit_energy_range_up_front() {
3772 let rd = u238_single_resonance();
3773 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3774 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3775 let data = InputData3D::Transmission {
3776 transmission: t_3d.view(),
3777 uncertainty: u_3d.view(),
3778 };
3779 let config = UnifiedFitConfig::new(
3784 energies,
3785 vec![rd],
3786 vec!["U-238".into()],
3787 0.0,
3788 None,
3789 vec![0.001],
3790 )
3791 .unwrap()
3792 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
3793 .with_fit_energy_range(Some((2.0, 8.0)))
3794 .unwrap();
3795
3796 let err = spatial_map_typed(&data, &config, None, None, None)
3797 .expect_err("transmission + Poisson-KL + fit_energy_range must be rejected up-front");
3798 let msg = err.to_string();
3799 assert!(
3800 matches!(err, PipelineError::InvalidParameter(_)),
3801 "expected InvalidParameter, got {err:?}"
3802 );
3803 assert!(
3804 msg.contains("fit_energy_range") && msg.contains("Poisson-KL"),
3805 "error must name the incompatibility, got: {msg}"
3806 );
3807 }
3808
3809 #[test]
3810 fn test_spatial_map_lm_rejects_too_narrow_fit_energy_range_up_front() {
3811 let rd = u238_single_resonance();
3812 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3814 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3815 let data = InputData3D::Transmission {
3816 transmission: t_3d.view(),
3817 uncertainty: u_3d.view(),
3818 };
3819 let config = UnifiedFitConfig::new(
3822 energies,
3823 vec![rd],
3824 vec!["U-238".into()],
3825 0.0,
3826 None,
3827 vec![0.001],
3828 )
3829 .unwrap()
3830 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
3831 .with_fit_energy_range(Some((5.0, 5.05)))
3832 .unwrap();
3833
3834 let err = spatial_map_typed(&data, &config, None, None, None)
3835 .expect_err("LM with too-narrow fit_energy_range must be rejected up-front");
3836 let msg = err.to_string();
3837 assert!(
3838 matches!(err, PipelineError::InvalidParameter(_)),
3839 "expected InvalidParameter, got {err:?}"
3840 );
3841 assert!(
3842 msg.contains("active bin") && msg.contains("LM transmission"),
3843 "error must mention narrow active-bin count for the LM path, got: {msg}"
3844 );
3845 }
3846
3847 #[test]
3848 fn test_spatial_map_counts_kl_rejects_too_narrow_fit_energy_range_up_front() {
3849 let rd = u238_single_resonance();
3850 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3851 let (sample, ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
3852 let data = InputData3D::Counts {
3853 sample_counts: sample.view(),
3854 open_beam_counts: ob.view(),
3855 };
3856 let config = UnifiedFitConfig::new(
3857 energies,
3858 vec![rd],
3859 vec!["U-238".into()],
3860 0.0,
3861 None,
3862 vec![0.001],
3863 )
3864 .unwrap()
3865 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
3866 .with_fit_energy_range(Some((5.0, 5.05)))
3867 .unwrap();
3868
3869 let err = spatial_map_typed(&data, &config, None, None, None)
3870 .expect_err("counts-KL with too-narrow fit_energy_range must be rejected up-front");
3871 let msg = err.to_string();
3872 assert!(
3873 matches!(err, PipelineError::InvalidParameter(_)),
3874 "expected InvalidParameter, got {err:?}"
3875 );
3876 assert!(
3877 msg.contains("active bin") && msg.contains("joint-Poisson"),
3878 "error must mention narrow active-bin count for the joint-Poisson path, got: {msg}"
3879 );
3880 }
3881
3882 #[test]
3883 fn test_spatial_map_counts_kl_rejects_invalid_c_up_front() {
3884 let rd = u238_single_resonance();
3885 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3886 let (sample, ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
3887 let data = InputData3D::Counts {
3888 sample_counts: sample.view(),
3889 open_beam_counts: ob.view(),
3890 };
3891 let config = UnifiedFitConfig::new(
3896 energies,
3897 vec![rd],
3898 vec!["U-238".into()],
3899 0.0,
3900 None,
3901 vec![0.001],
3902 )
3903 .unwrap()
3904 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
3905 .with_counts_background(crate::pipeline::CountsBackgroundConfig {
3906 c: -1.0,
3907 ..Default::default()
3908 });
3909
3910 let err = spatial_map_typed(&data, &config, None, None, None)
3911 .expect_err("counts-KL with non-positive c must be rejected up-front");
3912 let msg = err.to_string();
3913 assert!(
3914 matches!(err, PipelineError::InvalidParameter(_)),
3915 "expected InvalidParameter, got {err:?}"
3916 );
3917 assert!(
3918 msg.contains("finite c > 0"),
3919 "error must mention the c > 0 requirement, got: {msg}"
3920 );
3921 }
3922
3923 #[test]
3924 fn test_spatial_map_counts_kl_requires_back_a_for_back_b_c_up_front() {
3925 let rd = u238_single_resonance();
3926 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3927 let (sample, ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
3928 let data = InputData3D::Counts {
3929 sample_counts: sample.view(),
3930 open_beam_counts: ob.view(),
3931 };
3932 let bg = crate::pipeline::BackgroundConfig {
3937 fit_back_a: false,
3938 fit_back_b: true,
3939 fit_back_c: false,
3940 fit_back_d: false,
3941 fit_back_f: false,
3942 ..crate::pipeline::BackgroundConfig::default()
3943 };
3944 let config = UnifiedFitConfig::new(
3945 energies,
3946 vec![rd],
3947 vec!["U-238".into()],
3948 0.0,
3949 None,
3950 vec![0.001],
3951 )
3952 .unwrap()
3953 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
3954 .with_transmission_background(bg);
3955
3956 let err = spatial_map_typed(&data, &config, None, None, None)
3957 .expect_err("counts-KL with B_B but no B_A must be rejected up-front");
3958 let msg = err.to_string();
3959 assert!(
3960 matches!(err, PipelineError::InvalidParameter(_)),
3961 "expected InvalidParameter, got {err:?}"
3962 );
3963 assert!(
3964 msg.contains("B_A") && msg.contains("fit_back_a"),
3965 "error must name the B_A requirement, got: {msg}"
3966 );
3967 }
3968
3969 #[test]
3979 fn test_spatial_map_rejects_underdetermined_fit_range() {
3980 let rd = u238_single_resonance();
3981 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3982 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3983 let data = InputData3D::Transmission {
3984 transmission: t_3d.view(),
3985 uncertainty: u_3d.view(),
3986 };
3987 let bg = crate::pipeline::BackgroundConfig::default();
3997 let config = UnifiedFitConfig::new(
3998 energies,
3999 vec![rd],
4000 vec!["U-238".into()],
4001 293.0,
4005 None,
4006 vec![0.001],
4007 )
4008 .unwrap()
4009 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4010 .with_fit_temperature(true)
4011 .with_transmission_background(bg)
4012 .with_fit_energy_range(Some((5.0, 5.5)))
4013 .unwrap();
4014
4015 let err = spatial_map_typed(&data, &config, None, None, None)
4016 .expect_err("underdetermined fit_energy_range must be rejected up-front");
4017 let msg = err.to_string();
4018 assert!(
4019 matches!(err, PipelineError::InvalidParameter(_)),
4020 "expected InvalidParameter, got {err:?}"
4021 );
4022 assert!(
4026 msg.contains("active bin")
4027 && msg.contains("free parameter")
4028 && msg.contains("underdetermined"),
4029 "error must explain the underdetermined condition, got: {msg}"
4030 );
4031 }
4032
4033 fn lm_transmission_config(
4043 energies: Vec<f64>,
4044 data: nereids_endf::resonance::ResonanceData,
4045 ) -> UnifiedFitConfig {
4046 UnifiedFitConfig::new(
4047 energies,
4048 vec![data],
4049 vec!["U-238".into()],
4050 0.0,
4051 None,
4052 vec![0.001],
4053 )
4054 .unwrap()
4055 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4056 }
4057
4058 fn kl_counts_config(
4059 energies: Vec<f64>,
4060 data: nereids_endf::resonance::ResonanceData,
4061 ) -> UnifiedFitConfig {
4062 UnifiedFitConfig::new(
4063 energies,
4064 vec![data],
4065 vec!["U-238".into()],
4066 0.0,
4067 None,
4068 vec![0.001],
4069 )
4070 .unwrap()
4071 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4072 }
4073
4074 #[test]
4075 fn test_spatial_rejects_bad_transmission_value() {
4076 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4077 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
4078 let data = u238_single_resonance();
4079 let (mut t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
4080 t_3d[[10, 1, 2]] = bad;
4081 let config = lm_transmission_config(energies.clone(), data);
4082 let input = InputData3D::Transmission {
4083 transmission: t_3d.view(),
4084 uncertainty: u_3d.view(),
4085 };
4086 let err = spatial_map_typed(&input, &config, None, None, None)
4087 .expect_err("non-finite transmission value must be rejected up-front");
4088 assert!(
4089 matches!(err, PipelineError::InvalidParameter(_)),
4090 "got {err:?}"
4091 );
4092 let msg = err.to_string();
4093 assert!(
4094 msg.contains("transmission") && msg.contains("(y="),
4095 "error must name the cube and (y, x, e): {msg}"
4096 );
4097 }
4098 }
4099
4100 #[test]
4101 fn test_spatial_rejects_bad_uncertainty() {
4102 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4106 for bad in [f64::NAN, f64::INFINITY, 0.0, -1.0] {
4107 let data = u238_single_resonance();
4108 let (t_3d, mut u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
4109 u_3d[[9, 1, 0]] = bad;
4110 let config = lm_transmission_config(energies.clone(), data);
4111 let input = InputData3D::Transmission {
4112 transmission: t_3d.view(),
4113 uncertainty: u_3d.view(),
4114 };
4115 let err = spatial_map_typed(&input, &config, None, None, None)
4116 .expect_err("bad uncertainty must be rejected up-front");
4117 assert!(
4118 matches!(err, PipelineError::InvalidParameter(_)),
4119 "got {err:?}"
4120 );
4121 assert!(
4122 err.to_string().contains("uncertainty"),
4123 "error must name the uncertainty cube, got: {err}"
4124 );
4125 }
4126 }
4127
4128 #[test]
4129 fn test_spatial_accepts_negative_transmission_value() {
4130 let data = u238_single_resonance();
4133 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4134 let (mut t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
4135 t_3d[[12, 2, 2]] = -0.05;
4136 let config = lm_transmission_config(energies, data);
4137 let input = InputData3D::Transmission {
4138 transmission: t_3d.view(),
4139 uncertainty: u_3d.view(),
4140 };
4141 let result = spatial_map_typed(&input, &config, None, None, None)
4142 .expect("a finite negative transmission value must not be rejected");
4143 assert_eq!(result.n_total, 16);
4144 }
4145
4146 #[test]
4147 fn test_spatial_rejects_bad_sample_counts() {
4148 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4149 for bad in [f64::NAN, f64::INFINITY, -1.0] {
4150 let data = u238_single_resonance();
4151 let (mut sample, ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4152 sample[[8, 0, 3]] = bad;
4153 let config = kl_counts_config(energies.clone(), data);
4154 let input = InputData3D::Counts {
4155 sample_counts: sample.view(),
4156 open_beam_counts: ob.view(),
4157 };
4158 let err = spatial_map_typed(&input, &config, None, None, None)
4159 .expect_err("bad sample count must be rejected up-front");
4160 assert!(
4161 matches!(err, PipelineError::InvalidParameter(_)),
4162 "got {err:?}"
4163 );
4164 assert!(
4165 err.to_string().contains("sample_counts"),
4166 "error must name the sample_counts cube, got: {err}"
4167 );
4168 }
4169 }
4170
4171 #[test]
4172 fn test_spatial_rejects_bad_open_beam() {
4173 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4177 for bad in [f64::NAN, f64::INFINITY, -1.0] {
4178 let data = u238_single_resonance();
4179 let (sample, mut ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4180 ob[[6, 3, 1]] = bad;
4181 let config = kl_counts_config(energies.clone(), data);
4182 let input = InputData3D::Counts {
4183 sample_counts: sample.view(),
4184 open_beam_counts: ob.view(),
4185 };
4186 let err = spatial_map_typed(&input, &config, None, None, None)
4187 .expect_err("bad open-beam must be rejected up-front");
4188 assert!(
4189 matches!(err, PipelineError::InvalidParameter(_)),
4190 "got {err:?}"
4191 );
4192 assert!(
4193 err.to_string().contains("open_beam_counts"),
4194 "error must name the open_beam_counts cube, got: {err}"
4195 );
4196 }
4197 }
4198
4199 #[test]
4200 fn test_spatial_counts_with_nuisance_rejects_bad_flux() {
4201 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4202 for bad in [f64::NAN, -1.0] {
4203 let data = u238_single_resonance();
4204 let (sample, _ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4205 let mut flux = Array3::from_elem((energies.len(), 4, 4), 1000.0);
4206 let background = Array3::from_elem((energies.len(), 4, 4), 0.0);
4207 flux[[4, 2, 1]] = bad;
4208 let config = kl_counts_config(energies.clone(), data);
4209 let input = InputData3D::CountsWithNuisance {
4210 sample_counts: sample.view(),
4211 flux: flux.view(),
4212 background: background.view(),
4213 };
4214 let err = spatial_map_typed(&input, &config, None, None, None)
4215 .expect_err("bad flux must be rejected up-front");
4216 assert!(
4217 matches!(err, PipelineError::InvalidParameter(_)),
4218 "got {err:?}"
4219 );
4220 assert!(
4221 err.to_string().contains("flux"),
4222 "error must name the flux cube, got: {err}"
4223 );
4224 }
4225 }
4226
4227 #[test]
4228 fn test_spatial_counts_with_nuisance_rejects_nonfinite_background() {
4229 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4233 for bad in [f64::NAN, f64::INFINITY] {
4234 let data = u238_single_resonance();
4235 let (sample, _ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4236 let flux = Array3::from_elem((energies.len(), 4, 4), 1000.0);
4237 let mut background = Array3::from_elem((energies.len(), 4, 4), 0.0);
4238 background[[2, 3, 3]] = bad;
4239 let config = kl_counts_config(energies.clone(), data);
4240 let input = InputData3D::CountsWithNuisance {
4241 sample_counts: sample.view(),
4242 flux: flux.view(),
4243 background: background.view(),
4244 };
4245 let err = spatial_map_typed(&input, &config, None, None, None)
4246 .expect_err("non-finite background must be rejected up-front");
4247 assert!(
4248 matches!(err, PipelineError::InvalidParameter(_)),
4249 "got {err:?}"
4250 );
4251 assert!(
4252 err.to_string().contains("background"),
4253 "error must name the background cube, got: {err}"
4254 );
4255 }
4256 }
4257
4258 #[test]
4259 fn test_spatial_transmission_tolerates_nan_in_inactive_bin() {
4260 let data = u238_single_resonance();
4265 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4266 let (mut t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
4267 t_3d[[0, 1, 1]] = f64::NAN;
4269 let config = lm_transmission_config(energies, data)
4270 .with_fit_energy_range(Some((3.0, 9.0)))
4271 .unwrap();
4272 let input = InputData3D::Transmission {
4273 transmission: t_3d.view(),
4274 uncertainty: u_3d.view(),
4275 };
4276 let result = spatial_map_typed(&input, &config, None, None, None)
4277 .expect("NaN in an inactive (out-of-range) bin must be tolerated");
4278 assert!(
4279 result.n_converged > 0,
4280 "the active-bin fit should still converge"
4281 );
4282 }
4283
4284 #[test]
4285 fn test_spatial_rejects_nan_transmission_in_active_bin_with_range() {
4286 let data = u238_single_resonance();
4289 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4290 let (mut t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
4291 t_3d[[20, 0, 0]] = f64::NAN;
4293 let config = lm_transmission_config(energies, data)
4294 .with_fit_energy_range(Some((3.0, 9.0)))
4295 .unwrap();
4296 let input = InputData3D::Transmission {
4297 transmission: t_3d.view(),
4298 uncertainty: u_3d.view(),
4299 };
4300 let err = spatial_map_typed(&input, &config, None, None, None)
4301 .expect_err("NaN in an active bin must be rejected up-front");
4302 assert!(
4303 matches!(err, PipelineError::InvalidParameter(_)),
4304 "got {err:?}"
4305 );
4306 assert!(
4307 err.to_string().contains("transmission"),
4308 "error must name the transmission cube, got: {err}"
4309 );
4310 }
4311
4312 #[test]
4313 fn test_spatial_accepts_bad_value_in_dead_pixel() {
4314 let data = u238_single_resonance();
4317 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4318 let (mut sample, ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4319 sample[[5, 0, 0]] = f64::NAN;
4320 let config = kl_counts_config(energies, data);
4321 let mut dead = Array2::from_elem((4, 4), false);
4322 dead[[0, 0]] = true;
4323 let input = InputData3D::Counts {
4324 sample_counts: sample.view(),
4325 open_beam_counts: ob.view(),
4326 };
4327 let result = spatial_map_typed(&input, &config, Some(&dead), None, None)
4328 .expect("a bad value in a dead-masked pixel must be tolerated");
4329 assert!(
4330 result.n_converged > 0,
4331 "the remaining live pixels should still fit"
4332 );
4333 }
4334
4335 #[test]
4336 fn test_spatial_accepts_zero_counts_and_open_beam() {
4337 let data = u238_single_resonance();
4340 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4341 let (mut sample, mut ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4342 sample[[3, 2, 2]] = 0.0;
4343 ob[[7, 1, 1]] = 0.0;
4344 let config = kl_counts_config(energies, data);
4345 let input = InputData3D::Counts {
4346 sample_counts: sample.view(),
4347 open_beam_counts: ob.view(),
4348 };
4349 let result = spatial_map_typed(&input, &config, None, None, None)
4350 .expect("zero counts / zero open-beam are legitimate and must not be rejected");
4351 assert_eq!(result.n_total, 16);
4352 }
4353
4354 #[test]
4355 fn test_spatial_rejects_open_beam_flux_overflow() {
4356 let data = u238_single_resonance();
4363 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4364 let (sample, mut ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4365 for y in 0..4 {
4366 for x in 0..4 {
4367 ob[[5, y, x]] = f64::MAX;
4368 }
4369 }
4370 let config = kl_counts_config(energies, data);
4371 let input = InputData3D::Counts {
4372 sample_counts: sample.view(),
4373 open_beam_counts: ob.view(),
4374 };
4375 let err = spatial_map_typed(&input, &config, None, None, None)
4376 .expect_err("an overflowing averaged open-beam flux must be rejected up-front");
4377 assert!(
4378 matches!(err, PipelineError::InvalidParameter(_)),
4379 "got {err:?}"
4380 );
4381 assert!(
4382 err.to_string().contains("averaged open-beam flux"),
4383 "error must name the averaged-flux overflow, got: {err}"
4384 );
4385 }
4386}