1use ndarray::{Array2, Array3, 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>>,
76 pub isotope_labels: Vec<String>,
80 pub anorm_map: Option<Array2<f64>>,
83 pub background_maps: Option<[Array2<f64>; 3]>,
99 pub back_d_map: Option<Array2<f64>>,
107 pub back_f_map: Option<Array2<f64>>,
115 pub t0_us_map: Option<Array2<f64>>,
119 pub l_scale_map: Option<Array2<f64>>,
123 pub energy_scale_flight_path_m: Option<f64>,
130 pub baseline_global: Option<[f64; 3]>,
138 pub baseline_e_ref_ev: Option<f64>,
144 pub baseline_maps: Option<[Array2<f64>; 3]>,
149 pub warnings: Vec<String>,
154 pub n_converged: usize,
156 pub n_total: usize,
158 pub n_failed: usize,
162}
163
164use crate::pipeline::{
167 InputData, MultiplicativeBaselineConfig, SolverConfig, UnifiedFitConfig, count_free_params,
168 degenerate_normalization_warning, fit_spectrum_typed, required_active_bins,
169 validate_multiplicative_baseline, validate_transmission_background,
170};
171
172#[derive(Debug)]
177pub enum InputData3D<'a> {
178 Transmission {
180 transmission: ArrayView3<'a, f64>,
181 uncertainty: ArrayView3<'a, f64>,
182 },
183 Counts {
185 sample_counts: ArrayView3<'a, f64>,
186 open_beam_counts: ArrayView3<'a, f64>,
187 },
188 CountsWithNuisance {
190 sample_counts: ArrayView3<'a, f64>,
191 flux: ArrayView3<'a, f64>,
192 background: ArrayView3<'a, f64>,
193 },
194}
195
196impl InputData3D<'_> {
197 pub(crate) fn shape(&self) -> (usize, usize, usize) {
199 let s = match self {
200 Self::Transmission { transmission, .. } => transmission.shape(),
201 Self::Counts { sample_counts, .. } => sample_counts.shape(),
202 Self::CountsWithNuisance { sample_counts, .. } => sample_counts.shape(),
203 };
204 (s[0], s[1], s[2])
205 }
206
207 pub fn is_counts(&self) -> bool {
211 matches!(self, Self::Counts { .. } | Self::CountsWithNuisance { .. })
212 }
213}
214
215fn apply_spatial_polish_default(config: UnifiedFitConfig, n_pixels: usize) -> UnifiedFitConfig {
241 if n_pixels > 1 && config.counts_enable_polish().is_none() {
242 config.with_counts_enable_polish(Some(false))
243 } else {
244 config
245 }
246}
247
248fn validate_spatial_fit_preflight(
267 input: &InputData3D<'_>,
268 config: &UnifiedFitConfig,
269) -> Result<(), PipelineError> {
270 if config.fit_temperature() && config.temperature_k() < 1.0 {
276 return Err(PipelineError::InvalidParameter(format!(
277 "temperature must be >= 1.0 K when fit_temperature is true, got {}",
278 config.temperature_k(),
279 )));
280 }
281
282 if count_free_params(config) == 0 {
288 return Err(PipelineError::InvalidParameter(
289 "no free parameters to fit: all densities are frozen and no other \
290 parameter is free — free at least one density (with_density_free) \
291 or enable fit_temperature / energy-scale / background"
292 .into(),
293 ));
294 }
295
296 if let Some(bl) = config.multiplicative_baseline()
307 && bl.spatial_global
308 {
309 let n_baseline_free =
310 usize::from(bl.fit_b0) + usize::from(bl.fit_b1) + usize::from(bl.fit_b2);
311 if count_free_params(config) == n_baseline_free {
312 return Err(PipelineError::InvalidParameter(
313 "global multiplicative baseline (spatial_global = true) is the \
314 only free parameter block: after stage 1 freezes the fitted \
315 baseline, the per-pixel fits would have nothing left to fit. \
316 Free at least one per-pixel parameter (density / temperature / \
317 energy scale / background), fit the aggregated spectrum with a \
318 single-spectrum fitter instead, or set spatial_global = false \
319 to fit per-pixel baselines."
320 .into(),
321 ));
322 }
323 }
324
325 let is_counts = input.is_counts();
330 let is_kl = matches!(config.solver(), SolverConfig::PoissonKL(_))
331 || (matches!(config.solver(), SolverConfig::Auto) && is_counts);
332
333 if !is_counts && is_kl && config.fit_energy_range().is_some() {
341 return Err(PipelineError::InvalidParameter(
342 "fit_energy_range is not supported for the transmission + \
343 Poisson-KL solver path. Use joint-Poisson (provide sample + \
344 open-beam counts) or switch to the LM transmission solver."
345 .into(),
346 ));
347 }
348
349 if let Some((e_min, e_max)) = config.fit_energy_range() {
367 let active_mask = nereids_fitting::active_mask::build_active_mask(
368 config.energies(),
369 config.fit_energy_range(),
370 );
371 let n_active = nereids_fitting::active_mask::active_count(
372 active_mask.as_deref(),
373 config.energies().len(),
374 );
375 let required = required_active_bins(config);
376 if n_active < required {
377 let path_msg = if is_counts && is_kl {
382 "joint-Poisson"
383 } else {
384 "LM transmission"
385 };
386 return Err(PipelineError::InvalidParameter(format!(
387 "fit_energy_range [{e_min}, {e_max}] eV selects {n_active} active bin(s) \
388 on the configured energy grid; at least {required} active bin(s) are \
389 required for {path_msg} fitting with {n_free} free parameter(s) \
390 (underdetermined when n_active < n_free)",
391 n_free = count_free_params(config),
392 )));
393 }
394 }
395
396 validate_multiplicative_baseline(config)?;
402
403 if is_counts && is_kl {
412 if let Some(bg) = config.counts_background() {
413 if bg.fit_alpha_1 || bg.fit_alpha_2 {
414 return Err(PipelineError::InvalidParameter(
415 "joint-Poisson solver does not support fit_alpha_1/fit_alpha_2: \
416 the profile lambda-hat absorbs the global flux scale (alpha_1 redundant); \
417 alpha_2 / B_det wiring is not yet implemented."
418 .into(),
419 ));
420 }
421 if !(bg.c.is_finite() && bg.c > 0.0) {
428 return Err(PipelineError::InvalidParameter(format!(
429 "joint-Poisson solver requires finite c > 0 in CountsBackgroundConfig, got {}",
430 bg.c,
431 )));
432 }
433 }
434 if let Some(bg) = config.transmission_background()
435 && (bg.fit_back_b || bg.fit_back_c)
436 && !bg.fit_back_a
437 {
438 return Err(PipelineError::InvalidParameter(
439 "joint-Poisson transmission_background: B_A (fit_back_a) must be \
440 enabled whenever any of B_B / B_C is enabled (A_n alone cannot \
441 absorb a constant offset — benchmarked at −23% density bias)."
442 .into(),
443 ));
444 }
445 }
446
447 Ok(())
448}
449
450#[allow(clippy::too_many_arguments)]
469fn fit_global_baseline_stage1(
470 input: &InputData3D<'_>,
471 fast_config: &UnifiedFitConfig,
472 data_a: &Array3<f64>,
473 data_b: &Array3<f64>,
474 data_c: Option<&Array3<f64>>,
475 pixel_coords: &[(usize, usize)],
476 averaged_flux: Option<&[f64]>,
477) -> Result<[f64; 3], PipelineError> {
478 let n_e = data_a.shape()[2];
479 let n_live = pixel_coords.len() as f64;
480 let mean_over = |cube: &Array3<f64>| -> Vec<f64> {
481 let mut m = vec![0.0f64; n_e];
482 for &(y, x) in pixel_coords {
483 for (e, &v) in cube.slice(s![y, x, ..]).iter().enumerate() {
484 m[e] += v;
485 }
486 }
487 for v in &mut m {
488 *v /= n_live;
489 }
490 m
491 };
492
493 let aggregate = match input {
494 InputData3D::Transmission { .. } => {
495 let mean_t = mean_over(data_a);
496 let mut se = vec![0.0f64; n_e];
498 for &(y, x) in pixel_coords {
499 for (e, &sig) in data_b.slice(s![y, x, ..]).iter().enumerate() {
500 se[e] += sig * sig;
501 }
502 }
503 for v in &mut se {
504 *v = v.sqrt() / n_live;
505 }
506 InputData::Transmission {
507 transmission: mean_t,
508 uncertainty: se,
509 }
510 }
511 InputData3D::Counts { .. } => {
512 let mean_s = mean_over(data_a);
513 let flux = averaged_flux
514 .expect("averaged_flux is Some for InputData3D::Counts")
515 .to_vec();
516 let effective = fast_config.effective_solver(&InputData::Counts {
519 sample_counts: mean_s.clone(),
520 open_beam_counts: flux.clone(),
521 });
522 match effective {
523 SolverConfig::PoissonKL(_) => InputData::CountsWithNuisance {
524 sample_counts: mean_s,
525 flux,
526 background: vec![0.0f64; n_e],
527 },
528 _ => InputData::Counts {
529 sample_counts: mean_s,
530 open_beam_counts: flux,
531 },
532 }
533 }
534 InputData3D::CountsWithNuisance { .. } => InputData::CountsWithNuisance {
535 sample_counts: mean_over(data_a),
536 flux: mean_over(data_b),
537 background: mean_over(data_c.expect("CountsWithNuisance carries a background cube")),
538 },
539 };
540
541 let agg = fit_spectrum_typed(&aggregate, fast_config).map_err(|e| {
542 PipelineError::InvalidParameter(format!(
543 "multiplicative-baseline stage 1 (global fit on the aggregated \
544 mean spectrum) failed: {e}"
545 ))
546 })?;
547 if !agg.converged {
548 return Err(PipelineError::InvalidParameter(
549 "multiplicative-baseline stage 1 did not converge on the \
550 aggregated mean spectrum; refusing to fall back to per-pixel \
551 baselines (at low counts they biased fitted temperatures by up \
552 to +150 K). Check the baseline bounds/inits, or set \
553 spatial_global = false to fit per-pixel baselines explicitly."
554 .into(),
555 ));
556 }
557 Ok(agg
558 .baseline
559 .expect("stage 1 ran with a configured baseline, so the result carries it"))
560}
561
562#[derive(Clone, Copy)]
567enum CubeDomain {
568 Finite,
573 FinitePositive,
579 FiniteNonNegative,
585}
586
587impl CubeDomain {
588 #[inline]
589 fn accepts(self, v: f64) -> bool {
590 match self {
591 CubeDomain::Finite => v.is_finite(),
592 CubeDomain::FinitePositive => v.is_finite() && v > 0.0,
593 CubeDomain::FiniteNonNegative => v.is_finite() && v >= 0.0,
594 }
595 }
596
597 fn describe(self) -> &'static str {
598 match self {
599 CubeDomain::Finite => "finite",
600 CubeDomain::FinitePositive => "finite and > 0",
601 CubeDomain::FiniteNonNegative => "finite and >= 0",
602 }
603 }
604}
605
606fn check_cube(
618 cube: &ArrayView3<'_, f64>,
619 field: &'static str,
620 domain: CubeDomain,
621 live_pixels: &[(usize, usize)],
622 active_mask: Option<&[bool]>,
623) -> Result<(), PipelineError> {
624 let n_energies = cube.shape()[0];
625 for e in 0..n_energies {
626 if active_mask.is_some_and(|m| !m[e]) {
627 continue;
628 }
629 for &(y, x) in live_pixels {
630 let v = cube[[e, y, x]];
631 if !domain.accepts(v) {
632 return Err(PipelineError::InvalidParameter(format!(
633 "{field} at (y={y}, x={x}, e={e}) must be {}, got {v}",
634 domain.describe(),
635 )));
636 }
637 }
638 }
639 Ok(())
640}
641
642fn validate_spatial_data_values(
671 input: &InputData3D<'_>,
672 live_pixels: &[(usize, usize)],
673 active_mask: Option<&[bool]>,
674) -> Result<(), PipelineError> {
675 match input {
676 InputData3D::Transmission {
677 transmission,
678 uncertainty,
679 } => {
680 check_cube(
681 transmission,
682 "transmission",
683 CubeDomain::Finite,
684 live_pixels,
685 active_mask,
686 )?;
687 check_cube(
688 uncertainty,
689 "uncertainty",
690 CubeDomain::FinitePositive,
691 live_pixels,
692 active_mask,
693 )?;
694 }
695 InputData3D::Counts {
696 sample_counts,
697 open_beam_counts,
698 } => {
699 check_cube(
700 sample_counts,
701 "sample_counts",
702 CubeDomain::FiniteNonNegative,
703 live_pixels,
704 None,
705 )?;
706 check_cube(
707 open_beam_counts,
708 "open_beam_counts",
709 CubeDomain::FiniteNonNegative,
710 live_pixels,
711 None,
712 )?;
713 }
714 InputData3D::CountsWithNuisance {
715 sample_counts,
716 flux,
717 background,
718 } => {
719 check_cube(
720 sample_counts,
721 "sample_counts",
722 CubeDomain::FiniteNonNegative,
723 live_pixels,
724 None,
725 )?;
726 check_cube(
727 flux,
728 "flux",
729 CubeDomain::FiniteNonNegative,
730 live_pixels,
731 None,
732 )?;
733 check_cube(
734 background,
735 "background",
736 CubeDomain::Finite,
737 live_pixels,
738 None,
739 )?;
740 }
741 }
742 Ok(())
743}
744
745pub fn spatial_map_typed(
808 input: &InputData3D<'_>,
809 config: &UnifiedFitConfig,
810 dead_pixels: Option<&Array2<bool>>,
811 cancel: Option<&AtomicBool>,
812 progress: Option<&AtomicUsize>,
813) -> Result<SpatialResult, PipelineError> {
814 let (n_energies, height, width) = input.shape();
815 let n_maps = config.n_density_params();
817
818 if n_energies != config.energies().len() {
820 return Err(PipelineError::ShapeMismatch(format!(
821 "input spectral axis ({n_energies}) != config.energies length ({})",
822 config.energies().len(),
823 )));
824 }
825 match input {
826 InputData3D::Transmission {
827 transmission,
828 uncertainty,
829 } => {
830 if uncertainty.shape() != transmission.shape() {
831 return Err(PipelineError::ShapeMismatch(format!(
832 "uncertainty shape {:?} != transmission shape {:?}",
833 uncertainty.shape(),
834 transmission.shape(),
835 )));
836 }
837 }
838 InputData3D::Counts {
839 sample_counts,
840 open_beam_counts,
841 } => {
842 if open_beam_counts.shape() != sample_counts.shape() {
843 return Err(PipelineError::ShapeMismatch(format!(
844 "open_beam shape {:?} != sample shape {:?}",
845 open_beam_counts.shape(),
846 sample_counts.shape(),
847 )));
848 }
849 }
850 InputData3D::CountsWithNuisance {
851 sample_counts,
852 flux,
853 background,
854 } => {
855 if flux.shape() != sample_counts.shape() {
856 return Err(PipelineError::ShapeMismatch(format!(
857 "flux shape {:?} != sample shape {:?}",
858 flux.shape(),
859 sample_counts.shape(),
860 )));
861 }
862 if background.shape() != sample_counts.shape() {
863 return Err(PipelineError::ShapeMismatch(format!(
864 "background shape {:?} != sample shape {:?}",
865 background.shape(),
866 sample_counts.shape(),
867 )));
868 }
869 }
870 }
871 if let Some(dp) = dead_pixels
872 && dp.shape() != [height, width]
873 {
874 return Err(PipelineError::ShapeMismatch(format!(
875 "dead_pixels shape {:?} != spatial dimensions ({height}, {width})",
876 dp.shape(),
877 )));
878 }
879
880 if input.is_counts()
896 && matches!(config.solver(), SolverConfig::LevenbergMarquardt(_))
897 && config.fit_energy_scale()
898 {
899 return Err(PipelineError::InvalidParameter(
900 "spatial_map_typed: solver='lm' + fit_energy_scale=true on counts input is \
901 numerically unstable per-pixel (issue #458 B3). Recommended workaround: fit \
902 TZERO once on the aggregated spectrum via fit_counts_spectrum_typed, then \
903 build the corrected energy grid and pass it to spatial_map_typed with \
904 fit_energy_scale=false. For counts data, solver='kl' (or 'auto') is robust \
905 with per-pixel TZERO fitting."
906 .into(),
907 ));
908 }
909
910 if matches!(input, InputData3D::CountsWithNuisance { .. })
924 && matches!(config.solver(), SolverConfig::LevenbergMarquardt(_))
925 {
926 return Err(PipelineError::InvalidParameter(
927 "spatial_map_typed: InputData3D::CountsWithNuisance requires a counts-domain \
928 solver (joint-Poisson via SolverConfig::PoissonKL or SolverConfig::Auto); \
929 SolverConfig::LevenbergMarquardt cannot use the user-supplied nuisance \
930 parameters (alpha_1, alpha_2). Choose a counts-domain solver, or drop the \
931 nuisance arm by passing `InputData3D::Counts` instead."
932 .into(),
933 ));
934 }
935
936 if let Some(bg) = config.transmission_background() {
942 validate_transmission_background(bg)?;
946 if bg.fit_back_d && (!bg.back_d_init.is_finite() || bg.back_d_init <= 0.0) {
954 return Err(PipelineError::InvalidParameter(format!(
955 "transmission_background.back_d_init must be finite and strictly \
956 positive when fit_back_d=true (got {}). BackF's Jacobian column \
957 zeros out at BackD ≈ 0; non-finite or non-positive initial values \
958 produce a degenerate fit that LM cannot recover.",
959 bg.back_d_init,
960 )));
961 }
962 if bg.fit_back_f && (!bg.back_f_init.is_finite() || bg.back_f_init <= 0.0) {
963 return Err(PipelineError::InvalidParameter(format!(
964 "transmission_background.back_f_init must be finite and strictly \
965 positive when fit_back_f=true (got {}). BackD becomes a constant \
966 duplicate of BackA at BackF ≈ 0; non-finite or non-positive initial \
967 values produce a degenerate fit that LM cannot recover.",
968 bg.back_f_init,
969 )));
970 }
971 if (bg.fit_back_d || bg.fit_back_f)
976 && input.is_counts()
977 && !matches!(config.solver(), SolverConfig::LevenbergMarquardt(_))
978 {
979 return Err(PipelineError::InvalidParameter(
980 "spatial_map_typed: transmission_background with fit_back_d=true / \
981 fit_back_f=true cannot be combined with the counts-KL (joint-Poisson) \
982 dispatch. The joint-Poisson solver does not fit the SAMMY exponential \
983 tail. Either switch to SolverConfig::LevenbergMarquardt or disable the \
984 exponential tail (fit_back_d=false, fit_back_f=false)."
985 .into(),
986 ));
987 }
988 }
989
990 validate_spatial_fit_preflight(input, config)?;
1006
1007 crate::pipeline::validate_precomputed_cross_sections(config)?;
1012
1013 let mut pixel_coords: Vec<(usize, usize)> = Vec::new();
1015 for y in 0..height {
1016 for x in 0..width {
1017 let is_dead = dead_pixels.is_some_and(|m| m[[y, x]]);
1018 if !is_dead {
1019 pixel_coords.push((y, x));
1020 }
1021 }
1022 }
1023
1024 let isotope_labels = config.isotope_names().to_vec();
1025 let has_background_outputs =
1026 config.transmission_background().is_some() || config.counts_background().is_some();
1027 let has_back_d_map = config
1035 .transmission_background()
1036 .is_some_and(|bg| bg.fit_back_d);
1037 let has_back_f_map = config
1038 .transmission_background()
1039 .is_some_and(|bg| bg.fit_back_f);
1040
1041 let dispatches_to_counts_kl =
1051 input.is_counts() && !matches!(config.solver(), SolverConfig::LevenbergMarquardt(_));
1052
1053 let baseline_global_mode = config
1056 .multiplicative_baseline()
1057 .is_some_and(|bl| bl.spatial_global);
1058 let has_baseline_maps = config.multiplicative_baseline().is_some() && !baseline_global_mode;
1059 let baseline_e_ref_ev = config
1060 .multiplicative_baseline()
1061 .map(|_| config.baseline_reference_energy());
1062
1063 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
1064 return Err(PipelineError::Cancelled);
1065 }
1066 if pixel_coords.is_empty() {
1067 return Ok(SpatialResult {
1074 density_maps: (0..n_maps)
1075 .map(|_| Array2::from_elem((height, width), f64::NAN))
1076 .collect(),
1077 uncertainty_maps: (0..n_maps)
1078 .map(|_| Array2::from_elem((height, width), f64::NAN))
1079 .collect(),
1080 chi_squared_map: Array2::from_elem((height, width), f64::NAN),
1081 deviance_per_dof_map: if dispatches_to_counts_kl {
1082 Some(Array2::from_elem((height, width), f64::NAN))
1083 } else {
1084 None
1085 },
1086 converged_map: Array2::from_elem((height, width), false),
1087 temperature_map: if config.fit_temperature() {
1088 Some(Array2::from_elem((height, width), f64::NAN))
1089 } else {
1090 None
1091 },
1092 temperature_uncertainty_map: if config.fit_temperature() {
1093 Some(Array2::from_elem((height, width), f64::NAN))
1094 } else {
1095 None
1096 },
1097 isotope_labels,
1098 anorm_map: if has_background_outputs {
1099 Some(Array2::from_elem((height, width), f64::NAN))
1100 } else {
1101 None
1102 },
1103 background_maps: if has_background_outputs {
1104 Some([
1105 Array2::from_elem((height, width), f64::NAN),
1106 Array2::from_elem((height, width), f64::NAN),
1107 Array2::from_elem((height, width), f64::NAN),
1108 ])
1109 } else {
1110 None
1111 },
1112 back_d_map: if has_back_d_map {
1113 Some(Array2::from_elem((height, width), f64::NAN))
1114 } else {
1115 None
1116 },
1117 back_f_map: if has_back_f_map {
1118 Some(Array2::from_elem((height, width), f64::NAN))
1119 } else {
1120 None
1121 },
1122 t0_us_map: if config.fit_energy_scale() {
1123 Some(Array2::from_elem((height, width), f64::NAN))
1124 } else {
1125 None
1126 },
1127 l_scale_map: if config.fit_energy_scale() {
1128 Some(Array2::from_elem((height, width), f64::NAN))
1129 } else {
1130 None
1131 },
1132 energy_scale_flight_path_m: config.fit_energy_scale().then(|| config.flight_path_m()),
1133 baseline_global: config
1140 .multiplicative_baseline()
1141 .filter(|bl| bl.spatial_global && !bl.fit_b0 && !bl.fit_b1 && !bl.fit_b2)
1142 .map(|bl| [bl.b0_init, bl.b1_init, bl.b2_init]),
1143 baseline_e_ref_ev,
1144 baseline_maps: if has_baseline_maps {
1145 Some([
1146 Array2::from_elem((height, width), f64::NAN),
1147 Array2::from_elem((height, width), f64::NAN),
1148 Array2::from_elem((height, width), f64::NAN),
1149 ])
1150 } else {
1151 None
1152 },
1153 warnings: degenerate_normalization_warning(config)
1154 .into_iter()
1155 .collect(),
1156 n_converged: 0,
1157 n_total: 0,
1158 n_failed: 0,
1159 });
1160 }
1161
1162 let value_active_mask = nereids_fitting::active_mask::build_active_mask(
1171 config.energies(),
1172 config.fit_energy_range(),
1173 );
1174 validate_spatial_data_values(input, &pixel_coords, value_active_mask.as_deref())?;
1175
1176 let (data_a, data_b, data_c) = match input {
1178 InputData3D::Transmission {
1179 transmission,
1180 uncertainty,
1181 } => {
1182 let a = transmission
1183 .permuted_axes([1, 2, 0])
1184 .as_standard_layout()
1185 .into_owned();
1186 let b = uncertainty
1187 .permuted_axes([1, 2, 0])
1188 .as_standard_layout()
1189 .into_owned();
1190 (a, b, None)
1191 }
1192 InputData3D::Counts {
1193 sample_counts,
1194 open_beam_counts,
1195 } => {
1196 let a = sample_counts
1197 .permuted_axes([1, 2, 0])
1198 .as_standard_layout()
1199 .into_owned();
1200 let b = open_beam_counts
1201 .permuted_axes([1, 2, 0])
1202 .as_standard_layout()
1203 .into_owned();
1204 (a, b, None)
1205 }
1206 InputData3D::CountsWithNuisance {
1207 sample_counts,
1208 flux,
1209 background,
1210 } => {
1211 let a = sample_counts
1212 .permuted_axes([1, 2, 0])
1213 .as_standard_layout()
1214 .into_owned();
1215 let b = flux
1216 .permuted_axes([1, 2, 0])
1217 .as_standard_layout()
1218 .into_owned();
1219 let c = background
1220 .permuted_axes([1, 2, 0])
1221 .as_standard_layout()
1222 .into_owned();
1223 (a, b, Some(c))
1224 }
1225 };
1226
1227 let instrument = config.resolution().map(|r| InstrumentParams {
1240 resolution: r.clone(),
1241 });
1242
1243 let rd_refs: Vec<&_> = config.resonance_data().iter().collect();
1253 let layout = nereids_physics::transmission::resolution_working_grid(
1254 config.energies(),
1255 instrument.as_ref(),
1256 &rd_refs,
1257 )
1258 .map_err(PipelineError::Transmission)?;
1259 let aux_grid_active = !layout.is_identity();
1260
1261 let (xs, work_xs) = match config.precomputed_cross_sections().cloned() {
1263 Some(cached) if !aux_grid_active => (cached, None),
1269 Some(cached) => {
1270 let working = broadened_cross_sections_on_working_grid(
1271 config.energies(),
1272 config.resonance_data(),
1273 config.temperature_k(),
1274 instrument.as_ref(),
1275 cancel,
1276 )?;
1277 (cached, Some(Arc::new(working.sigma)))
1278 }
1279 None => {
1280 let working = broadened_cross_sections_on_working_grid(
1281 config.energies(),
1282 config.resonance_data(),
1283 config.temperature_k(),
1284 instrument.as_ref(),
1285 cancel,
1286 )?;
1287 if aux_grid_active {
1288 let data_xs: Vec<Vec<f64>> = working
1290 .sigma
1291 .iter()
1292 .map(|s| working.layout.extract(s))
1293 .collect();
1294 (Arc::new(data_xs), Some(Arc::new(working.sigma)))
1295 } else {
1296 (Arc::new(working.sigma), None)
1298 }
1299 }
1300 };
1301
1302 let work_layout: Option<Arc<nereids_physics::transmission::WorkingGridLayout>> =
1307 if work_xs.is_some() {
1308 Some(Arc::new(layout))
1309 } else {
1310 None
1311 };
1312
1313 let collapse = |xs: &Arc<Vec<Vec<f64>>>| -> Arc<Vec<Vec<f64>>> {
1319 if !config.fit_temperature()
1320 && let (Some(di), Some(dr)) = (&config.density_indices, &config.density_ratios)
1321 && xs.len() == di.len()
1322 && di.len() == dr.len()
1323 {
1324 let n_e = xs[0].len();
1325 let mut eff = vec![vec![0.0f64; n_e]; n_maps];
1326 for ((&idx, &ratio), member_xs) in di.iter().zip(dr.iter()).zip(xs.iter()) {
1327 for (j, &sigma) in member_xs.iter().enumerate() {
1328 eff[idx][j] += ratio * sigma;
1329 }
1330 }
1331 Arc::new(eff)
1332 } else {
1333 Arc::clone(xs)
1334 }
1335 };
1336 let xs = collapse(&xs);
1337 let work_xs = work_xs.as_ref().map(collapse);
1338
1339 let resolution_plan: Option<Arc<nereids_physics::resolution::ResolutionPlan>> =
1359 if !config.fit_energy_scale() {
1360 match config.resolution() {
1361 Some(res) => build_resolution_plan(config.energies(), res)
1368 .map_err(|e| {
1369 PipelineError::Transmission(
1370 nereids_physics::transmission::TransmissionError::from(e),
1371 )
1372 })?
1373 .map(Arc::new),
1374 None => None,
1375 }
1376 } else {
1377 None
1378 };
1379
1380 let caller_cubature = config.precomputed_sparse_cubature_plan().cloned();
1399 let sparse_cubature_plan: Option<Arc<nereids_physics::surrogate::SparseEmpiricalCubaturePlan>> =
1400 if !config.fit_temperature()
1401 && !config.fit_energy_scale()
1402 && resolution_plan.is_some()
1403 && xs.len() >= 2
1404 {
1405 let plan = resolution_plan.as_deref().expect("guarded above");
1406 let matrix = plan.compile_to_matrix();
1407 let k = xs.len();
1408 let n_rows = matrix.len();
1409 let mut sigmas_flat = Vec::with_capacity(k * n_rows);
1413 for row in xs.iter() {
1414 if row.len() != n_rows {
1415 sigmas_flat.clear();
1417 break;
1418 }
1419 sigmas_flat.extend_from_slice(row);
1420 }
1421 if sigmas_flat.len() == k * n_rows {
1422 debug_assert_eq!(
1432 sigmas_flat.len(),
1433 k * n_rows,
1434 "cubature σ dimensions: expected {k} × {n_rows} = {}, got {}",
1435 k * n_rows,
1436 sigmas_flat.len(),
1437 );
1438 let train_max: Vec<f64> = config
1443 .initial_densities()
1444 .iter()
1445 .map(|&n0| 2.0 * n0.max(1e-6))
1446 .collect();
1447 let training =
1448 nereids_physics::surrogate::SparseEmpiricalCubaturePlan::default_training_points(
1449 &train_max,
1450 );
1451 let anchor =
1452 nereids_physics::surrogate::SparseEmpiricalCubaturePlan::default_jacobian_anchor(
1453 &train_max,
1454 );
1455 match nereids_physics::surrogate::SparseEmpiricalCubaturePlan::build(
1456 &matrix,
1457 &sigmas_flat,
1458 k,
1459 &training,
1460 &anchor,
1461 ) {
1462 Ok(plan) => {
1463 Some(Arc::new(plan.with_density_box(train_max.clone())))
1469 }
1470 Err(e) => {
1471 eprintln!(
1478 "spatial_map_typed: sparse cubature build failed ({e}); \
1479 falling back to exact ResolutionPlan path for this call",
1480 );
1481 None
1482 }
1483 }
1484 } else {
1485 None
1486 }
1487 } else {
1488 None
1489 };
1490
1491 let sparse_cubature_plan = sparse_cubature_plan.or_else(|| {
1499 caller_cubature.filter(|p| {
1500 p.len() == xs.first().map(|r| r.len()).unwrap_or(0)
1501 && p.k() == xs.len()
1502 && p.target_energies() == config.energies()
1503 })
1504 });
1505
1506 let caller_scalar = config.precomputed_sparse_scalar_plan().cloned();
1520 let sparse_scalar_plan: Option<Arc<nereids_physics::surrogate::ScalarSurrogatePlan>> =
1521 if let Some(plan) = resolution_plan.as_ref()
1522 && !config.fit_temperature()
1523 && !config.fit_energy_scale()
1524 && xs.len() == 1
1525 {
1526 let sigma_row = &xs[0];
1527 const CHEBYSHEV_NODES: usize = 16;
1541 let n_max: f64 = 2.0 * config.initial_densities()[0].max(1e-6);
1542 match nereids_physics::surrogate::ScalarChebyshevPlan::build(
1543 Arc::clone(plan),
1544 sigma_row,
1545 n_max,
1546 CHEBYSHEV_NODES,
1547 ) {
1548 Ok(plan) => Some(Arc::new(plan)),
1549 Err(e) => {
1550 eprintln!(
1551 "spatial_map_typed: scalar Chebyshev build failed ({e}); \
1552 falling back to exact ResolutionPlan path",
1553 );
1554 None
1555 }
1556 }
1557 } else {
1558 None
1559 };
1560 let sparse_scalar_plan = sparse_scalar_plan.or_else(|| {
1566 caller_scalar.filter(|p| {
1567 let expected_len = xs.first().map(|r| r.len()).unwrap_or(0);
1568 if p.len() != expected_len {
1569 return false;
1570 }
1571 let plan_grid = p.target_energies();
1572 let cfg_grid = config.energies();
1573 if plan_grid.len() != cfg_grid.len() {
1574 return false;
1575 }
1576 plan_grid
1577 .iter()
1578 .zip(cfg_grid)
1579 .all(|(a, b)| a.to_bits() == b.to_bits())
1580 })
1581 });
1582
1583 let fast_config = if config.fit_temperature() {
1587 let base_xs: Vec<Vec<f64>> =
1594 unbroadened_cross_sections(config.energies(), config.resonance_data(), cancel)?;
1595 let mut cfg = config
1596 .clone()
1597 .with_precomputed_cross_sections(xs)
1598 .with_precomputed_base_xs(Arc::new(base_xs))
1599 .with_compute_covariance(true);
1600 if let Some(plan) = resolution_plan.clone() {
1601 cfg = cfg.with_precomputed_resolution_plan(plan);
1602 }
1603 cfg
1607 } else {
1608 let mut cfg = config.clone();
1612 if cfg.density_indices.is_some() {
1613 cfg.density_indices = None;
1614 cfg.density_ratios = None;
1615 }
1616 let mut cfg = cfg
1617 .with_precomputed_cross_sections(xs)
1618 .with_compute_covariance(true);
1619 if let (Some(work_xs), Some(layout)) = (work_xs.clone(), work_layout.clone()) {
1626 cfg = cfg.with_precomputed_work_cross_sections(work_xs, layout);
1627 }
1628 if let Some(plan) = resolution_plan.clone() {
1629 cfg = cfg.with_precomputed_resolution_plan(plan);
1630 }
1631 if let Some(plan) = sparse_cubature_plan.clone() {
1632 cfg = cfg.with_precomputed_sparse_cubature_plan(plan);
1633 }
1634 if let Some(plan) = sparse_scalar_plan.clone() {
1635 cfg = cfg.with_precomputed_sparse_scalar_plan(plan);
1636 }
1637 cfg
1638 };
1639
1640 let fast_config = apply_spatial_polish_default(fast_config, pixel_coords.len());
1648
1649 let averaged_flux: Option<Vec<f64>> = if matches!(input, InputData3D::Counts { .. }) {
1674 let n_e = data_b.shape()[2]; let mut flux = vec![0.0f64; n_e];
1676 let n_live = pixel_coords.len() as f64;
1677 if n_live > 0.0 {
1678 for &(y, x) in &pixel_coords {
1679 let ob_spectrum = data_b.slice(s![y, x, ..]);
1680 for (e, &v) in ob_spectrum.iter().enumerate() {
1681 flux[e] += v;
1682 }
1683 }
1684 for v in &mut flux {
1685 *v /= n_live;
1686 }
1687 if let Some(e) = flux.iter().position(|v| !v.is_finite()) {
1693 return Err(PipelineError::InvalidParameter(format!(
1694 "spatially-averaged open-beam flux is non-finite at energy \
1695 bin e={e} (got {}); summed open-beam counts overflowed. \
1696 Check the open-beam cube magnitude.",
1697 flux[e],
1698 )));
1699 }
1700 }
1701 Some(flux)
1702 } else {
1703 None
1704 };
1705 let background_zeros: Vec<f64> = if matches!(input, InputData3D::Counts { .. }) {
1706 vec![0.0f64; data_b.shape()[2]]
1707 } else {
1708 Vec::new()
1709 };
1710
1711 let warnings: Vec<String> = degenerate_normalization_warning(config)
1717 .into_iter()
1718 .inspect(|w| eprintln!("spatial_map_typed: warning: {w}"))
1719 .collect();
1720
1721 let (fast_config, baseline_global) = match fast_config.multiplicative_baseline().cloned() {
1728 Some(bl) if bl.spatial_global => {
1729 let b_global = if bl.fit_b0 || bl.fit_b1 || bl.fit_b2 {
1730 fit_global_baseline_stage1(
1731 input,
1732 &fast_config,
1733 &data_a,
1734 &data_b,
1735 data_c.as_ref(),
1736 &pixel_coords,
1737 averaged_flux.as_deref(),
1738 )?
1739 } else {
1740 [bl.b0_init, bl.b1_init, bl.b2_init]
1743 };
1744 let frozen = MultiplicativeBaselineConfig {
1745 b0_init: b_global[0],
1746 b1_init: b_global[1],
1747 b2_init: b_global[2],
1748 fit_b0: false,
1749 fit_b1: false,
1750 fit_b2: false,
1751 ..bl
1752 };
1753 (
1754 fast_config.with_multiplicative_baseline(frozen),
1755 Some(b_global),
1756 )
1757 }
1758 _ => (fast_config, None),
1760 };
1761
1762 let failed_count = AtomicUsize::new(0);
1764 let results: Vec<((usize, usize), SpectrumFitResult)> = pixel_coords
1765 .par_iter()
1766 .filter_map(|&(y, x)| {
1767 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
1768 return None;
1769 }
1770
1771 let spectrum_a: Vec<f64> = data_a.slice(s![y, x, ..]).to_vec();
1772
1773 let pixel_input = match input {
1775 InputData3D::Counts { .. } => {
1776 let ob_spectrum: Vec<f64> = data_b.slice(s![y, x, ..]).to_vec();
1777
1778 let effective = fast_config.effective_solver(&InputData::Counts {
1789 sample_counts: spectrum_a.clone(),
1790 open_beam_counts: ob_spectrum.clone(),
1791 });
1792 match effective {
1793 SolverConfig::PoissonKL(_) => InputData::CountsWithNuisance {
1794 sample_counts: spectrum_a,
1795 flux: averaged_flux.as_ref().unwrap().clone(),
1796 background: background_zeros.clone(),
1800 },
1801 _ => InputData::Counts {
1802 sample_counts: spectrum_a,
1803 open_beam_counts: ob_spectrum,
1804 },
1805 }
1806 }
1807 InputData3D::CountsWithNuisance { .. } => InputData::CountsWithNuisance {
1808 sample_counts: spectrum_a,
1811 flux: data_b.slice(s![y, x, ..]).to_vec(),
1812 background: data_c
1813 .as_ref()
1814 .expect("CountsWithNuisance requires background cube")
1815 .slice(s![y, x, ..])
1816 .to_vec(),
1817 },
1818 InputData3D::Transmission { .. } => {
1819 let spectrum_b: Vec<f64> = data_b.slice(s![y, x, ..]).to_vec();
1827 InputData::Transmission {
1828 transmission: spectrum_a,
1829 uncertainty: spectrum_b,
1830 }
1831 }
1832 };
1833
1834 let out = match fit_spectrum_typed(&pixel_input, &fast_config) {
1835 Ok(result) => Some(((y, x), result)),
1836 Err(_) => {
1837 failed_count.fetch_add(1, Ordering::Relaxed);
1838 None
1839 }
1840 };
1841 if let Some(p) = progress {
1842 p.fetch_add(1, Ordering::Relaxed);
1843 }
1844 out
1845 })
1846 .collect();
1847
1848 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
1859 return Err(PipelineError::Cancelled);
1860 }
1861
1862 let mut density_maps: Vec<Array2<f64>> = (0..n_maps)
1864 .map(|_| Array2::from_elem((height, width), f64::NAN))
1865 .collect();
1866 let mut uncertainty_maps: Vec<Array2<f64>> = (0..n_maps)
1867 .map(|_| Array2::from_elem((height, width), f64::NAN))
1868 .collect();
1869 let mut chi_squared_map = Array2::from_elem((height, width), f64::NAN);
1870 let mut deviance_per_dof_map: Option<Array2<f64>> = if dispatches_to_counts_kl {
1871 Some(Array2::from_elem((height, width), f64::NAN))
1872 } else {
1873 None
1874 };
1875 let mut converged_map = Array2::from_elem((height, width), false);
1876 let mut anorm_map: Option<Array2<f64>> = if has_background_outputs {
1877 Some(Array2::from_elem((height, width), f64::NAN))
1878 } else {
1879 None
1880 };
1881 let mut background_maps: Option<[Array2<f64>; 3]> = if has_background_outputs {
1882 Some([
1883 Array2::from_elem((height, width), f64::NAN),
1884 Array2::from_elem((height, width), f64::NAN),
1885 Array2::from_elem((height, width), f64::NAN),
1886 ])
1887 } else {
1888 None
1889 };
1890 let mut back_d_map: Option<Array2<f64>> = if has_back_d_map {
1891 Some(Array2::from_elem((height, width), f64::NAN))
1892 } else {
1893 None
1894 };
1895 let mut back_f_map: Option<Array2<f64>> = if has_back_f_map {
1896 Some(Array2::from_elem((height, width), f64::NAN))
1897 } else {
1898 None
1899 };
1900 let mut t0_us_map: Option<Array2<f64>> = if config.fit_energy_scale() {
1901 Some(Array2::from_elem((height, width), f64::NAN))
1902 } else {
1903 None
1904 };
1905 let mut l_scale_map: Option<Array2<f64>> = if config.fit_energy_scale() {
1906 Some(Array2::from_elem((height, width), f64::NAN))
1907 } else {
1908 None
1909 };
1910 let mut baseline_maps: Option<[Array2<f64>; 3]> = if has_baseline_maps {
1911 Some([
1912 Array2::from_elem((height, width), f64::NAN),
1913 Array2::from_elem((height, width), f64::NAN),
1914 Array2::from_elem((height, width), f64::NAN),
1915 ])
1916 } else {
1917 None
1918 };
1919 let mut n_converged = 0;
1920 let mut temperature_map: Option<Array2<f64>> = if config.fit_temperature() {
1921 Some(Array2::from_elem((height, width), f64::NAN))
1922 } else {
1923 None
1924 };
1925 let mut temperature_uncertainty_map: Option<Array2<f64>> = if config.fit_temperature() {
1926 Some(Array2::from_elem((height, width), f64::NAN))
1927 } else {
1928 None
1929 };
1930
1931 for ((y, x), result) in &results {
1955 converged_map[[*y, *x]] = result.converged;
1958 if !result.converged {
1959 continue;
1960 }
1961
1962 n_converged += 1;
1963
1964 for i in 0..n_maps {
1965 density_maps[i][[*y, *x]] = result.densities[i];
1966 if let Some(ref unc) = result.uncertainties {
1967 uncertainty_maps[i][[*y, *x]] = unc[i];
1968 }
1969 }
1970 chi_squared_map[[*y, *x]] = result.reduced_chi_squared;
1971 if let (Some(dpd), Some(v)) = (&mut deviance_per_dof_map, result.deviance_per_dof) {
1972 dpd[[*y, *x]] = v;
1973 }
1974 if let (Some(t_map), Some(t)) = (&mut temperature_map, result.temperature_k) {
1975 t_map[[*y, *x]] = t;
1976 }
1977 if let (Some(tu_map), Some(tu)) =
1978 (&mut temperature_uncertainty_map, result.temperature_k_unc)
1979 {
1980 tu_map[[*y, *x]] = tu;
1981 }
1982 if let Some(ref mut a_map) = anorm_map {
1983 a_map[[*y, *x]] = result.anorm;
1984 }
1985 if let Some(ref mut bg_maps) = background_maps {
1986 bg_maps[0][[*y, *x]] = result.background[0];
1987 bg_maps[1][[*y, *x]] = result.background[1];
1988 bg_maps[2][[*y, *x]] = result.background[2];
1989 }
1990 if let Some(ref mut map) = back_d_map {
2000 map[[*y, *x]] = result.back_d.unwrap_or(f64::NAN);
2001 }
2002 if let Some(ref mut map) = back_f_map {
2003 map[[*y, *x]] = result.back_f.unwrap_or(f64::NAN);
2004 }
2005 if let (Some(map), Some(v)) = (&mut t0_us_map, result.t0_us) {
2006 map[[*y, *x]] = v;
2007 }
2008 if let (Some(map), Some(v)) = (&mut l_scale_map, result.l_scale) {
2009 map[[*y, *x]] = v;
2010 }
2011 if let (Some(maps), Some(b)) = (&mut baseline_maps, result.baseline) {
2014 maps[0][[*y, *x]] = b[0];
2015 maps[1][[*y, *x]] = b[1];
2016 maps[2][[*y, *x]] = b[2];
2017 }
2018 }
2019
2020 Ok(SpatialResult {
2021 density_maps,
2022 uncertainty_maps,
2023 chi_squared_map,
2024 deviance_per_dof_map,
2025 converged_map,
2026 temperature_map,
2027 temperature_uncertainty_map,
2028 isotope_labels,
2029 anorm_map,
2030 background_maps,
2031 back_d_map,
2032 back_f_map,
2033 t0_us_map,
2034 l_scale_map,
2035 energy_scale_flight_path_m: config.fit_energy_scale().then(|| config.flight_path_m()),
2036 baseline_global,
2037 baseline_e_ref_ev,
2038 baseline_maps,
2039 warnings,
2040 n_converged,
2041 n_total: pixel_coords.len(),
2042 n_failed: failed_count.load(Ordering::Relaxed),
2043 })
2044}
2045
2046#[cfg(test)]
2049mod tests {
2050 use super::*;
2051 use ndarray::{Array2, Array3};
2052 use nereids_fitting::lm::{FitModel, LmConfig};
2053 use nereids_fitting::poisson::PoissonConfig;
2054 use nereids_fitting::transmission_model::PrecomputedTransmissionModel;
2055
2056 use crate::pipeline::{SolverConfig, UnifiedFitConfig};
2057 use nereids_endf::resonance::test_support::{
2058 synthetic_single_resonance, u238_single_resonance,
2059 };
2060
2061 fn synthetic_grid_transmission(
2064 res_data: &nereids_endf::resonance::ResonanceData,
2065 true_density: f64,
2066 energies: &[f64],
2067 height: usize,
2068 width: usize,
2069 ) -> (Array3<f64>, Array3<f64>) {
2070 let n_e = energies.len();
2071 let xs = nereids_physics::transmission::broadened_cross_sections(
2072 energies,
2073 std::slice::from_ref(res_data),
2074 0.0,
2075 None,
2076 None,
2077 )
2078 .unwrap();
2079 let model = PrecomputedTransmissionModel {
2080 cross_sections: Arc::new(xs),
2081 density_indices: Arc::new(vec![0]),
2082 energies: None,
2083 instrument: None,
2084 resolution_plan: None,
2085 sparse_cubature_plan: None,
2086 sparse_scalar_plan: None,
2087 work_layout: None,
2088 };
2089 let t_1d = model.evaluate(&[true_density]).unwrap();
2090 let sigma_1d: Vec<f64> = t_1d.iter().map(|&v| 0.01 * v.max(0.01)).collect();
2091
2092 let mut t_3d = Array3::zeros((n_e, height, width));
2093 let mut u_3d = Array3::zeros((n_e, height, width));
2094 for y in 0..height {
2095 for x in 0..width {
2096 for (i, (&t, &s)) in t_1d.iter().zip(sigma_1d.iter()).enumerate() {
2097 t_3d[[i, y, x]] = t;
2098 u_3d[[i, y, x]] = s;
2099 }
2100 }
2101 }
2102 (t_3d, u_3d)
2103 }
2104
2105 fn synthetic_4x4_transmission(
2107 res_data: &nereids_endf::resonance::ResonanceData,
2108 true_density: f64,
2109 energies: &[f64],
2110 ) -> (Array3<f64>, Array3<f64>) {
2111 synthetic_grid_transmission(res_data, true_density, energies, 4, 4)
2112 }
2113
2114 fn synthetic_4x4_counts(
2116 res_data: &nereids_endf::resonance::ResonanceData,
2117 true_density: f64,
2118 energies: &[f64],
2119 i0: f64,
2120 ) -> (Array3<f64>, Array3<f64>) {
2121 let (t_3d, _) = synthetic_4x4_transmission(res_data, true_density, energies);
2122 let n_e = energies.len();
2123 let mut sample = Array3::zeros((n_e, 4, 4));
2124 let mut ob = Array3::zeros((n_e, 4, 4));
2125 for y in 0..4 {
2126 for x in 0..4 {
2127 for i in 0..n_e {
2128 ob[[i, y, x]] = i0;
2129 sample[[i, y, x]] = (t_3d[[i, y, x]] * i0).round().max(0.0);
2130 }
2131 }
2132 }
2133 (sample, ob)
2134 }
2135
2136 #[test]
2137 fn test_spatial_map_typed_transmission_lm() {
2138 let data = u238_single_resonance();
2139 let true_density = 0.0005;
2140 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2141 let (t_3d, u_3d) = synthetic_4x4_transmission(&data, true_density, &energies);
2142
2143 let config = UnifiedFitConfig::new(
2144 energies,
2145 vec![data],
2146 vec!["U-238".into()],
2147 0.0,
2148 None,
2149 vec![0.001],
2150 )
2151 .unwrap()
2152 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2153
2154 let input = InputData3D::Transmission {
2155 transmission: t_3d.view(),
2156 uncertainty: u_3d.view(),
2157 };
2158
2159 let result = spatial_map_typed(&input, &config, None, None, None).unwrap();
2160 assert_eq!(result.n_total, 16);
2161 assert!(result.n_converged >= 14, "Most pixels should converge");
2162
2163 let d = &result.density_maps[0];
2165 let conv = &result.converged_map;
2166 let mean: f64 = d
2167 .iter()
2168 .zip(conv.iter())
2169 .filter(|(_, c)| **c)
2170 .map(|(d, _)| *d)
2171 .sum::<f64>()
2172 / result.n_converged as f64;
2173 assert!(
2174 (mean - true_density).abs() / true_density < 0.05,
2175 "mean density: {mean}, true: {true_density}"
2176 );
2177 }
2178
2179 #[test]
2180 fn test_spatial_map_typed_counts_kl() {
2181 let data = u238_single_resonance();
2182 let true_density = 0.0005;
2183 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2184 let (sample, ob) = synthetic_4x4_counts(&data, true_density, &energies, 1000.0);
2185
2186 let config = UnifiedFitConfig::new(
2187 energies,
2188 vec![data],
2189 vec!["U-238".into()],
2190 0.0,
2191 None,
2192 vec![0.001],
2193 )
2194 .unwrap()
2195 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
2196
2197 let input = InputData3D::Counts {
2198 sample_counts: sample.view(),
2199 open_beam_counts: ob.view(),
2200 };
2201
2202 let result = spatial_map_typed(&input, &config, None, None, None).unwrap();
2203 assert_eq!(result.n_total, 16);
2204 assert!(
2205 result.n_converged >= 14,
2206 "Most pixels should converge with KL"
2207 );
2208
2209 let d = &result.density_maps[0];
2210 let conv = &result.converged_map;
2211 let mean: f64 = d
2212 .iter()
2213 .zip(conv.iter())
2214 .filter(|(_, c)| **c)
2215 .map(|(d, _)| *d)
2216 .sum::<f64>()
2217 / result.n_converged.max(1) as f64;
2218 assert!(
2219 (mean - true_density).abs() / true_density < 0.10,
2220 "KL mean density: {mean}, true: {true_density}"
2221 );
2222 }
2223
2224 #[test]
2229 fn test_spatial_map_rejects_wrong_shape_precomputed_cross_sections() {
2230 let data = u238_single_resonance();
2231 let energies: Vec<f64> = (0..21).map(|i| 1.0 + (i as f64) * 0.1).collect();
2232 let (t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
2233
2234 let n_e = energies.len();
2236 let bad_xs = Arc::new(vec![vec![1.0; n_e], vec![1.0; n_e]]);
2237 let config = UnifiedFitConfig::new(
2238 energies,
2239 vec![data],
2240 vec!["U-238".into()],
2241 0.0,
2242 None,
2243 vec![0.001],
2244 )
2245 .unwrap()
2246 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
2247 .with_precomputed_cross_sections(bad_xs);
2248
2249 let input = InputData3D::Transmission {
2250 transmission: t_3d.view(),
2251 uncertainty: u_3d.view(),
2252 };
2253
2254 let err = spatial_map_typed(&input, &config, None, None, None)
2255 .expect_err("wrong-shape precomputed XS must be rejected up front");
2256 assert!(
2257 matches!(err, PipelineError::ShapeMismatch(_)),
2258 "expected ShapeMismatch, got {err:?}"
2259 );
2260 }
2261
2262 #[test]
2277 fn test_spatial_map_mid_run_cancellation_returns_err() {
2278 use std::sync::atomic::{AtomicBool, AtomicUsize};
2279
2280 let data = u238_single_resonance();
2281 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2282 let (t_3d, u_3d) = synthetic_grid_transmission(&data, 0.0005, &energies, 1, 64);
2285
2286 let config = UnifiedFitConfig::new(
2287 energies,
2288 vec![data],
2289 vec!["U-238".into()],
2290 0.0,
2291 None,
2292 vec![0.001],
2293 )
2294 .unwrap()
2295 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2296
2297 let input = InputData3D::Transmission {
2298 transmission: t_3d.view(),
2299 uncertainty: u_3d.view(),
2300 };
2301
2302 let mut saw_cancelled = false;
2311 for _attempt in 0..5 {
2312 let cancel = AtomicBool::new(false);
2313 let progress = AtomicUsize::new(0);
2314
2315 let result = std::thread::scope(|s| {
2316 s.spawn(|| {
2319 while progress.load(Ordering::Relaxed) < 1 {
2320 std::thread::yield_now();
2324 }
2325 cancel.store(true, Ordering::Relaxed);
2326 });
2327 spatial_map_typed(&input, &config, None, Some(&cancel), Some(&progress))
2328 });
2329
2330 match result {
2331 Err(PipelineError::Cancelled) => {
2332 saw_cancelled = true;
2333 break;
2334 }
2335 Ok(r) if r.n_converged == r.n_total && r.n_failed == 0 => {
2336 continue;
2339 }
2340 other => panic!(
2341 "mid-run cancellation must return Err(Cancelled) (or lose \
2342 the race with a COMPLETE map), got {other:?}"
2343 ),
2344 }
2345 }
2346 assert!(
2347 saw_cancelled,
2348 "all 5 attempts completed the whole sweep before the cancellation \
2349 flip became visible — enlarge the pixel grid for this runner"
2350 );
2351 }
2352
2353 #[test]
2376 fn test_fit_temperature_precompute_cancellation_maps_to_cancelled() {
2377 use std::sync::atomic::AtomicBool;
2378
2379 let data = u238_single_resonance();
2380 let n_e = 100_001usize;
2384 let energies: Vec<f64> = (0..n_e).map(|i| 1.0 + (i as f64) * 2e-4).collect();
2385 let (t_3d, u_3d) = synthetic_grid_transmission(&data, 0.0005, &energies, 2, 2);
2386
2387 let precomputed_xs = vec![vec![0.0f64; n_e]];
2392
2393 let config = UnifiedFitConfig::new(
2394 energies,
2395 vec![data],
2396 vec!["U-238".into()],
2397 293.6,
2398 None,
2399 vec![0.001],
2400 )
2401 .unwrap()
2402 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
2403 .with_fit_temperature(true)
2404 .with_precomputed_cross_sections(precomputed_xs.into());
2405
2406 let input = InputData3D::Transmission {
2407 transmission: t_3d.view(),
2408 uncertainty: u_3d.view(),
2409 };
2410
2411 let cancel = AtomicBool::new(false);
2412 let result = std::thread::scope(|s| {
2413 s.spawn(|| {
2414 std::thread::sleep(std::time::Duration::from_millis(5));
2415 cancel.store(true, Ordering::Relaxed);
2416 });
2417 spatial_map_typed(&input, &config, None, Some(&cancel), None)
2418 });
2419
2420 assert!(
2421 matches!(result, Err(PipelineError::Cancelled)),
2422 "cancellation during the fit_temperature precompute must map to \
2423 Err(Cancelled), got {result:?}"
2424 );
2425 }
2426
2427 fn synthetic_tabulated_text() -> String {
2438 "header\n---\n\
2444 5.0 0.0\n\
2445 -0.01 0.0\n\
2446 -0.005 0.5\n\
2447 0.0 1.0\n\
2448 0.005 0.5\n\
2449 0.01 0.0\n\
2450 \n\
2451 200.0 0.0\n\
2452 -0.02 0.0\n\
2453 -0.01 0.5\n\
2454 0.0 1.0\n\
2455 0.01 0.5\n\
2456 0.02 0.0\n"
2457 .to_string()
2458 }
2459
2460 #[test]
2473 fn test_spatial_map_typed_with_resolution_plan_converges_and_is_deterministic() {
2474 use nereids_physics::resolution::{ResolutionFunction, TabulatedResolution};
2475
2476 let data = u238_single_resonance();
2477 let true_density = 0.0005;
2478 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2479 let (t_3d, u_3d) = synthetic_4x4_transmission(&data, true_density, &energies);
2480
2481 let tab = TabulatedResolution::from_text(&synthetic_tabulated_text(), 25.0).unwrap();
2482 let resolution = ResolutionFunction::Tabulated(Arc::new(tab));
2483
2484 let config = UnifiedFitConfig::new(
2485 energies.clone(),
2486 vec![data.clone()],
2487 vec!["U-238".into()],
2488 0.0,
2489 Some(resolution),
2490 vec![0.001],
2491 )
2492 .unwrap()
2493 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2494
2495 let input = InputData3D::Transmission {
2496 transmission: t_3d.view(),
2497 uncertainty: u_3d.view(),
2498 };
2499
2500 let result_with_plan = spatial_map_typed(&input, &config, None, None, None).unwrap();
2501 assert_eq!(result_with_plan.n_total, 16);
2502 assert!(
2503 result_with_plan.n_converged >= 14,
2504 "plan path: {} / 16 pixels converged",
2505 result_with_plan.n_converged,
2506 );
2507
2508 let d = &result_with_plan.density_maps[0];
2509 let conv = &result_with_plan.converged_map;
2510 let mean: f64 = d
2511 .iter()
2512 .zip(conv.iter())
2513 .filter(|(_, c)| **c)
2514 .map(|(d, _)| *d)
2515 .sum::<f64>()
2516 / result_with_plan.n_converged.max(1) as f64;
2517 assert!(
2518 (mean - true_density).abs() / true_density < 0.10,
2519 "mean density with plan: {mean}, true: {true_density}"
2520 );
2521
2522 let reference = d
2528 .iter()
2529 .zip(conv.iter())
2530 .find(|(_, c)| **c)
2531 .map(|(d, _)| *d)
2532 .expect("at least one pixel converged");
2533 for (&cell, &c) in d.iter().zip(conv.iter()) {
2534 if c {
2535 assert_eq!(
2536 cell.to_bits(),
2537 reference.to_bits(),
2538 "plan cache leaked pixel-specific state: density cell {cell} != reference {reference}"
2539 );
2540 }
2541 }
2542 }
2543
2544 #[test]
2554 fn test_spatial_map_typed_gaussian_aux_grid_recovers_density() {
2555 use nereids_physics::resolution::{ResolutionFunction, ResolutionParams};
2556 use nereids_physics::transmission::{SampleParams, forward_model};
2557
2558 let data = u238_single_resonance(); let true_density = 0.0005;
2560 let temperature = 300.0;
2561 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2562 let inst = Arc::new(InstrumentParams {
2563 resolution: ResolutionFunction::Gaussian(
2564 ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2565 ),
2566 });
2567
2568 let sample = SampleParams::new(temperature, vec![(data.clone(), true_density)]).unwrap();
2571 let t_1d = forward_model(&energies, &sample, Some(&inst)).unwrap();
2572
2573 let t_none = forward_model(&energies, &sample, None).unwrap();
2576 let broaden = t_1d
2577 .iter()
2578 .zip(t_none.iter())
2579 .map(|(a, b)| (a - b).abs())
2580 .fold(0.0f64, f64::max);
2581 assert!(
2582 broaden > 1e-4,
2583 "Gaussian kernel must broaden the spectrum non-trivially (got {broaden:.3e})"
2584 );
2585
2586 let n_e = energies.len();
2588 let sigma_1d: Vec<f64> = t_1d.iter().map(|&v| 0.01 * v.max(0.01)).collect();
2589 let mut t_3d = Array3::zeros((n_e, 4, 4));
2590 let mut u_3d = Array3::zeros((n_e, 4, 4));
2591 for y in 0..4 {
2592 for x in 0..4 {
2593 for (i, (&t, &s)) in t_1d.iter().zip(sigma_1d.iter()).enumerate() {
2594 t_3d[[i, y, x]] = t;
2595 u_3d[[i, y, x]] = s;
2596 }
2597 }
2598 }
2599
2600 let config = UnifiedFitConfig::new(
2601 energies.clone(),
2602 vec![data],
2603 vec!["U-238".into()],
2604 temperature,
2605 Some(ResolutionFunction::Gaussian(
2606 ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2607 )),
2608 vec![0.001],
2609 )
2610 .unwrap()
2611 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2612
2613 let input = InputData3D::Transmission {
2614 transmission: t_3d.view(),
2615 uncertainty: u_3d.view(),
2616 };
2617 let result = spatial_map_typed(&input, &config, None, None, None).unwrap();
2618 assert_eq!(result.n_total, 16);
2619 assert!(
2620 result.n_converged >= 14,
2621 "Gaussian aux-grid path: {} / 16 pixels converged",
2622 result.n_converged,
2623 );
2624
2625 let d = &result.density_maps[0];
2627 let conv = &result.converged_map;
2628 let mean: f64 = d
2629 .iter()
2630 .zip(conv.iter())
2631 .filter(|(_, c)| **c)
2632 .map(|(d, _)| *d)
2633 .sum::<f64>()
2634 / result.n_converged.max(1) as f64;
2635 assert!(
2636 (mean - true_density).abs() / true_density < 0.10,
2637 "Gaussian aux-grid mean density: {mean}, true: {true_density}"
2638 );
2639
2640 let reference = d
2643 .iter()
2644 .zip(conv.iter())
2645 .find(|(_, c)| **c)
2646 .map(|(d, _)| *d)
2647 .expect("at least one pixel converged");
2648 for (&cell, &c) in d.iter().zip(conv.iter()) {
2649 if c {
2650 assert_eq!(
2651 cell.to_bits(),
2652 reference.to_bits(),
2653 "aux-grid path leaked pixel-specific state: density cell {cell} != reference {reference}"
2654 );
2655 }
2656 }
2657 }
2658
2659 #[test]
2666 fn test_spatial_map_typed_gaussian_aux_grid_with_precomputed_sigma() {
2667 use nereids_physics::resolution::{ResolutionFunction, ResolutionParams};
2668 use nereids_physics::transmission::{
2669 SampleParams, broadened_cross_sections, forward_model,
2670 };
2671
2672 let data = u238_single_resonance();
2673 let true_density = 0.0005;
2674 let temperature = 300.0;
2675 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2676 let inst = Arc::new(InstrumentParams {
2677 resolution: ResolutionFunction::Gaussian(
2678 ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2679 ),
2680 });
2681 let sample = SampleParams::new(temperature, vec![(data.clone(), true_density)]).unwrap();
2682 let t_1d = forward_model(&energies, &sample, Some(&inst)).unwrap();
2683 let n_e = energies.len();
2684 let sigma_1d: Vec<f64> = t_1d.iter().map(|&v| 0.01 * v.max(0.01)).collect();
2685 let mut t_3d = Array3::zeros((n_e, 4, 4));
2686 let mut u_3d = Array3::zeros((n_e, 4, 4));
2687 for y in 0..4 {
2688 for x in 0..4 {
2689 for (i, (&t, &s)) in t_1d.iter().zip(sigma_1d.iter()).enumerate() {
2690 t_3d[[i, y, x]] = t;
2691 u_3d[[i, y, x]] = s;
2692 }
2693 }
2694 }
2695 let data_sigma = broadened_cross_sections(
2697 &energies,
2698 std::slice::from_ref(&data),
2699 temperature,
2700 None,
2701 None,
2702 )
2703 .unwrap();
2704 let config = UnifiedFitConfig::new(
2705 energies,
2706 vec![data],
2707 vec!["U-238".into()],
2708 temperature,
2709 Some(ResolutionFunction::Gaussian(
2710 ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2711 )),
2712 vec![0.001],
2713 )
2714 .unwrap()
2715 .with_precomputed_cross_sections(Arc::new(data_sigma))
2716 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2717 let input = InputData3D::Transmission {
2718 transmission: t_3d.view(),
2719 uncertainty: u_3d.view(),
2720 };
2721 let result = spatial_map_typed(&input, &config, None, None, None).unwrap();
2722 assert_eq!(result.n_total, 16);
2723 assert!(
2724 result.n_converged >= 14,
2725 "Some(cached)+aux path: {} / 16 pixels converged",
2726 result.n_converged,
2727 );
2728 let d = &result.density_maps[0];
2729 let conv = &result.converged_map;
2730 let mean: f64 = d
2731 .iter()
2732 .zip(conv.iter())
2733 .filter(|(_, c)| **c)
2734 .map(|(d, _)| *d)
2735 .sum::<f64>()
2736 / result.n_converged.max(1) as f64;
2737 assert!(
2738 (mean - true_density).abs() / true_density < 0.10,
2739 "Some(cached)+aux mean density: {mean}, true: {true_density}"
2740 );
2741 }
2742
2743 #[test]
2744 fn test_spatial_map_typed_counts_kl_low_counts() {
2745 let data = u238_single_resonance();
2747 let true_density = 0.0005;
2748 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2749 let (sample, ob) = synthetic_4x4_counts(&data, true_density, &energies, 10.0);
2750
2751 let config = UnifiedFitConfig::new(
2752 energies,
2753 vec![data],
2754 vec!["U-238".into()],
2755 0.0,
2756 None,
2757 vec![0.001],
2758 )
2759 .unwrap(); let input = InputData3D::Counts {
2762 sample_counts: sample.view(),
2763 open_beam_counts: ob.view(),
2764 };
2765
2766 let result = spatial_map_typed(&input, &config, None, None, None).unwrap();
2767 assert_eq!(result.n_total, 16);
2768 assert!(
2770 result.n_converged >= 10,
2771 "KL at I0=10: only {}/{} converged",
2772 result.n_converged,
2773 result.n_total
2774 );
2775 }
2776
2777 #[test]
2778 fn test_spatial_map_typed_dead_pixels() {
2779 let data = u238_single_resonance();
2780 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
2781 let (t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
2782
2783 let config = UnifiedFitConfig::new(
2784 energies,
2785 vec![data],
2786 vec!["U-238".into()],
2787 0.0,
2788 None,
2789 vec![0.001],
2790 )
2791 .unwrap();
2792
2793 let mut dead = Array2::from_elem((4, 4), false);
2795 for y in 0..2 {
2796 for x in 0..4 {
2797 dead[[y, x]] = true;
2798 }
2799 }
2800
2801 let input = InputData3D::Transmission {
2802 transmission: t_3d.view(),
2803 uncertainty: u_3d.view(),
2804 };
2805
2806 let result = spatial_map_typed(&input, &config, Some(&dead), None, None).unwrap();
2807 assert_eq!(result.n_total, 8, "Only 8 live pixels");
2808 }
2809
2810 #[test]
2820 fn test_spatial_map_rejects_counts_kl_alpha_up_front() {
2821 let data = u238_single_resonance();
2822 let true_density = 0.0005;
2823 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
2824 let (sample, ob) = synthetic_4x4_counts(&data, true_density, &energies, 1000.0);
2825
2826 let config = UnifiedFitConfig::new(
2827 energies,
2828 vec![data],
2829 vec!["U-238".into()],
2830 0.0,
2831 None,
2832 vec![0.001],
2833 )
2834 .unwrap()
2835 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
2836 .with_counts_background(crate::pipeline::CountsBackgroundConfig {
2837 alpha_1_init: 1.0,
2838 alpha_2_init: 1.0,
2839 fit_alpha_1: false,
2840 fit_alpha_2: true,
2841 c: 1.0,
2842 });
2843
2844 let input = InputData3D::Counts {
2845 sample_counts: sample.view(),
2846 open_beam_counts: ob.view(),
2847 };
2848
2849 let err = spatial_map_typed(&input, &config, None, None, None)
2850 .expect_err("counts-KL with fit_alpha_2 must be rejected up-front");
2851 let msg = err.to_string();
2852 assert!(
2853 matches!(err, PipelineError::InvalidParameter(_)),
2854 "expected InvalidParameter, got {err:?}"
2855 );
2856 assert!(
2857 msg.contains("fit_alpha_1") || msg.contains("fit_alpha_2"),
2858 "error must name the offending flag, got: {msg}"
2859 );
2860 }
2861
2862 #[test]
2865 fn test_spatial_map_grouped() {
2866 let rd1 = synthetic_single_resonance(92, 235, 233.025, 5.0);
2867 let rd2 = synthetic_single_resonance(92, 238, 236.006, 7.0);
2868
2869 let iso1 = nereids_core::types::Isotope::new(92, 235).unwrap();
2870 let iso2 = nereids_core::types::Isotope::new(92, 238).unwrap();
2871 let group = nereids_core::types::IsotopeGroup::custom(
2872 "U (60/40)".into(),
2873 vec![(iso1, 0.6), (iso2, 0.4)],
2874 )
2875 .unwrap();
2876
2877 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
2878 let n_e = energies.len();
2879 let true_density = 0.0005;
2880
2881 let sample = nereids_physics::transmission::SampleParams::new(
2883 0.0,
2884 vec![
2885 (rd1.clone(), true_density * 0.6),
2886 (rd2.clone(), true_density * 0.4),
2887 ],
2888 )
2889 .unwrap();
2890 let t_1d = nereids_physics::transmission::forward_model(&energies, &sample, None).unwrap();
2891 let s_1d: Vec<f64> = t_1d.iter().map(|&v| 0.01 * v.max(0.01)).collect();
2892
2893 let mut t_3d = Array3::zeros((n_e, 2, 2));
2895 let mut u_3d = Array3::zeros((n_e, 2, 2));
2896 for y in 0..2 {
2897 for x in 0..2 {
2898 for (i, (&t, &s)) in t_1d.iter().zip(s_1d.iter()).enumerate() {
2899 t_3d[[i, y, x]] = t;
2900 u_3d[[i, y, x]] = s;
2901 }
2902 }
2903 }
2904
2905 let config = UnifiedFitConfig::new(
2906 energies,
2907 vec![rd1.clone()],
2908 vec!["placeholder".into()],
2909 0.0,
2910 None,
2911 vec![0.001],
2912 )
2913 .unwrap()
2914 .with_groups(&[(&group, &[rd1, rd2])], vec![0.001])
2915 .unwrap()
2916 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2917
2918 let input = InputData3D::Transmission {
2919 transmission: t_3d.view(),
2920 uncertainty: u_3d.view(),
2921 };
2922
2923 let result = spatial_map_typed(&input, &config, None, None, None).unwrap();
2924
2925 assert_eq!(
2927 result.density_maps.len(),
2928 1,
2929 "should have 1 group density map"
2930 );
2931 assert_eq!(result.isotope_labels, vec!["U (60/40)"]);
2932 assert_eq!(result.n_total, 4);
2933
2934 for y in 0..2 {
2936 for x in 0..2 {
2937 let fitted = result.density_maps[0][[y, x]];
2938 let rel_error = (fitted - true_density).abs() / true_density;
2939 assert!(
2940 rel_error < 0.05,
2941 "pixel ({y},{x}): fitted={fitted}, true={true_density}, rel_error={rel_error}"
2942 );
2943 }
2944 }
2945 }
2946
2947 #[test]
2951 fn test_spatial_lm_populates_density_uncertainty() {
2952 let rd = u238_single_resonance();
2953 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2954 let (mut t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
2955 for y in 0..4 {
2958 for x in 0..4 {
2959 for e in 0..energies.len() {
2960 let noise = 0.002 * ((e * 7 + y * 13 + x * 29) % 17) as f64 / 17.0 - 0.001;
2961 t_3d[[e, y, x]] = (t_3d[[e, y, x]] + noise).max(0.001);
2962 }
2963 }
2964 }
2965 let data = InputData3D::Transmission {
2966 transmission: t_3d.view(),
2967 uncertainty: u_3d.view(),
2968 };
2969 let config = UnifiedFitConfig::new(
2970 energies,
2971 vec![rd],
2972 vec!["U-238".into()],
2973 0.0,
2974 None,
2975 vec![0.0005],
2976 )
2977 .unwrap()
2978 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2979
2980 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
2981 assert!(result.n_converged > 0, "some pixels should converge");
2982 let unc_map = &result.uncertainty_maps[0];
2984 let conv_map = &result.converged_map;
2985 let mut n_finite = 0;
2986 for y in 0..4 {
2987 for x in 0..4 {
2988 if conv_map[[y, x]] {
2989 let u = unc_map[[y, x]];
2990 assert!(
2991 u.is_finite() && u > 0.0,
2992 "LM density unc at ({y},{x}) should be finite+positive, got {u}"
2993 );
2994 n_finite += 1;
2995 }
2996 }
2997 }
2998 assert!(
2999 n_finite > 0,
3000 "at least one converged pixel should have finite unc"
3001 );
3002 }
3003
3004 #[test]
3006 fn test_spatial_kl_populates_density_uncertainty() {
3007 let rd = u238_single_resonance();
3008 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
3009 let (t_3d, _) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3010 let ob_3d = Array3::from_elem(t_3d.raw_dim(), 1000.0);
3012 let sample_3d = &t_3d * &ob_3d;
3013 let data = InputData3D::Counts {
3014 sample_counts: sample_3d.view(),
3015 open_beam_counts: ob_3d.view(),
3016 };
3017 let config = UnifiedFitConfig::new(
3018 energies,
3019 vec![rd],
3020 vec!["U-238".into()],
3021 0.0,
3022 None,
3023 vec![0.0005],
3024 )
3025 .unwrap()
3026 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
3027
3028 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
3029 assert!(result.n_converged > 0);
3030 let unc_map = &result.uncertainty_maps[0];
3031 let conv_map = &result.converged_map;
3032 let mut n_finite = 0;
3033 for y in 0..4 {
3034 for x in 0..4 {
3035 if conv_map[[y, x]] {
3036 let u = unc_map[[y, x]];
3037 assert!(
3038 u.is_finite() && u > 0.0,
3039 "KL density unc at ({y},{x}) should be finite+positive, got {u}"
3040 );
3041 n_finite += 1;
3042 }
3043 }
3044 }
3045 assert!(n_finite > 0);
3046 }
3047
3048 #[test]
3050 fn test_spatial_temperature_uncertainty_map() {
3051 let rd = u238_single_resonance();
3052 let energies: Vec<f64> = (0..101).map(|i| 4.0 + (i as f64) * 0.05).collect();
3053 let (mut t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3054 for y in 0..4 {
3056 for x in 0..4 {
3057 for e in 0..energies.len() {
3058 let noise = 0.002 * ((e * 7 + y * 13 + x * 29) % 17) as f64 / 17.0 - 0.001;
3059 t_3d[[e, y, x]] = (t_3d[[e, y, x]] + noise).max(0.001);
3060 }
3061 }
3062 }
3063 let data = InputData3D::Transmission {
3064 transmission: t_3d.view(),
3065 uncertainty: u_3d.view(),
3066 };
3067 let config = UnifiedFitConfig::new(
3068 energies,
3069 vec![rd],
3070 vec!["U-238".into()],
3071 300.0,
3072 None,
3073 vec![0.0005],
3074 )
3075 .unwrap()
3076 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
3077 .with_fit_temperature(true);
3078
3079 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
3080 assert!(result.temperature_map.is_some());
3081 let tu_map = result
3082 .temperature_uncertainty_map
3083 .as_ref()
3084 .expect("temperature_uncertainty_map should be Some when fit_temperature=true");
3085 assert_eq!(tu_map.shape(), [4, 4]);
3086 let mut n_finite = 0;
3088 for y in 0..4 {
3089 for x in 0..4 {
3090 if result.converged_map[[y, x]] {
3091 let tu = tu_map[[y, x]];
3092 if tu.is_finite() && tu > 0.0 {
3093 n_finite += 1;
3094 }
3095 }
3096 }
3097 }
3098 assert!(
3099 n_finite > 0,
3100 "at least one converged pixel should have finite temperature uncertainty"
3101 );
3102 }
3103
3104 #[test]
3113 fn test_spatial_unconverged_pixels_are_nan() {
3114 let rd = u238_single_resonance();
3115 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
3116 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3123 let data = InputData3D::Transmission {
3124 transmission: t_3d.view(),
3125 uncertainty: u_3d.view(),
3126 };
3127 let config = UnifiedFitConfig::new(
3128 energies,
3129 vec![rd],
3130 vec!["U-238".into()],
3131 0.0,
3132 None,
3133 vec![0.1], )
3135 .unwrap()
3136 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig {
3137 max_iter: 1,
3138 ..Default::default()
3139 }))
3140 .with_transmission_background(crate::pipeline::BackgroundConfig::default());
3141
3142 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
3143
3144 let unconverged_pixel = (0..4)
3149 .flat_map(|y| (0..4).map(move |x| (y, x)))
3150 .find(|(y, x)| !result.converged_map[[*y, *x]]);
3151 let (uy, ux) = match unconverged_pixel {
3152 Some(p) => p,
3153 None => panic!(
3154 "every pixel converged in max_iter=1 + 100×-off initial density setup — \
3155 test is no longer exercising the un-converged aggregation path; \
3156 tighten the setup (larger offset or fewer iterations)"
3157 ),
3158 };
3159
3160 for (i, m) in result.density_maps.iter().enumerate() {
3162 let v = m[[uy, ux]];
3163 assert!(
3164 v.is_nan(),
3165 "density_maps[{i}] at unconverged pixel ({uy},{ux}) must be NaN, got {v}"
3166 );
3167 }
3168 for (i, m) in result.uncertainty_maps.iter().enumerate() {
3169 let v = m[[uy, ux]];
3170 assert!(
3171 v.is_nan(),
3172 "uncertainty_maps[{i}] at unconverged pixel ({uy},{ux}) must be NaN, got {v}"
3173 );
3174 }
3175 let chi2 = result.chi_squared_map[[uy, ux]];
3176 assert!(
3177 chi2.is_nan(),
3178 "chi_squared_map at unconverged pixel ({uy},{ux}) must be NaN, got {chi2}"
3179 );
3180 if let Some(ref a_map) = result.anorm_map {
3181 let v = a_map[[uy, ux]];
3182 assert!(
3183 v.is_nan(),
3184 "anorm_map at unconverged pixel ({uy},{ux}) must be NaN, got {v}"
3185 );
3186 }
3187 if let Some(ref bg) = result.background_maps {
3188 for (i, m) in bg.iter().enumerate() {
3189 let v = m[[uy, ux]];
3190 assert!(
3191 v.is_nan(),
3192 "background_maps[{i}] at unconverged pixel ({uy},{ux}) must be NaN, got {v}"
3193 );
3194 }
3195 }
3196 if let Some(ref m) = result.back_d_map {
3197 let v = m[[uy, ux]];
3198 assert!(
3199 v.is_nan(),
3200 "back_d_map at unconverged pixel ({uy},{ux}) must be NaN, got {v}"
3201 );
3202 }
3203 if let Some(ref m) = result.back_f_map {
3204 let v = m[[uy, ux]];
3205 assert!(
3206 v.is_nan(),
3207 "back_f_map at unconverged pixel ({uy},{ux}) must be NaN, got {v}"
3208 );
3209 }
3210 }
3211
3212 #[test]
3217 fn test_spatial_map_back_d_f_maps_none_when_fit_disabled() {
3218 let rd = u238_single_resonance();
3219 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3220 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3221 let data = InputData3D::Transmission {
3222 transmission: t_3d.view(),
3223 uncertainty: u_3d.view(),
3224 };
3225 let config = UnifiedFitConfig::new(
3226 energies,
3227 vec![rd],
3228 vec!["U-238".into()],
3229 0.0,
3230 None,
3231 vec![0.001],
3232 )
3233 .unwrap()
3234 .with_transmission_background(crate::pipeline::BackgroundConfig::default());
3237
3238 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
3239 assert!(
3240 result.background_maps.is_some(),
3241 "background_maps should be Some when transmission_background is attached"
3242 );
3243 assert!(
3244 result.back_d_map.is_none(),
3245 "back_d_map must be None when fit_back_d=false"
3246 );
3247 assert!(
3248 result.back_f_map.is_none(),
3249 "back_f_map must be None when fit_back_f=false"
3250 );
3251 }
3252
3253 #[test]
3264 fn test_spatial_map_back_d_f_maps_some_when_fit_enabled() {
3265 let rd = u238_single_resonance();
3266 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
3268 let true_density = 0.0005;
3269 let true_back_d = 0.03;
3270 let true_back_f = 2.0;
3271 let (mut t_3d, u_3d) = synthetic_4x4_transmission(&rd, true_density, &energies);
3277 for (i, &e) in energies.iter().enumerate() {
3278 let inv_sqrt_e = 1.0 / e.sqrt();
3279 let tail = true_back_d * (-true_back_f * inv_sqrt_e).exp();
3280 for y in 0..4 {
3281 for x in 0..4 {
3282 t_3d[[i, y, x]] += tail;
3283 }
3284 }
3285 }
3286 let data = InputData3D::Transmission {
3287 transmission: t_3d.view(),
3288 uncertainty: u_3d.view(),
3289 };
3290 let bg = crate::pipeline::BackgroundConfig {
3295 fit_back_d: true,
3296 fit_back_f: true,
3297 back_d_init: 0.01,
3298 back_f_init: 1.0,
3299 ..crate::pipeline::BackgroundConfig::default()
3300 };
3301 let config = UnifiedFitConfig::new(
3302 energies,
3303 vec![rd],
3304 vec!["U-238".into()],
3305 0.0,
3306 None,
3307 vec![true_density],
3308 )
3309 .unwrap()
3310 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig {
3311 max_iter: 500,
3312 ..LmConfig::default()
3313 }))
3314 .with_transmission_background(bg);
3315
3316 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
3317 let bd = result
3318 .back_d_map
3319 .as_ref()
3320 .expect("back_d_map should be Some when fit_back_d=true");
3321 let bf = result
3322 .back_f_map
3323 .as_ref()
3324 .expect("back_f_map should be Some when fit_back_f=true");
3325 assert_eq!(bd.shape(), [4, 4]);
3326 assert_eq!(bf.shape(), [4, 4]);
3327 assert!(
3328 result.n_converged > 0,
3329 "no pixels converged with LM + 7-param transmission background \
3330 on synthetic data carrying an exponential tail — test fixture \
3331 is no longer exercising the gating contract"
3332 );
3333 let mut n_finite_d = 0;
3336 let mut n_finite_f = 0;
3337 for y in 0..4 {
3338 for x in 0..4 {
3339 if result.converged_map[[y, x]] {
3340 if bd[[y, x]].is_finite() {
3341 n_finite_d += 1;
3342 }
3343 if bf[[y, x]].is_finite() {
3344 n_finite_f += 1;
3345 }
3346 } else {
3347 assert!(
3348 bd[[y, x]].is_nan(),
3349 "back_d_map at unconverged ({y},{x}) must be NaN"
3350 );
3351 assert!(
3352 bf[[y, x]].is_nan(),
3353 "back_f_map at unconverged ({y},{x}) must be NaN"
3354 );
3355 }
3356 }
3357 }
3358 assert!(
3361 n_finite_d > 0 && n_finite_f > 0,
3362 "at least one converged pixel must produce finite back_d/back_f \
3363 (n_converged={}, n_finite_d={n_finite_d}, n_finite_f={n_finite_f})",
3364 result.n_converged
3365 );
3366 }
3367
3368 #[test]
3373 fn test_spatial_map_counts_kl_back_d_f_maps_are_none() {
3374 let rd = u238_single_resonance();
3375 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
3376 let (sample, ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
3377 let data = InputData3D::Counts {
3378 sample_counts: sample.view(),
3379 open_beam_counts: ob.view(),
3380 };
3381 let config = UnifiedFitConfig::new(
3382 energies,
3383 vec![rd],
3384 vec!["U-238".into()],
3385 0.0,
3386 None,
3387 vec![0.001],
3388 )
3389 .unwrap()
3390 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
3391 .with_counts_background(crate::pipeline::CountsBackgroundConfig::default());
3392 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
3393 assert!(
3394 result.back_d_map.is_none(),
3395 "back_d_map must be None on the counts-KL path"
3396 );
3397 assert!(
3398 result.back_f_map.is_none(),
3399 "back_f_map must be None on the counts-KL path"
3400 );
3401 }
3402
3403 #[test]
3408 fn test_spatial_map_back_d_f_unpaired_rejected_up_front() {
3409 let rd = u238_single_resonance();
3410 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3411 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3412 let data = InputData3D::Transmission {
3413 transmission: t_3d.view(),
3414 uncertainty: u_3d.view(),
3415 };
3416 let bg = crate::pipeline::BackgroundConfig {
3417 fit_back_d: true,
3418 fit_back_f: false, back_d_init: 0.01,
3420 back_f_init: 1.0,
3421 ..crate::pipeline::BackgroundConfig::default()
3422 };
3423 let config = UnifiedFitConfig::new(
3424 energies,
3425 vec![rd],
3426 vec!["U-238".into()],
3427 0.0,
3428 None,
3429 vec![0.001],
3430 )
3431 .unwrap()
3432 .with_transmission_background(bg);
3433 let err = spatial_map_typed(&data, &config, None, None, None)
3434 .expect_err("unpaired fit_back_d/fit_back_f must be rejected up-front");
3435 let msg = err.to_string();
3436 assert!(
3437 msg.contains("fit_back_d") && msg.contains("fit_back_f"),
3438 "error message must reference both fit flags, got: {msg}"
3439 );
3440 }
3441
3442 #[test]
3446 fn test_spatial_map_back_d_init_non_positive_rejected() {
3447 let rd = u238_single_resonance();
3448 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3449 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3450 let data = InputData3D::Transmission {
3451 transmission: t_3d.view(),
3452 uncertainty: u_3d.view(),
3453 };
3454 let bg = crate::pipeline::BackgroundConfig {
3455 fit_back_d: true,
3456 fit_back_f: true,
3457 back_d_init: 0.0, back_f_init: 1.0,
3459 ..crate::pipeline::BackgroundConfig::default()
3460 };
3461 let config = UnifiedFitConfig::new(
3462 energies,
3463 vec![rd],
3464 vec!["U-238".into()],
3465 0.0,
3466 None,
3467 vec![0.001],
3468 )
3469 .unwrap()
3470 .with_transmission_background(bg);
3471 let err = spatial_map_typed(&data, &config, None, None, None)
3472 .expect_err("back_d_init=0.0 with fit_back_d=true must be rejected up-front");
3473 assert!(
3474 err.to_string().contains("back_d_init"),
3475 "error must reference back_d_init, got: {err}"
3476 );
3477 }
3478
3479 #[test]
3483 fn test_spatial_map_back_f_init_non_positive_rejected() {
3484 let rd = u238_single_resonance();
3485 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3486 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3487 let data = InputData3D::Transmission {
3488 transmission: t_3d.view(),
3489 uncertainty: u_3d.view(),
3490 };
3491 let bg = crate::pipeline::BackgroundConfig {
3492 fit_back_d: true,
3493 fit_back_f: true,
3494 back_d_init: 0.01,
3495 back_f_init: -1.0, ..crate::pipeline::BackgroundConfig::default()
3497 };
3498 let config = UnifiedFitConfig::new(
3499 energies,
3500 vec![rd],
3501 vec!["U-238".into()],
3502 0.0,
3503 None,
3504 vec![0.001],
3505 )
3506 .unwrap()
3507 .with_transmission_background(bg);
3508 let err = spatial_map_typed(&data, &config, None, None, None)
3509 .expect_err("back_f_init=-1.0 with fit_back_f=true must be rejected up-front");
3510 assert!(
3511 err.to_string().contains("back_f_init"),
3512 "error must reference back_f_init, got: {err}"
3513 );
3514 }
3515
3516 #[test]
3521 fn test_spatial_map_back_d_init_nan_rejected() {
3522 let rd = u238_single_resonance();
3523 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3524 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3525 let data = InputData3D::Transmission {
3526 transmission: t_3d.view(),
3527 uncertainty: u_3d.view(),
3528 };
3529 let bg = crate::pipeline::BackgroundConfig {
3530 fit_back_d: true,
3531 fit_back_f: true,
3532 back_d_init: f64::NAN, back_f_init: 1.0,
3534 ..crate::pipeline::BackgroundConfig::default()
3535 };
3536 let config = UnifiedFitConfig::new(
3537 energies,
3538 vec![rd],
3539 vec!["U-238".into()],
3540 0.0,
3541 None,
3542 vec![0.001],
3543 )
3544 .unwrap()
3545 .with_transmission_background(bg);
3546 let err = spatial_map_typed(&data, &config, None, None, None)
3547 .expect_err("NaN back_d_init must be rejected up-front");
3548 let msg = err.to_string();
3549 assert!(
3550 msg.contains("back_d_init") && (msg.contains("finite") || msg.contains("NaN")),
3551 "error must mention finite/NaN for back_d_init, got: {msg}"
3552 );
3553 }
3554
3555 #[test]
3559 fn test_spatial_map_back_f_init_inf_rejected() {
3560 let rd = u238_single_resonance();
3561 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3562 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3563 let data = InputData3D::Transmission {
3564 transmission: t_3d.view(),
3565 uncertainty: u_3d.view(),
3566 };
3567 let bg = crate::pipeline::BackgroundConfig {
3568 fit_back_d: true,
3569 fit_back_f: true,
3570 back_d_init: 0.01,
3571 back_f_init: f64::INFINITY, ..crate::pipeline::BackgroundConfig::default()
3573 };
3574 let config = UnifiedFitConfig::new(
3575 energies,
3576 vec![rd],
3577 vec!["U-238".into()],
3578 0.0,
3579 None,
3580 vec![0.001],
3581 )
3582 .unwrap()
3583 .with_transmission_background(bg);
3584 let err = spatial_map_typed(&data, &config, None, None, None)
3585 .expect_err("+inf back_f_init must be rejected up-front");
3586 let msg = err.to_string();
3587 assert!(
3588 msg.contains("back_f_init") && (msg.contains("finite") || msg.contains("inf")),
3589 "error must mention finite/inf for back_f_init, got: {msg}"
3590 );
3591 }
3592
3593 #[test]
3599 fn test_spatial_map_counts_kl_plus_back_d_rejected_up_front() {
3600 let rd = u238_single_resonance();
3601 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3602 let (sample, ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
3603 let data = InputData3D::Counts {
3604 sample_counts: sample.view(),
3605 open_beam_counts: ob.view(),
3606 };
3607 let bg = crate::pipeline::BackgroundConfig {
3608 fit_back_d: true,
3609 fit_back_f: true,
3610 back_d_init: 0.01,
3611 back_f_init: 1.0,
3612 ..crate::pipeline::BackgroundConfig::default()
3613 };
3614 let config = UnifiedFitConfig::new(
3615 energies,
3616 vec![rd],
3617 vec!["U-238".into()],
3618 0.0,
3619 None,
3620 vec![0.001],
3621 )
3622 .unwrap()
3623 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
3624 .with_transmission_background(bg);
3625 let err = spatial_map_typed(&data, &config, None, None, None)
3626 .expect_err("counts-KL + fit_back_d/fit_back_f must be rejected up-front");
3627 let msg = err.to_string();
3628 assert!(
3629 msg.contains("counts-KL") || msg.contains("joint-Poisson"),
3630 "error must reference the counts-KL incompatibility, got: {msg}"
3631 );
3632 }
3633
3634 #[test]
3640 fn test_spatial_map_counts_with_nuisance_plus_lm_rejected_up_front() {
3641 use ndarray::Array3;
3642 let rd = u238_single_resonance();
3643 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3644 let (sample, _ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
3645 let flux: Array3<f64> = Array3::from_elem((energies.len(), 4, 4), 1000.0);
3649 let background: Array3<f64> = Array3::from_elem((energies.len(), 4, 4), 0.0);
3650 let data = InputData3D::CountsWithNuisance {
3651 sample_counts: sample.view(),
3652 flux: flux.view(),
3653 background: background.view(),
3654 };
3655 let config = UnifiedFitConfig::new(
3656 energies,
3657 vec![rd],
3658 vec!["U-238".into()],
3659 0.0,
3660 None,
3661 vec![0.001],
3662 )
3663 .unwrap()
3664 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
3665 let err = spatial_map_typed(&data, &config, None, None, None)
3666 .expect_err("CountsWithNuisance + LM must be rejected up-front");
3667 let msg = err.to_string();
3668 assert!(
3669 msg.contains("CountsWithNuisance") && msg.contains("counts-domain"),
3670 "error must mention CountsWithNuisance + counts-domain requirement, got: {msg}"
3671 );
3672 }
3673
3674 #[test]
3684 fn test_spatial_map_reports_solver_mismatch_before_fit_range_gate() {
3685 use ndarray::Array3;
3686 let rd = u238_single_resonance();
3687 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3688 let (sample, _ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
3689 let flux: Array3<f64> = Array3::from_elem((energies.len(), 4, 4), 1000.0);
3690 let background: Array3<f64> = Array3::from_elem((energies.len(), 4, 4), 0.0);
3691 let data = InputData3D::CountsWithNuisance {
3692 sample_counts: sample.view(),
3693 flux: flux.view(),
3694 background: background.view(),
3695 };
3696 let config = UnifiedFitConfig::new(
3704 energies,
3705 vec![rd],
3706 vec!["U-238".into()],
3707 0.0,
3708 None,
3709 vec![0.001],
3710 )
3711 .unwrap()
3712 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
3713 .with_fit_energy_range(Some((5.0, 5.05)))
3714 .unwrap();
3715
3716 let err = spatial_map_typed(&data, &config, None, None, None)
3717 .expect_err("CountsWithNuisance + LM + narrow fit_energy_range must be rejected");
3718 let msg = err.to_string();
3719 assert!(
3720 matches!(err, PipelineError::InvalidParameter(_)),
3721 "expected InvalidParameter, got {err:?}"
3722 );
3723 assert!(
3724 msg.contains("CountsWithNuisance") && msg.contains("counts-domain"),
3725 "error must surface the solver mismatch (not the fit-range gate), got: {msg}"
3726 );
3727 assert!(
3728 !msg.contains("active bin"),
3729 "error must not be the downstream fit-range diagnostic, got: {msg}"
3730 );
3731 }
3732
3733 #[test]
3740 fn test_spatial_map_typed_counts_kl_populates_deviance_per_dof_map() {
3741 let data = u238_single_resonance();
3742 let true_density = 0.0005;
3743 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
3744 let (t_3d, _) = synthetic_4x4_transmission(&data, true_density, &energies);
3745 let n_e = energies.len();
3746
3747 let c_val = 2.0_f64;
3749 let lam_ob = 500.0_f64;
3750 let mut sample = Array3::zeros((n_e, 4, 4));
3751 let mut open_beam = Array3::from_elem((n_e, 4, 4), lam_ob);
3752 for y in 0..4 {
3753 for x in 0..4 {
3754 for (i, _) in energies.iter().enumerate() {
3755 open_beam[[i, y, x]] = lam_ob;
3756 sample[[i, y, x]] = c_val * lam_ob * t_3d[[i, y, x]];
3757 }
3758 }
3759 }
3760
3761 let config = UnifiedFitConfig::new(
3762 energies,
3763 vec![data],
3764 vec!["U-238".into()],
3765 0.0,
3766 None,
3767 vec![0.001],
3768 )
3769 .unwrap()
3770 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
3771 .with_counts_background(crate::pipeline::CountsBackgroundConfig {
3772 c: c_val,
3773 ..Default::default()
3774 });
3775
3776 let input = InputData3D::Counts {
3777 sample_counts: sample.view(),
3778 open_beam_counts: open_beam.view(),
3779 };
3780 let r = spatial_map_typed(&input, &config, None, None, None).unwrap();
3781 let dpd = r
3783 .deviance_per_dof_map
3784 .as_ref()
3785 .expect("counts-KL spatial should populate deviance_per_dof_map");
3786 assert_eq!(dpd.shape(), &[4, 4]);
3787 let sample_val = dpd[[0, 0]];
3788 assert!(
3789 sample_val.is_finite(),
3790 "deviance_per_dof_map[0,0] = {sample_val} (should be finite)"
3791 );
3792 let density_mean: f64 = r.density_maps[0].iter().copied().sum::<f64>() / 16.0;
3794 assert!(
3795 (density_mean - true_density).abs() / true_density < 0.05,
3796 "mean density {density_mean} vs truth {true_density}",
3797 );
3798 }
3799
3800 #[test]
3806 fn test_apply_spatial_polish_default_multi_pixel_auto_disables() {
3807 let data = u238_single_resonance();
3810 let energies: Vec<f64> = (0..10).map(|i| 1.0 + i as f64).collect();
3811 let cfg = UnifiedFitConfig::new(
3812 energies,
3813 vec![data],
3814 vec!["U-238".into()],
3815 0.0,
3816 None,
3817 vec![0.001],
3818 )
3819 .unwrap();
3820
3821 assert_eq!(cfg.counts_enable_polish(), None);
3823 let resolved = apply_spatial_polish_default(cfg.clone(), 16);
3824 assert_eq!(
3825 resolved.counts_enable_polish(),
3826 Some(false),
3827 "multi-pixel with no override should auto-disable polish"
3828 );
3829
3830 let resolved = apply_spatial_polish_default(cfg.clone(), 1);
3832 assert_eq!(
3833 resolved.counts_enable_polish(),
3834 None,
3835 "single-pixel should preserve the caller's unset state"
3836 );
3837
3838 let cfg_forced_on = cfg.clone().with_counts_enable_polish(Some(true));
3840 let resolved = apply_spatial_polish_default(cfg_forced_on, 16);
3841 assert_eq!(
3842 resolved.counts_enable_polish(),
3843 Some(true),
3844 "caller override Some(true) must be preserved for multi-pixel"
3845 );
3846
3847 let cfg_forced_off = cfg.with_counts_enable_polish(Some(false));
3849 let resolved = apply_spatial_polish_default(cfg_forced_off, 16);
3850 assert_eq!(resolved.counts_enable_polish(), Some(false));
3851 }
3852
3853 #[test]
3858 fn test_spatial_map_typed_counts_kl_populates_map_without_polish_regression() {
3859 let data = u238_single_resonance();
3860 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.1).collect();
3861 let (t_3d, _) = synthetic_4x4_transmission(&data, 0.0005, &energies);
3862 let n_e = energies.len();
3863
3864 let mut sample = Array3::zeros((n_e, 4, 4));
3865 let open_beam = Array3::from_elem((n_e, 4, 4), 500.0);
3866 for y in 0..4 {
3867 for x in 0..4 {
3868 for i in 0..n_e {
3869 sample[[i, y, x]] = 500.0 * t_3d[[i, y, x]];
3870 }
3871 }
3872 }
3873
3874 let config = UnifiedFitConfig::new(
3875 energies,
3876 vec![data],
3877 vec!["U-238".into()],
3878 0.0,
3879 None,
3880 vec![0.001],
3881 )
3882 .unwrap()
3883 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
3884
3885 let input = InputData3D::Counts {
3886 sample_counts: sample.view(),
3887 open_beam_counts: open_beam.view(),
3888 };
3889 let r = spatial_map_typed(&input, &config, None, None, None).unwrap();
3890 assert!(r.deviance_per_dof_map.is_some());
3891 let dpd = r.deviance_per_dof_map.as_ref().unwrap();
3893 assert!(dpd.iter().all(|v| v.is_finite()));
3894 }
3895
3896 #[test]
3901 fn test_spatial_map_typed_counts_lm_no_deviance_map() {
3902 let data = u238_single_resonance();
3903 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.1).collect();
3904 let (t_3d, _) = synthetic_4x4_transmission(&data, 0.0005, &energies);
3905 let n_e = energies.len();
3906 let mut sample = Array3::zeros((n_e, 4, 4));
3907 let open_beam = Array3::from_elem((n_e, 4, 4), 500.0);
3908 for y in 0..4 {
3909 for x in 0..4 {
3910 for i in 0..n_e {
3911 sample[[i, y, x]] = 500.0 * t_3d[[i, y, x]];
3912 }
3913 }
3914 }
3915
3916 let config = UnifiedFitConfig::new(
3917 energies,
3918 vec![data],
3919 vec!["U-238".into()],
3920 0.0,
3921 None,
3922 vec![0.001],
3923 )
3924 .unwrap()
3925 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
3928
3929 let input = InputData3D::Counts {
3930 sample_counts: sample.view(),
3931 open_beam_counts: open_beam.view(),
3932 };
3933 let r = spatial_map_typed(&input, &config, None, None, None).unwrap();
3934 assert!(
3935 r.deviance_per_dof_map.is_none(),
3936 "(Counts, LM) must not allocate deviance_per_dof_map (would mislabel GOF in GUI)"
3937 );
3938 assert!(r.chi_squared_map.iter().any(|v| v.is_finite()));
3940 }
3941
3942 #[test]
3945 fn test_spatial_map_typed_transmission_no_deviance_map() {
3946 let data = u238_single_resonance();
3947 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.1).collect();
3948 let (t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
3949
3950 let config = UnifiedFitConfig::new(
3951 energies,
3952 vec![data],
3953 vec!["U-238".into()],
3954 0.0,
3955 None,
3956 vec![0.001],
3957 )
3958 .unwrap();
3959 let input = InputData3D::Transmission {
3960 transmission: t_3d.view(),
3961 uncertainty: u_3d.view(),
3962 };
3963 let r = spatial_map_typed(&input, &config, None, None, None).unwrap();
3964 assert!(r.deviance_per_dof_map.is_none());
3965 }
3966
3967 #[test]
3974 fn test_spatial_map_typed_fit_energy_scale_populates_maps() {
3975 let rd = u238_single_resonance();
3976 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
3977 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3978 let data = InputData3D::Transmission {
3979 transmission: t_3d.view(),
3980 uncertainty: u_3d.view(),
3981 };
3982 let config = UnifiedFitConfig::new(
3983 energies,
3984 vec![rd],
3985 vec!["U-238".into()],
3986 0.0,
3987 None,
3988 vec![0.0005],
3989 )
3990 .unwrap()
3991 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
3992 .with_energy_scale(0.0, 1.0, 25.0);
3993
3994 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
3995 let t0_map = result
3996 .t0_us_map
3997 .as_ref()
3998 .expect("t0_us_map must be Some when fit_energy_scale=true");
3999 let l_map = result
4000 .l_scale_map
4001 .as_ref()
4002 .expect("l_scale_map must be Some when fit_energy_scale=true");
4003 assert_eq!(t0_map.shape(), [4, 4]);
4004 assert_eq!(l_map.shape(), [4, 4]);
4005 for y in 0..4 {
4013 for x in 0..4 {
4014 let converged = result.converged_map[[y, x]];
4015 let t0 = t0_map[[y, x]];
4016 let ls = l_map[[y, x]];
4017 if converged {
4018 assert!(
4019 t0.is_finite() && ls.is_finite(),
4020 "converged pixel ({y},{x}) must have finite t0/L, got t0={t0}, L={ls}"
4021 );
4022 } else {
4023 assert!(
4024 t0.is_nan() && ls.is_nan(),
4025 "un-converged pixel ({y},{x}) must have NaN t0/L (B1 gating), got t0={t0}, L={ls}"
4026 );
4027 }
4028 }
4029 }
4030 }
4031
4032 #[test]
4034 fn test_spatial_map_typed_no_energy_scale_no_maps() {
4035 let rd = u238_single_resonance();
4036 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4037 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
4038 let data = InputData3D::Transmission {
4039 transmission: t_3d.view(),
4040 uncertainty: u_3d.view(),
4041 };
4042 let config = UnifiedFitConfig::new(
4043 energies,
4044 vec![rd],
4045 vec!["U-238".into()],
4046 0.0,
4047 None,
4048 vec![0.0005],
4049 )
4050 .unwrap()
4051 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
4052
4053 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
4054 assert!(result.t0_us_map.is_none());
4055 assert!(result.l_scale_map.is_none());
4056 }
4057
4058 #[test]
4063 fn test_spatial_map_typed_rejects_counts_lm_with_energy_scale() {
4064 let rd = u238_single_resonance();
4065 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4066 let (sample, ob) = synthetic_4x4_counts(&rd, 0.001, &energies, 1000.0);
4067 let data = InputData3D::Counts {
4068 sample_counts: sample.view(),
4069 open_beam_counts: ob.view(),
4070 };
4071 let config = UnifiedFitConfig::new(
4072 energies,
4073 vec![rd],
4074 vec!["U-238".into()],
4075 0.0,
4076 None,
4077 vec![0.0005],
4078 )
4079 .unwrap()
4080 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4081 .with_energy_scale(0.0, 1.0, 25.0);
4082
4083 let err = spatial_map_typed(&data, &config, None, None, None)
4084 .expect_err("LM + counts + fit_energy_scale must be rejected");
4085 let msg = err.to_string();
4086 assert!(
4087 msg.contains("fit_energy_scale") && msg.contains("lm"),
4088 "error message should name both culprits, got: {msg}"
4089 );
4090 assert!(
4091 msg.contains("#458"),
4092 "error message should reference the tracking issue, got: {msg}"
4093 );
4094 }
4095
4096 #[test]
4099 fn test_spatial_map_typed_allows_counts_kl_with_energy_scale() {
4100 let rd = u238_single_resonance();
4101 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4102 let (sample, ob) = synthetic_4x4_counts(&rd, 0.001, &energies, 1000.0);
4103 let data = InputData3D::Counts {
4104 sample_counts: sample.view(),
4105 open_beam_counts: ob.view(),
4106 };
4107 let config = UnifiedFitConfig::new(
4108 energies,
4109 vec![rd],
4110 vec!["U-238".into()],
4111 0.0,
4112 None,
4113 vec![0.0005],
4114 )
4115 .unwrap()
4116 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4117 .with_energy_scale(0.0, 1.0, 25.0);
4118
4119 let result = spatial_map_typed(&data, &config, None, None, None)
4120 .expect("KL + counts + fit_energy_scale must be allowed");
4121 assert!(result.t0_us_map.is_some());
4122 }
4123
4124 #[test]
4133 fn test_spatial_map_typed_allows_energy_scale_with_temperature() {
4134 let rd = u238_single_resonance();
4135 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4136 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
4137 let data = InputData3D::Transmission {
4138 transmission: t_3d.view(),
4139 uncertainty: u_3d.view(),
4140 };
4141 let config = UnifiedFitConfig::new(
4142 energies,
4143 vec![rd],
4144 vec!["U-238".into()],
4145 300.0,
4146 None,
4147 vec![0.0005],
4148 )
4149 .unwrap()
4150 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4151 .with_fit_temperature(true)
4152 .with_energy_scale(0.0, 1.0, 25.0);
4153
4154 let result = spatial_map_typed(&data, &config, None, None, None)
4155 .expect("fit_energy_scale + fit_temperature is now supported (#634)");
4156 assert_eq!(result.n_total, 16, "4×4 map");
4157 assert!(
4160 result.n_converged >= 14,
4161 "joint fit should converge on (nearly) all pixels, got {}/16",
4162 result.n_converged
4163 );
4164 let finite_count = |m: &Option<ndarray::Array2<f64>>| {
4167 m.as_ref()
4168 .expect("map allocated when its flag is set")
4169 .iter()
4170 .filter(|v| v.is_finite())
4171 .count()
4172 };
4173 for (name, map) in [
4174 ("temperature_map", &result.temperature_map),
4175 ("t0_us_map", &result.t0_us_map),
4176 ("l_scale_map", &result.l_scale_map),
4177 ] {
4178 let n_finite = finite_count(map);
4179 assert!(
4180 n_finite >= result.n_converged,
4181 "{name}: {n_finite} finite entries < {} converged pixels — \
4182 converged pixels must write finite values",
4183 result.n_converged
4184 );
4185 }
4186 }
4187
4188 #[test]
4194 fn test_spatial_map_typed_allows_transmission_lm_with_energy_scale() {
4195 let rd = u238_single_resonance();
4196 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4197 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
4198 let data = InputData3D::Transmission {
4199 transmission: t_3d.view(),
4200 uncertainty: u_3d.view(),
4201 };
4202 let config = UnifiedFitConfig::new(
4203 energies,
4204 vec![rd],
4205 vec!["U-238".into()],
4206 0.0,
4207 None,
4208 vec![0.0005],
4209 )
4210 .unwrap()
4211 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4212 .with_energy_scale(0.0, 1.0, 25.0);
4213
4214 let result = spatial_map_typed(&data, &config, None, None, None)
4215 .expect("LM + transmission + fit_energy_scale must be allowed");
4216 assert!(result.t0_us_map.is_some());
4217 }
4218
4219 #[test]
4231 fn test_spatial_map_rejects_fit_temperature_below_one_up_front() {
4232 let rd = u238_single_resonance();
4233 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4234 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
4235 let data = InputData3D::Transmission {
4236 transmission: t_3d.view(),
4237 uncertainty: u_3d.view(),
4238 };
4239 let config = UnifiedFitConfig::new(
4243 energies,
4244 vec![rd],
4245 vec!["U-238".into()],
4246 0.5,
4247 None,
4248 vec![0.001],
4249 )
4250 .unwrap()
4251 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4252 .with_fit_temperature(true);
4253
4254 let err = spatial_map_typed(&data, &config, None, None, None)
4255 .expect_err("fit_temperature with temperature_k < 1.0 must be rejected up-front");
4256 let msg = err.to_string();
4257 assert!(
4258 matches!(err, PipelineError::InvalidParameter(_)),
4259 "expected InvalidParameter, got {err:?}"
4260 );
4261 assert!(
4262 msg.contains("temperature") && msg.contains("1.0"),
4263 "error must mention the 1.0 K floor, got: {msg}"
4264 );
4265 }
4266
4267 #[test]
4268 fn test_spatial_map_transmission_poisson_rejects_fit_energy_range_up_front() {
4269 let rd = u238_single_resonance();
4270 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4271 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
4272 let data = InputData3D::Transmission {
4273 transmission: t_3d.view(),
4274 uncertainty: u_3d.view(),
4275 };
4276 let config = UnifiedFitConfig::new(
4281 energies,
4282 vec![rd],
4283 vec!["U-238".into()],
4284 0.0,
4285 None,
4286 vec![0.001],
4287 )
4288 .unwrap()
4289 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4290 .with_fit_energy_range(Some((2.0, 8.0)))
4291 .unwrap();
4292
4293 let err = spatial_map_typed(&data, &config, None, None, None)
4294 .expect_err("transmission + Poisson-KL + fit_energy_range must be rejected up-front");
4295 let msg = err.to_string();
4296 assert!(
4297 matches!(err, PipelineError::InvalidParameter(_)),
4298 "expected InvalidParameter, got {err:?}"
4299 );
4300 assert!(
4301 msg.contains("fit_energy_range") && msg.contains("Poisson-KL"),
4302 "error must name the incompatibility, got: {msg}"
4303 );
4304 }
4305
4306 #[test]
4307 fn test_spatial_map_lm_rejects_too_narrow_fit_energy_range_up_front() {
4308 let rd = u238_single_resonance();
4309 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4311 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
4312 let data = InputData3D::Transmission {
4313 transmission: t_3d.view(),
4314 uncertainty: u_3d.view(),
4315 };
4316 let config = UnifiedFitConfig::new(
4319 energies,
4320 vec![rd],
4321 vec!["U-238".into()],
4322 0.0,
4323 None,
4324 vec![0.001],
4325 )
4326 .unwrap()
4327 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4328 .with_fit_energy_range(Some((5.0, 5.05)))
4329 .unwrap();
4330
4331 let err = spatial_map_typed(&data, &config, None, None, None)
4332 .expect_err("LM with too-narrow fit_energy_range must be rejected up-front");
4333 let msg = err.to_string();
4334 assert!(
4335 matches!(err, PipelineError::InvalidParameter(_)),
4336 "expected InvalidParameter, got {err:?}"
4337 );
4338 assert!(
4339 msg.contains("active bin") && msg.contains("LM transmission"),
4340 "error must mention narrow active-bin count for the LM path, got: {msg}"
4341 );
4342 }
4343
4344 #[test]
4345 fn test_spatial_map_counts_kl_rejects_too_narrow_fit_energy_range_up_front() {
4346 let rd = u238_single_resonance();
4347 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4348 let (sample, ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
4349 let data = InputData3D::Counts {
4350 sample_counts: sample.view(),
4351 open_beam_counts: ob.view(),
4352 };
4353 let config = UnifiedFitConfig::new(
4354 energies,
4355 vec![rd],
4356 vec!["U-238".into()],
4357 0.0,
4358 None,
4359 vec![0.001],
4360 )
4361 .unwrap()
4362 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4363 .with_fit_energy_range(Some((5.0, 5.05)))
4364 .unwrap();
4365
4366 let err = spatial_map_typed(&data, &config, None, None, None)
4367 .expect_err("counts-KL with too-narrow fit_energy_range must be rejected up-front");
4368 let msg = err.to_string();
4369 assert!(
4370 matches!(err, PipelineError::InvalidParameter(_)),
4371 "expected InvalidParameter, got {err:?}"
4372 );
4373 assert!(
4374 msg.contains("active bin") && msg.contains("joint-Poisson"),
4375 "error must mention narrow active-bin count for the joint-Poisson path, got: {msg}"
4376 );
4377 }
4378
4379 #[test]
4380 fn test_spatial_map_counts_kl_rejects_invalid_c_up_front() {
4381 let rd = u238_single_resonance();
4382 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4383 let (sample, ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
4384 let data = InputData3D::Counts {
4385 sample_counts: sample.view(),
4386 open_beam_counts: ob.view(),
4387 };
4388 let config = UnifiedFitConfig::new(
4393 energies,
4394 vec![rd],
4395 vec!["U-238".into()],
4396 0.0,
4397 None,
4398 vec![0.001],
4399 )
4400 .unwrap()
4401 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4402 .with_counts_background(crate::pipeline::CountsBackgroundConfig {
4403 c: -1.0,
4404 ..Default::default()
4405 });
4406
4407 let err = spatial_map_typed(&data, &config, None, None, None)
4408 .expect_err("counts-KL with non-positive c must be rejected up-front");
4409 let msg = err.to_string();
4410 assert!(
4411 matches!(err, PipelineError::InvalidParameter(_)),
4412 "expected InvalidParameter, got {err:?}"
4413 );
4414 assert!(
4415 msg.contains("finite c > 0"),
4416 "error must mention the c > 0 requirement, got: {msg}"
4417 );
4418 }
4419
4420 #[test]
4421 fn test_spatial_map_counts_kl_requires_back_a_for_back_b_c_up_front() {
4422 let rd = u238_single_resonance();
4423 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4424 let (sample, ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
4425 let data = InputData3D::Counts {
4426 sample_counts: sample.view(),
4427 open_beam_counts: ob.view(),
4428 };
4429 let bg = crate::pipeline::BackgroundConfig {
4434 fit_back_a: false,
4435 fit_back_b: true,
4436 fit_back_c: false,
4437 fit_back_d: false,
4438 fit_back_f: false,
4439 ..crate::pipeline::BackgroundConfig::default()
4440 };
4441 let config = UnifiedFitConfig::new(
4442 energies,
4443 vec![rd],
4444 vec!["U-238".into()],
4445 0.0,
4446 None,
4447 vec![0.001],
4448 )
4449 .unwrap()
4450 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4451 .with_transmission_background(bg);
4452
4453 let err = spatial_map_typed(&data, &config, None, None, None)
4454 .expect_err("counts-KL with B_B but no B_A must be rejected up-front");
4455 let msg = err.to_string();
4456 assert!(
4457 matches!(err, PipelineError::InvalidParameter(_)),
4458 "expected InvalidParameter, got {err:?}"
4459 );
4460 assert!(
4461 msg.contains("B_A") && msg.contains("fit_back_a"),
4462 "error must name the B_A requirement, got: {msg}"
4463 );
4464 }
4465
4466 #[test]
4476 fn test_spatial_map_rejects_underdetermined_fit_range() {
4477 let rd = u238_single_resonance();
4478 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4479 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
4480 let data = InputData3D::Transmission {
4481 transmission: t_3d.view(),
4482 uncertainty: u_3d.view(),
4483 };
4484 let bg = crate::pipeline::BackgroundConfig::default();
4494 let config = UnifiedFitConfig::new(
4495 energies,
4496 vec![rd],
4497 vec!["U-238".into()],
4498 293.0,
4502 None,
4503 vec![0.001],
4504 )
4505 .unwrap()
4506 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4507 .with_fit_temperature(true)
4508 .with_transmission_background(bg)
4509 .with_fit_energy_range(Some((5.0, 5.5)))
4510 .unwrap();
4511
4512 let err = spatial_map_typed(&data, &config, None, None, None)
4513 .expect_err("underdetermined fit_energy_range must be rejected up-front");
4514 let msg = err.to_string();
4515 assert!(
4516 matches!(err, PipelineError::InvalidParameter(_)),
4517 "expected InvalidParameter, got {err:?}"
4518 );
4519 assert!(
4523 msg.contains("active bin")
4524 && msg.contains("free parameter")
4525 && msg.contains("underdetermined"),
4526 "error must explain the underdetermined condition, got: {msg}"
4527 );
4528 }
4529
4530 fn lm_transmission_config(
4540 energies: Vec<f64>,
4541 data: nereids_endf::resonance::ResonanceData,
4542 ) -> UnifiedFitConfig {
4543 UnifiedFitConfig::new(
4544 energies,
4545 vec![data],
4546 vec!["U-238".into()],
4547 0.0,
4548 None,
4549 vec![0.001],
4550 )
4551 .unwrap()
4552 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4553 }
4554
4555 fn kl_counts_config(
4556 energies: Vec<f64>,
4557 data: nereids_endf::resonance::ResonanceData,
4558 ) -> UnifiedFitConfig {
4559 UnifiedFitConfig::new(
4560 energies,
4561 vec![data],
4562 vec!["U-238".into()],
4563 0.0,
4564 None,
4565 vec![0.001],
4566 )
4567 .unwrap()
4568 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4569 }
4570
4571 #[test]
4572 fn test_spatial_rejects_bad_transmission_value() {
4573 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4574 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
4575 let data = u238_single_resonance();
4576 let (mut t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
4577 t_3d[[10, 1, 2]] = bad;
4578 let config = lm_transmission_config(energies.clone(), data);
4579 let input = InputData3D::Transmission {
4580 transmission: t_3d.view(),
4581 uncertainty: u_3d.view(),
4582 };
4583 let err = spatial_map_typed(&input, &config, None, None, None)
4584 .expect_err("non-finite transmission value must be rejected up-front");
4585 assert!(
4586 matches!(err, PipelineError::InvalidParameter(_)),
4587 "got {err:?}"
4588 );
4589 let msg = err.to_string();
4590 assert!(
4591 msg.contains("transmission") && msg.contains("(y="),
4592 "error must name the cube and (y, x, e): {msg}"
4593 );
4594 }
4595 }
4596
4597 #[test]
4598 fn test_spatial_rejects_bad_uncertainty() {
4599 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4603 for bad in [f64::NAN, f64::INFINITY, 0.0, -1.0] {
4604 let data = u238_single_resonance();
4605 let (t_3d, mut u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
4606 u_3d[[9, 1, 0]] = bad;
4607 let config = lm_transmission_config(energies.clone(), data);
4608 let input = InputData3D::Transmission {
4609 transmission: t_3d.view(),
4610 uncertainty: u_3d.view(),
4611 };
4612 let err = spatial_map_typed(&input, &config, None, None, None)
4613 .expect_err("bad uncertainty must be rejected up-front");
4614 assert!(
4615 matches!(err, PipelineError::InvalidParameter(_)),
4616 "got {err:?}"
4617 );
4618 assert!(
4619 err.to_string().contains("uncertainty"),
4620 "error must name the uncertainty cube, got: {err}"
4621 );
4622 }
4623 }
4624
4625 #[test]
4626 fn test_spatial_accepts_negative_transmission_value() {
4627 let data = u238_single_resonance();
4630 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4631 let (mut t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
4632 t_3d[[12, 2, 2]] = -0.05;
4633 let config = lm_transmission_config(energies, data);
4634 let input = InputData3D::Transmission {
4635 transmission: t_3d.view(),
4636 uncertainty: u_3d.view(),
4637 };
4638 let result = spatial_map_typed(&input, &config, None, None, None)
4639 .expect("a finite negative transmission value must not be rejected");
4640 assert_eq!(result.n_total, 16);
4641 }
4642
4643 #[test]
4644 fn test_spatial_rejects_bad_sample_counts() {
4645 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4646 for bad in [f64::NAN, f64::INFINITY, -1.0] {
4647 let data = u238_single_resonance();
4648 let (mut sample, ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4649 sample[[8, 0, 3]] = bad;
4650 let config = kl_counts_config(energies.clone(), data);
4651 let input = InputData3D::Counts {
4652 sample_counts: sample.view(),
4653 open_beam_counts: ob.view(),
4654 };
4655 let err = spatial_map_typed(&input, &config, None, None, None)
4656 .expect_err("bad sample count must be rejected up-front");
4657 assert!(
4658 matches!(err, PipelineError::InvalidParameter(_)),
4659 "got {err:?}"
4660 );
4661 assert!(
4662 err.to_string().contains("sample_counts"),
4663 "error must name the sample_counts cube, got: {err}"
4664 );
4665 }
4666 }
4667
4668 #[test]
4669 fn test_spatial_rejects_bad_open_beam() {
4670 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4674 for bad in [f64::NAN, f64::INFINITY, -1.0] {
4675 let data = u238_single_resonance();
4676 let (sample, mut ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4677 ob[[6, 3, 1]] = bad;
4678 let config = kl_counts_config(energies.clone(), data);
4679 let input = InputData3D::Counts {
4680 sample_counts: sample.view(),
4681 open_beam_counts: ob.view(),
4682 };
4683 let err = spatial_map_typed(&input, &config, None, None, None)
4684 .expect_err("bad open-beam must be rejected up-front");
4685 assert!(
4686 matches!(err, PipelineError::InvalidParameter(_)),
4687 "got {err:?}"
4688 );
4689 assert!(
4690 err.to_string().contains("open_beam_counts"),
4691 "error must name the open_beam_counts cube, got: {err}"
4692 );
4693 }
4694 }
4695
4696 #[test]
4697 fn test_spatial_counts_with_nuisance_rejects_bad_flux() {
4698 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4699 for bad in [f64::NAN, -1.0] {
4700 let data = u238_single_resonance();
4701 let (sample, _ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4702 let mut flux = Array3::from_elem((energies.len(), 4, 4), 1000.0);
4703 let background = Array3::from_elem((energies.len(), 4, 4), 0.0);
4704 flux[[4, 2, 1]] = bad;
4705 let config = kl_counts_config(energies.clone(), data);
4706 let input = InputData3D::CountsWithNuisance {
4707 sample_counts: sample.view(),
4708 flux: flux.view(),
4709 background: background.view(),
4710 };
4711 let err = spatial_map_typed(&input, &config, None, None, None)
4712 .expect_err("bad flux must be rejected up-front");
4713 assert!(
4714 matches!(err, PipelineError::InvalidParameter(_)),
4715 "got {err:?}"
4716 );
4717 assert!(
4718 err.to_string().contains("flux"),
4719 "error must name the flux cube, got: {err}"
4720 );
4721 }
4722 }
4723
4724 #[test]
4725 fn test_spatial_counts_with_nuisance_rejects_nonfinite_background() {
4726 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4730 for bad in [f64::NAN, f64::INFINITY] {
4731 let data = u238_single_resonance();
4732 let (sample, _ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4733 let flux = Array3::from_elem((energies.len(), 4, 4), 1000.0);
4734 let mut background = Array3::from_elem((energies.len(), 4, 4), 0.0);
4735 background[[2, 3, 3]] = bad;
4736 let config = kl_counts_config(energies.clone(), data);
4737 let input = InputData3D::CountsWithNuisance {
4738 sample_counts: sample.view(),
4739 flux: flux.view(),
4740 background: background.view(),
4741 };
4742 let err = spatial_map_typed(&input, &config, None, None, None)
4743 .expect_err("non-finite background must be rejected up-front");
4744 assert!(
4745 matches!(err, PipelineError::InvalidParameter(_)),
4746 "got {err:?}"
4747 );
4748 assert!(
4749 err.to_string().contains("background"),
4750 "error must name the background cube, got: {err}"
4751 );
4752 }
4753 }
4754
4755 #[test]
4756 fn test_spatial_transmission_tolerates_nan_in_inactive_bin() {
4757 let data = u238_single_resonance();
4762 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4763 let (mut t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
4764 t_3d[[0, 1, 1]] = f64::NAN;
4766 let config = lm_transmission_config(energies, data)
4767 .with_fit_energy_range(Some((3.0, 9.0)))
4768 .unwrap();
4769 let input = InputData3D::Transmission {
4770 transmission: t_3d.view(),
4771 uncertainty: u_3d.view(),
4772 };
4773 let result = spatial_map_typed(&input, &config, None, None, None)
4774 .expect("NaN in an inactive (out-of-range) bin must be tolerated");
4775 assert!(
4776 result.n_converged > 0,
4777 "the active-bin fit should still converge"
4778 );
4779 }
4780
4781 #[test]
4782 fn test_spatial_rejects_nan_transmission_in_active_bin_with_range() {
4783 let data = u238_single_resonance();
4786 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4787 let (mut t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
4788 t_3d[[20, 0, 0]] = f64::NAN;
4790 let config = lm_transmission_config(energies, data)
4791 .with_fit_energy_range(Some((3.0, 9.0)))
4792 .unwrap();
4793 let input = InputData3D::Transmission {
4794 transmission: t_3d.view(),
4795 uncertainty: u_3d.view(),
4796 };
4797 let err = spatial_map_typed(&input, &config, None, None, None)
4798 .expect_err("NaN in an active bin must be rejected up-front");
4799 assert!(
4800 matches!(err, PipelineError::InvalidParameter(_)),
4801 "got {err:?}"
4802 );
4803 assert!(
4804 err.to_string().contains("transmission"),
4805 "error must name the transmission cube, got: {err}"
4806 );
4807 }
4808
4809 #[test]
4810 fn test_spatial_accepts_bad_value_in_dead_pixel() {
4811 let data = u238_single_resonance();
4814 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4815 let (mut sample, ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4816 sample[[5, 0, 0]] = f64::NAN;
4817 let config = kl_counts_config(energies, data);
4818 let mut dead = Array2::from_elem((4, 4), false);
4819 dead[[0, 0]] = true;
4820 let input = InputData3D::Counts {
4821 sample_counts: sample.view(),
4822 open_beam_counts: ob.view(),
4823 };
4824 let result = spatial_map_typed(&input, &config, Some(&dead), None, None)
4825 .expect("a bad value in a dead-masked pixel must be tolerated");
4826 assert!(
4827 result.n_converged > 0,
4828 "the remaining live pixels should still fit"
4829 );
4830 }
4831
4832 #[test]
4833 fn test_spatial_accepts_zero_counts_and_open_beam() {
4834 let data = u238_single_resonance();
4837 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4838 let (mut sample, mut ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4839 sample[[3, 2, 2]] = 0.0;
4840 ob[[7, 1, 1]] = 0.0;
4841 let config = kl_counts_config(energies, data);
4842 let input = InputData3D::Counts {
4843 sample_counts: sample.view(),
4844 open_beam_counts: ob.view(),
4845 };
4846 let result = spatial_map_typed(&input, &config, None, None, None)
4847 .expect("zero counts / zero open-beam are legitimate and must not be rejected");
4848 assert_eq!(result.n_total, 16);
4849 }
4850
4851 #[test]
4852 fn test_spatial_rejects_open_beam_flux_overflow() {
4853 let data = u238_single_resonance();
4860 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4861 let (sample, mut ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4862 for y in 0..4 {
4863 for x in 0..4 {
4864 ob[[5, y, x]] = f64::MAX;
4865 }
4866 }
4867 let config = kl_counts_config(energies, data);
4868 let input = InputData3D::Counts {
4869 sample_counts: sample.view(),
4870 open_beam_counts: ob.view(),
4871 };
4872 let err = spatial_map_typed(&input, &config, None, None, None)
4873 .expect_err("an overflowing averaged open-beam flux must be rejected up-front");
4874 assert!(
4875 matches!(err, PipelineError::InvalidParameter(_)),
4876 "got {err:?}"
4877 );
4878 assert!(
4879 err.to_string().contains("averaged open-beam flux"),
4880 "error must name the averaged-flux overflow, got: {err}"
4881 );
4882 }
4883
4884 const SPATIAL_BL_TRUE: [f64; 3] = [1.02, -0.03, 0.01];
4890
4891 fn spatial_baseline_at(e: f64, e_ref: f64) -> f64 {
4892 let z = (e / e_ref).ln();
4893 SPATIAL_BL_TRUE[0] + SPATIAL_BL_TRUE[1] * z + SPATIAL_BL_TRUE[2] * z * z
4894 }
4895
4896 fn baseline_thermometry_cube(
4902 energies: &[f64],
4903 true_density: f64,
4904 true_temp: f64,
4905 i0: f64,
4906 ) -> (Array3<f64>, Array3<f64>) {
4907 let data = u238_single_resonance();
4908 let xs = nereids_physics::transmission::broadened_cross_sections(
4909 energies,
4910 std::slice::from_ref(&data),
4911 true_temp,
4912 None,
4913 None,
4914 )
4915 .unwrap();
4916 let model = PrecomputedTransmissionModel {
4917 cross_sections: Arc::new(xs),
4918 density_indices: Arc::new(vec![0]),
4919 energies: None,
4920 instrument: None,
4921 resolution_plan: None,
4922 sparse_cubature_plan: None,
4923 sparse_scalar_plan: None,
4924 work_layout: None,
4925 };
4926 let t_1d = model.evaluate(&[true_density]).unwrap();
4927 let e_ref = nereids_fitting::transmission_model::baseline_reference_energy(energies);
4928 let n_e = energies.len();
4929 let mut sample = Array3::zeros((n_e, 3, 3));
4930 let mut ob = Array3::zeros((n_e, 3, 3));
4931 for y in 0..3 {
4932 for x in 0..3 {
4933 for (i, (&t, &e)) in t_1d.iter().zip(energies.iter()).enumerate() {
4934 let lam = i0 * spatial_baseline_at(e, e_ref) * t;
4935 let g = (1.7 * (i as f64) + 7.9 * (y as f64) + 13.3 * (x as f64)).sin();
4937 sample[[i, y, x]] = (lam + lam.sqrt() * g).round().max(0.0);
4938 ob[[i, y, x]] = i0;
4939 }
4940 }
4941 }
4942 (sample, ob)
4943 }
4944
4945 #[test]
4946 fn spatial_global_baseline_recovers_truth_and_beats_unmodeled_control() {
4947 let true_density = 0.002;
4948 let true_temp = 600.0;
4949 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
4950 let (sample, ob) = baseline_thermometry_cube(&energies, true_density, true_temp, 400.0);
4951
4952 let base_config = UnifiedFitConfig::new(
4955 energies.clone(),
4956 vec![u238_single_resonance()],
4957 vec!["U-238".into()],
4958 500.0,
4959 None,
4960 vec![true_density],
4961 )
4962 .unwrap()
4963 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4964 .with_fit_temperature(true)
4965 .with_fix_densities(true);
4966
4967 let input = InputData3D::Counts {
4968 sample_counts: sample.view(),
4969 open_beam_counts: ob.view(),
4970 };
4971
4972 let with_bl = base_config
4974 .clone()
4975 .with_multiplicative_baseline(crate::pipeline::MultiplicativeBaselineConfig::default());
4976 let r = spatial_map_typed(&input, &with_bl, None, None, None).unwrap();
4977 assert_eq!(r.n_converged, 9, "all 9 pixels converge in global mode");
4978 assert!(
4979 r.warnings.is_empty(),
4980 "no degenerate trio here: {:?}",
4981 r.warnings
4982 );
4983 assert!(
4984 r.baseline_maps.is_none(),
4985 "global mode reports a scalar baseline, not maps"
4986 );
4987
4988 let bg = r.baseline_global.expect("global baseline populated");
4992 for (i, (&fitted, &truth)) in bg.iter().zip(SPATIAL_BL_TRUE.iter()).enumerate() {
4993 assert!(
4994 (fitted - truth).abs() < 0.01,
4995 "baseline_global[{i}] = {fitted} vs truth {truth}"
4996 );
4997 }
4998 let e_ref_expected =
4999 nereids_fitting::transmission_model::baseline_reference_energy(&energies);
5000 let e_ref = r.baseline_e_ref_ev.expect("E_ref reported");
5001 assert!(
5002 (e_ref - e_ref_expected).abs() < 1e-12,
5003 "E_ref {e_ref} != geometric midpoint {e_ref_expected}"
5004 );
5005
5006 let t_map = r.temperature_map.as_ref().unwrap();
5009 let mut temps: Vec<f64> = t_map.iter().copied().filter(|v| v.is_finite()).collect();
5010 temps.sort_by(|a, b| a.partial_cmp(b).unwrap());
5011 let median_t = temps[temps.len() / 2];
5012 assert!(
5013 (median_t - true_temp).abs() < 15.0,
5014 "median fitted T = {median_t} vs truth {true_temp}"
5015 );
5016
5017 let control = spatial_map_typed(&input, &base_config, None, None, None).unwrap();
5025 let mean_dpd = |res: &SpatialResult| -> f64 {
5026 let m = res.deviance_per_dof_map.as_ref().unwrap();
5027 let v: Vec<f64> = m.iter().copied().filter(|v| v.is_finite()).collect();
5028 v.iter().sum::<f64>() / v.len() as f64
5029 };
5030 let dpd_baseline = mean_dpd(&r);
5031 let dpd_control = mean_dpd(&control);
5032 assert!(
5033 dpd_baseline < dpd_control,
5034 "modeling the baseline must improve the fit: D/dof {dpd_baseline} \
5035 (baseline) vs {dpd_control} (unmodeled control)"
5036 );
5037 }
5038
5039 #[test]
5040 fn spatial_per_pixel_baseline_mode_populates_maps() {
5041 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
5042 let (sample, ob) = baseline_thermometry_cube(&energies, 0.002, 600.0, 400.0);
5043 let config = UnifiedFitConfig::new(
5044 energies,
5045 vec![u238_single_resonance()],
5046 vec!["U-238".into()],
5047 500.0,
5048 None,
5049 vec![0.002],
5050 )
5051 .unwrap()
5052 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
5053 .with_fit_temperature(true)
5054 .with_fix_densities(true)
5055 .with_multiplicative_baseline(crate::pipeline::MultiplicativeBaselineConfig {
5056 spatial_global: false,
5057 ..Default::default()
5058 });
5059 let input = InputData3D::Counts {
5060 sample_counts: sample.view(),
5061 open_beam_counts: ob.view(),
5062 };
5063 let r = spatial_map_typed(&input, &config, None, None, None).unwrap();
5064 assert!(
5065 r.baseline_global.is_none(),
5066 "per-pixel mode has no global baseline"
5067 );
5068 assert!(
5069 r.baseline_e_ref_ev.is_some(),
5070 "E_ref reported in both modes"
5071 );
5072 let maps = r.baseline_maps.as_ref().expect("per-pixel baseline maps");
5073 for y in 0..3 {
5074 for x in 0..3 {
5075 if !r.converged_map[[y, x]] {
5076 continue;
5077 }
5078 let b0 = maps[0][[y, x]];
5079 assert!(
5080 (b0 - SPATIAL_BL_TRUE[0]).abs() < 0.05,
5081 "per-pixel b0[{y},{x}] = {b0} vs truth {}",
5082 SPATIAL_BL_TRUE[0]
5083 );
5084 assert!(maps[1][[y, x]].is_finite() && maps[2][[y, x]].is_finite());
5085 }
5086 }
5087 assert!(r.n_converged > 0, "at least some pixels converge");
5088 }
5089
5090 #[test]
5098 fn spatial_global_baseline_as_only_free_block_rejected_up_front() {
5099 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
5100 let (sample, ob) = baseline_thermometry_cube(&energies, 0.002, 600.0, 400.0);
5101 let config = UnifiedFitConfig::new(
5105 energies,
5106 vec![u238_single_resonance()],
5107 vec!["U-238".into()],
5108 600.0,
5109 None,
5110 vec![0.002],
5111 )
5112 .unwrap()
5113 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
5114 .with_fix_densities(true)
5115 .with_multiplicative_baseline(crate::pipeline::MultiplicativeBaselineConfig::default());
5116 let input = InputData3D::Counts {
5117 sample_counts: sample.view(),
5118 open_beam_counts: ob.view(),
5119 };
5120 let err = spatial_map_typed(&input, &config, None, None, None).expect_err(
5121 "global-baseline-only config must be a whole-map rejection, not \
5122 an Ok(all-NaN) result",
5123 );
5124 let msg = err.to_string();
5125 assert!(
5126 msg.contains("only free parameter block"),
5127 "error must explain the stage-2 freeze consequence, got: {msg}"
5128 );
5129
5130 let per_pixel =
5133 config.with_multiplicative_baseline(crate::pipeline::MultiplicativeBaselineConfig {
5134 spatial_global: false,
5135 ..Default::default()
5136 });
5137 let r = spatial_map_typed(&input, &per_pixel, None, None, None)
5138 .expect("per-pixel baseline-only fits are well-posed");
5139 assert!(r.n_converged > 0, "per-pixel baseline-only fits converge");
5140 }
5141
5142 #[test]
5143 fn spatial_stage1_nonconvergence_is_hard_error() {
5144 let data = u238_single_resonance();
5148 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
5149 let (t_3d, sigma_3d) = synthetic_grid_transmission(&data, 0.002, &energies, 2, 2);
5150 let e_ref = nereids_fitting::transmission_model::baseline_reference_energy(&energies);
5151 let mut t_bl = t_3d.clone();
5152 for y in 0..2 {
5153 for x in 0..2 {
5154 for (i, &e) in energies.iter().enumerate() {
5155 t_bl[[i, y, x]] = t_3d[[i, y, x]] * spatial_baseline_at(e, e_ref);
5156 }
5157 }
5158 }
5159 let config = UnifiedFitConfig::new(
5160 energies,
5161 vec![data],
5162 vec!["U-238".into()],
5163 0.0,
5164 None,
5165 vec![0.001],
5166 )
5167 .unwrap()
5168 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig {
5169 max_iter: 1,
5170 ..LmConfig::default()
5171 }))
5172 .with_multiplicative_baseline(crate::pipeline::MultiplicativeBaselineConfig::default());
5173 let input = InputData3D::Transmission {
5174 transmission: t_bl.view(),
5175 uncertainty: sigma_3d.view(),
5176 };
5177 let err = spatial_map_typed(&input, &config, None, None, None)
5178 .expect_err("non-converged stage 1 must be a hard error");
5179 assert!(
5180 err.to_string().contains("stage 1 did not converge"),
5181 "error must name stage 1, got: {err}"
5182 );
5183 }
5184
5185 #[test]
5186 fn spatial_rejects_free_anorm_with_baseline_up_front() {
5187 let data = u238_single_resonance();
5188 let energies: Vec<f64> = (0..11).map(|i| 1.0 + (i as f64) * 0.1).collect();
5189 let (t_3d, sigma_3d) = synthetic_grid_transmission(&data, 0.002, &energies, 2, 2);
5190 let config = UnifiedFitConfig::new(
5191 energies,
5192 vec![data],
5193 vec!["U-238".into()],
5194 0.0,
5195 None,
5196 vec![0.001],
5197 )
5198 .unwrap()
5199 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
5200 .with_transmission_background(crate::pipeline::BackgroundConfig::default())
5202 .with_multiplicative_baseline(crate::pipeline::MultiplicativeBaselineConfig::default());
5203 let input = InputData3D::Transmission {
5204 transmission: t_3d.view(),
5205 uncertainty: sigma_3d.view(),
5206 };
5207 let err = spatial_map_typed(&input, &config, None, None, None)
5208 .expect_err("free Anorm + baseline must be hoisted to a whole-map rejection");
5209 assert!(
5210 err.to_string().contains("Anorm"),
5211 "rejection must name the degeneracy, got: {err}"
5212 );
5213 }
5214
5215 #[test]
5216 fn spatial_result_carries_degenerate_trio_warning() {
5217 let data = u238_single_resonance();
5221 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
5222 let (t_3d, sigma_3d) = synthetic_grid_transmission(&data, 0.002, &energies, 2, 2);
5223 let config = UnifiedFitConfig::new(
5224 energies,
5225 vec![data],
5226 vec!["U-238".into()],
5227 300.0,
5228 None,
5229 vec![0.001],
5230 )
5231 .unwrap()
5232 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig {
5233 max_iter: 2,
5234 ..LmConfig::default()
5235 }))
5236 .with_fit_temperature(true)
5237 .with_transmission_background(crate::pipeline::BackgroundConfig::default());
5238 let input = InputData3D::Transmission {
5239 transmission: t_3d.view(),
5240 uncertainty: sigma_3d.view(),
5241 };
5242 let r = spatial_map_typed(&input, &config, None, None, None).unwrap();
5243 assert!(
5244 r.warnings.iter().any(|w| w.contains("degenerate")),
5245 "spatial result must carry the degenerate-trio warning, got {:?}",
5246 r.warnings
5247 );
5248 }
5249}