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::{PrecomputedXs, 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, fit_spectrum_validated,
169 required_active_bins, validate_counts_resolution_route, validate_multiplicative_baseline,
170 validate_transmission_background,
171};
172
173#[derive(Debug)]
178pub enum InputData3D<'a> {
179 Transmission {
181 transmission: ArrayView3<'a, f64>,
182 uncertainty: ArrayView3<'a, f64>,
183 },
184 Counts {
186 sample_counts: ArrayView3<'a, f64>,
187 open_beam_counts: ArrayView3<'a, f64>,
188 },
189 CountsWithNuisance {
191 sample_counts: ArrayView3<'a, f64>,
192 flux: ArrayView3<'a, f64>,
193 background: ArrayView3<'a, f64>,
194 },
195}
196
197impl InputData3D<'_> {
198 pub(crate) fn shape(&self) -> (usize, usize, usize) {
200 let s = match self {
201 Self::Transmission { transmission, .. } => transmission.shape(),
202 Self::Counts { sample_counts, .. } => sample_counts.shape(),
203 Self::CountsWithNuisance { sample_counts, .. } => sample_counts.shape(),
204 };
205 (s[0], s[1], s[2])
206 }
207
208 pub fn is_counts(&self) -> bool {
212 matches!(self, Self::Counts { .. } | Self::CountsWithNuisance { .. })
213 }
214}
215
216fn apply_spatial_polish_default(config: UnifiedFitConfig, n_pixels: usize) -> UnifiedFitConfig {
243 if n_pixels > 1 && config.counts_enable_polish().is_none() {
244 config.with_counts_enable_polish(Some(false))
245 } else {
246 config
247 }
248}
249
250fn validate_spatial_fit_preflight(
271 input: &InputData3D<'_>,
272 config: &UnifiedFitConfig,
273) -> Result<(), PipelineError> {
274 if config.fit_temperature() && config.temperature_k() < 1.0 {
280 return Err(PipelineError::InvalidParameter(format!(
281 "temperature must be >= 1.0 K when fit_temperature is true, got {}",
282 config.temperature_k(),
283 )));
284 }
285
286 if count_free_params(config) == 0 {
292 return Err(PipelineError::InvalidParameter(
293 "no free parameters to fit: all densities are frozen and no other \
294 parameter is free — free at least one density (with_density_free) \
295 or enable fit_temperature / energy-scale / background"
296 .into(),
297 ));
298 }
299
300 if let Some(bl) = config.multiplicative_baseline()
311 && bl.spatial_global
312 {
313 let n_baseline_free =
314 usize::from(bl.fit_b0) + usize::from(bl.fit_b1) + usize::from(bl.fit_b2);
315 if count_free_params(config) == n_baseline_free {
316 return Err(PipelineError::InvalidParameter(
317 "global multiplicative baseline (spatial_global = true) is the \
318 only free parameter block: after stage 1 freezes the fitted \
319 baseline, the per-pixel fits would have nothing left to fit. \
320 Free at least one per-pixel parameter (density / temperature / \
321 energy scale / background), fit the aggregated spectrum with a \
322 single-spectrum fitter instead, or set spatial_global = false \
323 to fit per-pixel baselines."
324 .into(),
325 ));
326 }
327 }
328
329 let is_counts = input.is_counts();
334 if config.exact_count_response().is_some() {
335 return Err(PipelineError::InvalidParameter(
336 "exact resolved counts are currently supported by the single-spectrum \
337 count fitter only; spatial mapping would rebuild the detector matrix \
338 for every pixel and is disabled until that fixed matrix is cached once"
339 .into(),
340 ));
341 }
342 if is_counts && config.resolution().is_some() {
348 return Err(PipelineError::InvalidParameter(
349 "spatial_map_typed: resolved count mapping needs the exact separate-arm \
350 model R[Phi] and R[Phi*T], which is currently available on the \
351 single-spectrum count fitter only: fit pre-normalized transmission \
352 cubes with resolution, aggregate to a spectrum and use \
353 fit_counts_spectrum_typed with exact_count_response, or disable \
354 instrument resolution for this count map"
355 .into(),
356 ));
357 }
358 validate_counts_resolution_route(is_counts, input.shape().0, config)?;
362 let is_kl = matches!(config.solver(), SolverConfig::PoissonKL(_))
363 || (matches!(config.solver(), SolverConfig::Auto) && is_counts);
364
365 if !is_counts && is_kl {
369 return Err(PipelineError::InvalidParameter(
370 "spatial_map_typed: normalized transmission cannot use the Poisson/KL \
371 count objective because fractional transmission is not Poisson count \
372 data and the supplied uncertainty would be ignored; use the LM \
373 least-squares transmission engine, or supply separate open/sample counts"
374 .into(),
375 ));
376 }
377 if !is_counts && config.counts_background().is_some() {
378 return Err(PipelineError::InvalidParameter(
379 "spatial_map_typed: counts background configuration cannot be used with \
380 transmission data; use SAMMY transmission_background or \
381 multiplicative_baseline, or supply separate open/sample counts"
382 .into(),
383 ));
384 }
385
386 if let Some((e_min, e_max)) = config.fit_energy_range() {
404 let active_mask = nereids_fitting::active_mask::build_active_mask(
405 config.energies(),
406 config.fit_energy_range(),
407 );
408 let n_active = nereids_fitting::active_mask::active_count(
409 active_mask.as_deref(),
410 config.energies().len(),
411 );
412 let required = required_active_bins(config);
413 if n_active < required {
414 let path_msg = if is_counts && is_kl {
419 "joint-Poisson"
420 } else {
421 "LM transmission"
422 };
423 return Err(PipelineError::InvalidParameter(format!(
424 "fit_energy_range [{e_min}, {e_max}] eV selects {n_active} active bin(s) \
425 on the configured energy grid; at least {required} active bin(s) are \
426 required for {path_msg} fitting with {n_free} free parameter(s) \
427 (underdetermined when n_active < n_free)",
428 n_free = count_free_params(config),
429 )));
430 }
431 }
432
433 validate_multiplicative_baseline(config)?;
439
440 if is_counts && is_kl {
448 if let Some(bg) = config.counts_background() {
449 if bg.fit_alpha_1 || bg.fit_alpha_2 {
450 return Err(PipelineError::InvalidParameter(
451 "joint-Poisson solver does not support fit_alpha_1/fit_alpha_2: \
452 the profile lambda-hat absorbs the global flux scale (alpha_1 redundant); \
453 alpha_2 / B_det wiring is not yet implemented."
454 .into(),
455 ));
456 }
457 if !(bg.c.is_finite() && bg.c > 0.0) {
464 return Err(PipelineError::InvalidParameter(format!(
465 "joint-Poisson solver requires finite c > 0 in CountsBackgroundConfig, got {}",
466 bg.c,
467 )));
468 }
469 }
470 if let Some(bg) = config.transmission_background()
471 && (bg.fit_back_b || bg.fit_back_c)
472 && !bg.fit_back_a
473 {
474 return Err(PipelineError::InvalidParameter(
475 "joint-Poisson transmission_background: B_A (fit_back_a) must be \
476 enabled whenever any of B_B / B_C is enabled (A_n alone cannot \
477 absorb a constant offset — benchmarked at −23% density bias)."
478 .into(),
479 ));
480 }
481 }
482
483 Ok(())
484}
485
486#[allow(clippy::too_many_arguments)]
505fn fit_global_baseline_stage1(
506 input: &InputData3D<'_>,
507 fast_config: &UnifiedFitConfig,
508 data_a: &Array3<f64>,
509 data_b: &Array3<f64>,
510 data_c: Option<&Array3<f64>>,
511 pixel_coords: &[(usize, usize)],
512 averaged_flux: Option<&[f64]>,
513) -> Result<[f64; 3], PipelineError> {
514 let n_e = data_a.shape()[2];
515 let n_live = pixel_coords.len() as f64;
516 let mean_over = |cube: &Array3<f64>| -> Vec<f64> {
517 let mut m = vec![0.0f64; n_e];
518 for &(y, x) in pixel_coords {
519 for (e, &v) in cube.slice(s![y, x, ..]).iter().enumerate() {
520 m[e] += v;
521 }
522 }
523 for v in &mut m {
524 *v /= n_live;
525 }
526 m
527 };
528
529 let aggregate = match input {
530 InputData3D::Transmission { .. } => {
531 let mean_t = mean_over(data_a);
532 let mut se = vec![0.0f64; n_e];
534 for &(y, x) in pixel_coords {
535 for (e, &sig) in data_b.slice(s![y, x, ..]).iter().enumerate() {
536 se[e] += sig * sig;
537 }
538 }
539 for v in &mut se {
540 *v = v.sqrt() / n_live;
541 }
542 InputData::Transmission {
543 transmission: mean_t,
544 uncertainty: se,
545 }
546 }
547 InputData3D::Counts { .. } => {
548 let mean_s = mean_over(data_a);
549 let flux = averaged_flux
550 .expect("averaged_flux is Some for InputData3D::Counts")
551 .to_vec();
552 let effective = fast_config.effective_solver(&InputData::Counts {
555 sample_counts: mean_s.clone(),
556 open_beam_counts: flux.clone(),
557 });
558 match effective {
559 SolverConfig::PoissonKL(_) => InputData::CountsWithNuisance {
560 sample_counts: mean_s,
561 flux,
562 background: vec![0.0f64; n_e],
563 },
564 _ => InputData::Counts {
565 sample_counts: mean_s,
566 open_beam_counts: flux,
567 },
568 }
569 }
570 InputData3D::CountsWithNuisance { .. } => InputData::CountsWithNuisance {
571 sample_counts: mean_over(data_a),
572 flux: mean_over(data_b),
573 background: mean_over(data_c.expect("CountsWithNuisance carries a background cube")),
574 },
575 };
576
577 let agg = fit_spectrum_typed(&aggregate, fast_config).map_err(|e| {
578 PipelineError::InvalidParameter(format!(
579 "multiplicative-baseline stage 1 (global fit on the aggregated \
580 mean spectrum) failed: {e}"
581 ))
582 })?;
583 if !agg.converged {
584 return Err(PipelineError::InvalidParameter(
585 "multiplicative-baseline stage 1 did not converge on the \
586 aggregated mean spectrum; refusing to fall back to per-pixel \
587 baselines (at low counts they biased fitted temperatures by up \
588 to +150 K). Check the baseline bounds/inits, or set \
589 spatial_global = false to fit per-pixel baselines explicitly."
590 .into(),
591 ));
592 }
593 Ok(agg
594 .baseline
595 .expect("stage 1 ran with a configured baseline, so the result carries it"))
596}
597
598#[derive(Clone, Copy)]
603enum CubeDomain {
604 Finite,
609 FinitePositive,
615 FiniteNonNegative,
621}
622
623impl CubeDomain {
624 #[inline]
625 fn accepts(self, v: f64) -> bool {
626 match self {
627 CubeDomain::Finite => v.is_finite(),
628 CubeDomain::FinitePositive => v.is_finite() && v > 0.0,
629 CubeDomain::FiniteNonNegative => v.is_finite() && v >= 0.0,
630 }
631 }
632
633 fn describe(self) -> &'static str {
634 match self {
635 CubeDomain::Finite => "finite",
636 CubeDomain::FinitePositive => "finite and > 0",
637 CubeDomain::FiniteNonNegative => "finite and >= 0",
638 }
639 }
640}
641
642fn check_cube(
654 cube: &ArrayView3<'_, f64>,
655 field: &'static str,
656 domain: CubeDomain,
657 live_pixels: &[(usize, usize)],
658 active_mask: Option<&[bool]>,
659) -> Result<(), PipelineError> {
660 let n_energies = cube.shape()[0];
661 for e in 0..n_energies {
662 if active_mask.is_some_and(|m| !m[e]) {
663 continue;
664 }
665 for &(y, x) in live_pixels {
666 let v = cube[[e, y, x]];
667 if !domain.accepts(v) {
668 return Err(PipelineError::InvalidParameter(format!(
669 "{field} at (y={y}, x={x}, e={e}) must be {}, got {v}",
670 domain.describe(),
671 )));
672 }
673 }
674 }
675 Ok(())
676}
677
678fn validate_spatial_data_values(
708 input: &InputData3D<'_>,
709 live_pixels: &[(usize, usize)],
710 active_mask: Option<&[bool]>,
711) -> Result<(), PipelineError> {
712 match input {
713 InputData3D::Transmission {
714 transmission,
715 uncertainty,
716 } => {
717 check_cube(
718 transmission,
719 "transmission",
720 CubeDomain::Finite,
721 live_pixels,
722 active_mask,
723 )?;
724 check_cube(
725 uncertainty,
726 "uncertainty",
727 CubeDomain::FinitePositive,
728 live_pixels,
729 active_mask,
730 )?;
731 }
732 InputData3D::Counts {
733 sample_counts,
734 open_beam_counts,
735 } => {
736 check_cube(
737 sample_counts,
738 "sample_counts",
739 CubeDomain::FiniteNonNegative,
740 live_pixels,
741 None,
742 )?;
743 check_cube(
744 open_beam_counts,
745 "open_beam_counts",
746 CubeDomain::FiniteNonNegative,
747 live_pixels,
748 None,
749 )?;
750 }
751 InputData3D::CountsWithNuisance {
752 sample_counts,
753 flux,
754 background,
755 } => {
756 check_cube(
757 sample_counts,
758 "sample_counts",
759 CubeDomain::FiniteNonNegative,
760 live_pixels,
761 None,
762 )?;
763 check_cube(
764 flux,
765 "flux",
766 CubeDomain::FiniteNonNegative,
767 live_pixels,
768 None,
769 )?;
770 check_cube(
771 background,
772 "background",
773 CubeDomain::Finite,
774 live_pixels,
775 None,
776 )?;
777 if live_pixels.iter().any(|&(y, x)| {
786 (0..background.shape()[0]).any(|energy| background[[energy, y, x]] != 0.0)
787 }) {
788 return Err(PipelineError::InvalidParameter(
789 "joint-Poisson solver with non-zero detector_background is not yet \
790 supported (B_det wiring is deferred)."
791 .into(),
792 ));
793 }
794 }
795 }
796 Ok(())
797}
798
799pub fn spatial_map_typed(
861 input: &InputData3D<'_>,
862 config: &UnifiedFitConfig,
863 dead_pixels: Option<&Array2<bool>>,
864 cancel: Option<&AtomicBool>,
865 progress: Option<&AtomicUsize>,
866) -> Result<SpatialResult, PipelineError> {
867 let (n_energies, height, width) = input.shape();
868 let n_maps = config.n_density_params();
870
871 if n_energies != config.energies().len() {
873 return Err(PipelineError::ShapeMismatch(format!(
874 "input spectral axis ({n_energies}) != config.energies length ({})",
875 config.energies().len(),
876 )));
877 }
878 match input {
879 InputData3D::Transmission {
880 transmission,
881 uncertainty,
882 } => {
883 if uncertainty.shape() != transmission.shape() {
884 return Err(PipelineError::ShapeMismatch(format!(
885 "uncertainty shape {:?} != transmission shape {:?}",
886 uncertainty.shape(),
887 transmission.shape(),
888 )));
889 }
890 }
891 InputData3D::Counts {
892 sample_counts,
893 open_beam_counts,
894 } => {
895 if open_beam_counts.shape() != sample_counts.shape() {
896 return Err(PipelineError::ShapeMismatch(format!(
897 "open_beam shape {:?} != sample shape {:?}",
898 open_beam_counts.shape(),
899 sample_counts.shape(),
900 )));
901 }
902 }
903 InputData3D::CountsWithNuisance {
904 sample_counts,
905 flux,
906 background,
907 } => {
908 if flux.shape() != sample_counts.shape() {
909 return Err(PipelineError::ShapeMismatch(format!(
910 "flux shape {:?} != sample shape {:?}",
911 flux.shape(),
912 sample_counts.shape(),
913 )));
914 }
915 if background.shape() != sample_counts.shape() {
916 return Err(PipelineError::ShapeMismatch(format!(
917 "background shape {:?} != sample shape {:?}",
918 background.shape(),
919 sample_counts.shape(),
920 )));
921 }
922 }
923 }
924 if let Some(dp) = dead_pixels
925 && dp.shape() != [height, width]
926 {
927 return Err(PipelineError::ShapeMismatch(format!(
928 "dead_pixels shape {:?} != spatial dimensions ({height}, {width})",
929 dp.shape(),
930 )));
931 }
932
933 if input.is_counts() && matches!(config.solver(), SolverConfig::LevenbergMarquardt(_)) {
945 return Err(PipelineError::InvalidParameter(
946 "spatial_map_typed: separate open/sample counts cannot use the LM \
947 least-squares transmission engine because silent ratio conversion loses \
948 open-beam uncertainty and count statistics; use the Poisson/KL count \
949 engine or SolverConfig::Auto"
950 .into(),
951 ));
952 }
953
954 if let Some(bg) = config.transmission_background() {
960 validate_transmission_background(bg)?;
964 if bg.fit_back_d && (!bg.back_d_init.is_finite() || bg.back_d_init <= 0.0) {
972 return Err(PipelineError::InvalidParameter(format!(
973 "transmission_background.back_d_init must be finite and strictly \
974 positive when fit_back_d=true (got {}). BackF's Jacobian column \
975 zeros out at BackD ≈ 0; non-finite or non-positive initial values \
976 produce a degenerate fit that LM cannot recover.",
977 bg.back_d_init,
978 )));
979 }
980 if bg.fit_back_f && (!bg.back_f_init.is_finite() || bg.back_f_init <= 0.0) {
981 return Err(PipelineError::InvalidParameter(format!(
982 "transmission_background.back_f_init must be finite and strictly \
983 positive when fit_back_f=true (got {}). BackD becomes a constant \
984 duplicate of BackA at BackF ≈ 0; non-finite or non-positive initial \
985 values produce a degenerate fit that LM cannot recover.",
986 bg.back_f_init,
987 )));
988 }
989 if (bg.fit_back_d || bg.fit_back_f)
994 && input.is_counts()
995 && !matches!(config.solver(), SolverConfig::LevenbergMarquardt(_))
996 {
997 return Err(PipelineError::InvalidParameter(
998 "spatial_map_typed: transmission_background with fit_back_d=true / \
999 fit_back_f=true cannot be combined with the counts-KL (joint-Poisson) \
1000 dispatch. The joint-Poisson solver does not fit the SAMMY exponential \
1001 tail. Disable the exponential tail (fit_back_d=false, \
1002 fit_back_f=false), or fit pre-normalized transmission with \
1003 uncertainties via SolverConfig::LevenbergMarquardt (raw counts \
1004 cannot use LM)."
1005 .into(),
1006 ));
1007 }
1008 }
1009
1010 validate_spatial_fit_preflight(input, config)?;
1026
1027 crate::pipeline::validate_precomputed_cross_sections(config)?;
1032
1033 let mut pixel_coords: Vec<(usize, usize)> = Vec::new();
1035 for y in 0..height {
1036 for x in 0..width {
1037 let is_dead = dead_pixels.is_some_and(|m| m[[y, x]]);
1038 if !is_dead {
1039 pixel_coords.push((y, x));
1040 }
1041 }
1042 }
1043
1044 let isotope_labels = config.isotope_names().to_vec();
1045 let has_background_outputs =
1046 config.transmission_background().is_some() || config.counts_background().is_some();
1047 let has_back_d_map = config
1055 .transmission_background()
1056 .is_some_and(|bg| bg.fit_back_d);
1057 let has_back_f_map = config
1058 .transmission_background()
1059 .is_some_and(|bg| bg.fit_back_f);
1060
1061 let dispatches_to_counts_kl =
1071 input.is_counts() && !matches!(config.solver(), SolverConfig::LevenbergMarquardt(_));
1072
1073 let baseline_global_mode = config
1076 .multiplicative_baseline()
1077 .is_some_and(|bl| bl.spatial_global);
1078 let has_baseline_maps = config.multiplicative_baseline().is_some() && !baseline_global_mode;
1079 let baseline_e_ref_ev = config
1080 .multiplicative_baseline()
1081 .map(|_| config.baseline_reference_energy());
1082
1083 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
1084 return Err(PipelineError::Cancelled);
1085 }
1086 if pixel_coords.is_empty() {
1087 return Ok(SpatialResult {
1094 density_maps: (0..n_maps)
1095 .map(|_| Array2::from_elem((height, width), f64::NAN))
1096 .collect(),
1097 uncertainty_maps: (0..n_maps)
1098 .map(|_| Array2::from_elem((height, width), f64::NAN))
1099 .collect(),
1100 chi_squared_map: Array2::from_elem((height, width), f64::NAN),
1101 deviance_per_dof_map: if dispatches_to_counts_kl {
1102 Some(Array2::from_elem((height, width), f64::NAN))
1103 } else {
1104 None
1105 },
1106 converged_map: Array2::from_elem((height, width), false),
1107 temperature_map: if config.fit_temperature() {
1108 Some(Array2::from_elem((height, width), f64::NAN))
1109 } else {
1110 None
1111 },
1112 temperature_uncertainty_map: if config.fit_temperature() {
1113 Some(Array2::from_elem((height, width), f64::NAN))
1114 } else {
1115 None
1116 },
1117 isotope_labels,
1118 anorm_map: if has_background_outputs {
1119 Some(Array2::from_elem((height, width), f64::NAN))
1120 } else {
1121 None
1122 },
1123 background_maps: if has_background_outputs {
1124 Some([
1125 Array2::from_elem((height, width), f64::NAN),
1126 Array2::from_elem((height, width), f64::NAN),
1127 Array2::from_elem((height, width), f64::NAN),
1128 ])
1129 } else {
1130 None
1131 },
1132 back_d_map: if has_back_d_map {
1133 Some(Array2::from_elem((height, width), f64::NAN))
1134 } else {
1135 None
1136 },
1137 back_f_map: if has_back_f_map {
1138 Some(Array2::from_elem((height, width), f64::NAN))
1139 } else {
1140 None
1141 },
1142 t0_us_map: if config.fit_energy_scale() {
1143 Some(Array2::from_elem((height, width), f64::NAN))
1144 } else {
1145 None
1146 },
1147 l_scale_map: if config.fit_energy_scale() {
1148 Some(Array2::from_elem((height, width), f64::NAN))
1149 } else {
1150 None
1151 },
1152 energy_scale_flight_path_m: config.fit_energy_scale().then(|| config.flight_path_m()),
1153 baseline_global: config
1160 .multiplicative_baseline()
1161 .filter(|bl| bl.spatial_global && !bl.fit_b0 && !bl.fit_b1 && !bl.fit_b2)
1162 .map(|bl| [bl.b0_init, bl.b1_init, bl.b2_init]),
1163 baseline_e_ref_ev,
1164 baseline_maps: if has_baseline_maps {
1165 Some([
1166 Array2::from_elem((height, width), f64::NAN),
1167 Array2::from_elem((height, width), f64::NAN),
1168 Array2::from_elem((height, width), f64::NAN),
1169 ])
1170 } else {
1171 None
1172 },
1173 warnings: degenerate_normalization_warning(config)
1174 .into_iter()
1175 .collect(),
1176 n_converged: 0,
1177 n_total: 0,
1178 n_failed: 0,
1179 });
1180 }
1181
1182 let value_active_mask = nereids_fitting::active_mask::build_active_mask(
1191 config.energies(),
1192 config.fit_energy_range(),
1193 );
1194 validate_spatial_data_values(input, &pixel_coords, value_active_mask.as_deref())?;
1195
1196 let (data_a, data_b, data_c) = match input {
1198 InputData3D::Transmission {
1199 transmission,
1200 uncertainty,
1201 } => {
1202 let a = transmission
1203 .permuted_axes([1, 2, 0])
1204 .as_standard_layout()
1205 .into_owned();
1206 let b = uncertainty
1207 .permuted_axes([1, 2, 0])
1208 .as_standard_layout()
1209 .into_owned();
1210 (a, b, None)
1211 }
1212 InputData3D::Counts {
1213 sample_counts,
1214 open_beam_counts,
1215 } => {
1216 let a = sample_counts
1217 .permuted_axes([1, 2, 0])
1218 .as_standard_layout()
1219 .into_owned();
1220 let b = open_beam_counts
1221 .permuted_axes([1, 2, 0])
1222 .as_standard_layout()
1223 .into_owned();
1224 (a, b, None)
1225 }
1226 InputData3D::CountsWithNuisance {
1227 sample_counts,
1228 flux,
1229 background,
1230 } => {
1231 let a = sample_counts
1232 .permuted_axes([1, 2, 0])
1233 .as_standard_layout()
1234 .into_owned();
1235 let b = flux
1236 .permuted_axes([1, 2, 0])
1237 .as_standard_layout()
1238 .into_owned();
1239 let c = background
1240 .permuted_axes([1, 2, 0])
1241 .as_standard_layout()
1242 .into_owned();
1243 (a, b, Some(c))
1244 }
1245 };
1246
1247 let instrument = config.resolution().map(|r| InstrumentParams {
1248 resolution: r.clone(),
1249 });
1250 let xs: PrecomputedXs = match config.precomputed_cross_sections() {
1251 Some(cached) => cached.clone(),
1252 None => PrecomputedXs::from(broadened_cross_sections_on_working_grid(
1253 config.energies(),
1254 config.resonance_data(),
1255 config.temperature_k(),
1256 instrument.as_ref(),
1257 cancel,
1258 )?),
1259 };
1260
1261 let sigma = if !config.fit_temperature()
1262 && let (Some(di), Some(dr)) = (&config.density_indices, &config.density_ratios)
1263 && xs.sigma.len() == di.len()
1264 && di.len() == dr.len()
1265 {
1266 let n_e = xs.sigma[0].len();
1267 let mut eff = vec![vec![0.0f64; n_e]; n_maps];
1268 for ((&idx, &ratio), member_xs) in di.iter().zip(dr.iter()).zip(xs.sigma.iter()) {
1269 for (j, &sigma) in member_xs.iter().enumerate() {
1270 eff[idx][j] += ratio * sigma;
1271 }
1272 }
1273 Arc::new(eff)
1274 } else {
1275 Arc::clone(&xs.sigma)
1276 };
1277 let xs = PrecomputedXs {
1278 sigma,
1279 layout: xs.layout,
1280 };
1281
1282 let plan_grid: &[f64] = &xs.layout.energies;
1283 let plan_xs: &Arc<Vec<Vec<f64>>> = &xs.sigma;
1284
1285 let resolution_plan: Option<Arc<nereids_physics::resolution::ResolutionPlan>> =
1288 if !config.fit_energy_scale() {
1289 match config.resolution() {
1290 Some(res) => build_resolution_plan(plan_grid, res)
1291 .map_err(|e| {
1292 PipelineError::Transmission(
1293 nereids_physics::transmission::TransmissionError::from(e),
1294 )
1295 })?
1296 .map(Arc::new),
1297 None => None,
1298 }
1299 } else {
1300 None
1301 };
1302
1303 let caller_cubature = config.precomputed_sparse_cubature_plan().cloned();
1322 let sparse_cubature_plan: Option<Arc<nereids_physics::surrogate::SparseEmpiricalCubaturePlan>> =
1323 if !config.fit_temperature()
1324 && !config.fit_energy_scale()
1325 && resolution_plan.is_some()
1326 && plan_xs.len() >= 2
1327 {
1328 let plan = resolution_plan.as_deref().expect("guarded above");
1329 let matrix = plan.compile_to_matrix();
1330 let k = plan_xs.len();
1331 let n_rows = matrix.len();
1332 let mut sigmas_flat = Vec::with_capacity(k * n_rows);
1336 for row in plan_xs.iter() {
1337 if row.len() != n_rows {
1338 sigmas_flat.clear();
1340 break;
1341 }
1342 sigmas_flat.extend_from_slice(row);
1343 }
1344 if sigmas_flat.len() == k * n_rows {
1345 debug_assert_eq!(
1355 sigmas_flat.len(),
1356 k * n_rows,
1357 "cubature σ dimensions: expected {k} × {n_rows} = {}, got {}",
1358 k * n_rows,
1359 sigmas_flat.len(),
1360 );
1361 let train_max: Vec<f64> = config
1366 .initial_densities()
1367 .iter()
1368 .map(|&n0| 2.0 * n0.max(1e-6))
1369 .collect();
1370 let training =
1371 nereids_physics::surrogate::SparseEmpiricalCubaturePlan::default_training_points(
1372 &train_max,
1373 );
1374 let anchor =
1375 nereids_physics::surrogate::SparseEmpiricalCubaturePlan::default_jacobian_anchor(
1376 &train_max,
1377 );
1378 match nereids_physics::surrogate::SparseEmpiricalCubaturePlan::build(
1379 &matrix,
1380 &sigmas_flat,
1381 k,
1382 &training,
1383 &anchor,
1384 ) {
1385 Ok(plan) => {
1386 Some(Arc::new(plan.with_density_box(train_max.clone())))
1392 }
1393 Err(e) => {
1394 eprintln!(
1401 "spatial_map_typed: sparse cubature build failed ({e}); \
1402 falling back to exact ResolutionPlan path for this call",
1403 );
1404 None
1405 }
1406 }
1407 } else {
1408 None
1409 }
1410 } else {
1411 None
1412 };
1413
1414 let sparse_cubature_plan = sparse_cubature_plan.or_else(|| {
1422 caller_cubature.filter(|p| {
1423 p.len() == plan_grid.len() && p.k() == plan_xs.len() && p.target_energies() == plan_grid
1424 })
1425 });
1426
1427 let caller_scalar = config.precomputed_sparse_scalar_plan().cloned();
1441 let sparse_scalar_plan: Option<Arc<nereids_physics::surrogate::ScalarSurrogatePlan>> =
1442 if let Some(plan) = resolution_plan.as_ref()
1443 && !config.fit_temperature()
1444 && !config.fit_energy_scale()
1445 && plan_xs.len() == 1
1446 {
1447 let sigma_row = &plan_xs[0];
1448 const CHEBYSHEV_NODES: usize = 16;
1462 let n_max: f64 = 2.0 * config.initial_densities()[0].max(1e-6);
1463 match nereids_physics::surrogate::ScalarChebyshevPlan::build(
1464 Arc::clone(plan),
1465 sigma_row,
1466 n_max,
1467 CHEBYSHEV_NODES,
1468 ) {
1469 Ok(plan) => Some(Arc::new(plan)),
1470 Err(e) => {
1471 eprintln!(
1472 "spatial_map_typed: scalar Chebyshev build failed ({e}); \
1473 falling back to exact ResolutionPlan path",
1474 );
1475 None
1476 }
1477 }
1478 } else {
1479 None
1480 };
1481 let sparse_scalar_plan = sparse_scalar_plan.or_else(|| {
1487 caller_scalar.filter(|p| {
1488 if p.len() != plan_grid.len() {
1489 return false;
1490 }
1491 p.target_energies()
1492 .iter()
1493 .zip(plan_grid)
1494 .all(|(a, b)| a.to_bits() == b.to_bits())
1495 })
1496 });
1497
1498 let fast_config = if config.fit_temperature() {
1502 let base_xs: Vec<Vec<f64>> =
1509 unbroadened_cross_sections(config.energies(), config.resonance_data(), cancel)?;
1510 let mut cfg = config
1511 .clone()
1512 .with_precomputed_cross_sections(xs.clone())
1513 .with_precomputed_base_xs(Arc::new(base_xs))
1514 .with_compute_covariance(true);
1515 if let Some(plan) = resolution_plan.clone() {
1516 cfg = cfg.with_precomputed_resolution_plan(plan);
1517 }
1518 cfg
1522 } else {
1523 let mut cfg = config.clone();
1527 if cfg.density_indices.is_some() {
1528 cfg.density_indices = None;
1529 cfg.density_ratios = None;
1530 }
1531 let mut cfg = cfg
1532 .with_precomputed_cross_sections(xs.clone())
1533 .with_compute_covariance(true);
1534 if let Some(plan) = resolution_plan.clone() {
1535 cfg = cfg.with_precomputed_resolution_plan(plan);
1536 }
1537 if let Some(plan) = sparse_cubature_plan.clone() {
1538 cfg = cfg.with_precomputed_sparse_cubature_plan(plan);
1539 }
1540 if let Some(plan) = sparse_scalar_plan.clone() {
1541 cfg = cfg.with_precomputed_sparse_scalar_plan(plan);
1542 }
1543 cfg
1544 };
1545
1546 let fast_config = apply_spatial_polish_default(fast_config, pixel_coords.len());
1554 crate::pipeline::validate_precomputed_cross_sections(&fast_config)?;
1555
1556 let averaged_flux: Option<Vec<f64>> = if matches!(input, InputData3D::Counts { .. }) {
1581 let n_e = data_b.shape()[2]; let mut flux = vec![0.0f64; n_e];
1583 let n_live = pixel_coords.len() as f64;
1584 if n_live > 0.0 {
1585 for &(y, x) in &pixel_coords {
1586 let ob_spectrum = data_b.slice(s![y, x, ..]);
1587 for (e, &v) in ob_spectrum.iter().enumerate() {
1588 flux[e] += v;
1589 }
1590 }
1591 for v in &mut flux {
1592 *v /= n_live;
1593 }
1594 if let Some(e) = flux.iter().position(|v| !v.is_finite()) {
1600 return Err(PipelineError::InvalidParameter(format!(
1601 "spatially-averaged open-beam flux is non-finite at energy \
1602 bin e={e} (got {}); summed open-beam counts overflowed. \
1603 Check the open-beam cube magnitude.",
1604 flux[e],
1605 )));
1606 }
1607 }
1608 Some(flux)
1609 } else {
1610 None
1611 };
1612 let background_zeros: Vec<f64> = if matches!(input, InputData3D::Counts { .. }) {
1613 vec![0.0f64; data_b.shape()[2]]
1614 } else {
1615 Vec::new()
1616 };
1617
1618 let warnings: Vec<String> = degenerate_normalization_warning(config)
1624 .into_iter()
1625 .inspect(|w| eprintln!("spatial_map_typed: warning: {w}"))
1626 .collect();
1627
1628 let (fast_config, baseline_global) = match fast_config.multiplicative_baseline().cloned() {
1635 Some(bl) if bl.spatial_global => {
1636 let b_global = if bl.fit_b0 || bl.fit_b1 || bl.fit_b2 {
1637 fit_global_baseline_stage1(
1638 input,
1639 &fast_config,
1640 &data_a,
1641 &data_b,
1642 data_c.as_ref(),
1643 &pixel_coords,
1644 averaged_flux.as_deref(),
1645 )?
1646 } else {
1647 [bl.b0_init, bl.b1_init, bl.b2_init]
1650 };
1651 let frozen = MultiplicativeBaselineConfig {
1652 b0_init: b_global[0],
1653 b1_init: b_global[1],
1654 b2_init: b_global[2],
1655 fit_b0: false,
1656 fit_b1: false,
1657 fit_b2: false,
1658 ..bl
1659 };
1660 (
1661 fast_config.with_multiplicative_baseline(frozen),
1662 Some(b_global),
1663 )
1664 }
1665 _ => (fast_config, None),
1667 };
1668
1669 let failed_count = AtomicUsize::new(0);
1671 let results: Vec<((usize, usize), SpectrumFitResult)> = pixel_coords
1672 .par_iter()
1673 .filter_map(|&(y, x)| {
1674 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
1675 return None;
1676 }
1677
1678 let spectrum_a: Vec<f64> = data_a.slice(s![y, x, ..]).to_vec();
1679
1680 let pixel_input = match input {
1682 InputData3D::Counts { .. } => {
1683 let ob_spectrum: Vec<f64> = data_b.slice(s![y, x, ..]).to_vec();
1684
1685 let effective = fast_config.effective_solver(&InputData::Counts {
1697 sample_counts: spectrum_a.clone(),
1698 open_beam_counts: ob_spectrum.clone(),
1699 });
1700 match effective {
1701 SolverConfig::PoissonKL(_) => InputData::CountsWithNuisance {
1702 sample_counts: spectrum_a,
1703 flux: averaged_flux.as_ref().unwrap().clone(),
1704 background: background_zeros.clone(),
1708 },
1709 _ => InputData::Counts {
1710 sample_counts: spectrum_a,
1711 open_beam_counts: ob_spectrum,
1712 },
1713 }
1714 }
1715 InputData3D::CountsWithNuisance { .. } => InputData::CountsWithNuisance {
1716 sample_counts: spectrum_a,
1719 flux: data_b.slice(s![y, x, ..]).to_vec(),
1720 background: data_c
1721 .as_ref()
1722 .expect("CountsWithNuisance requires background cube")
1723 .slice(s![y, x, ..])
1724 .to_vec(),
1725 },
1726 InputData3D::Transmission { .. } => {
1727 let spectrum_b: Vec<f64> = data_b.slice(s![y, x, ..]).to_vec();
1735 InputData::Transmission {
1736 transmission: spectrum_a,
1737 uncertainty: spectrum_b,
1738 }
1739 }
1740 };
1741
1742 let out = match fit_spectrum_validated(&pixel_input, &fast_config) {
1743 Ok(result) => Some(((y, x), result)),
1744 Err(_) => {
1745 failed_count.fetch_add(1, Ordering::Relaxed);
1746 None
1747 }
1748 };
1749 if let Some(p) = progress {
1750 p.fetch_add(1, Ordering::Relaxed);
1751 }
1752 out
1753 })
1754 .collect();
1755
1756 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
1767 return Err(PipelineError::Cancelled);
1768 }
1769
1770 let mut density_maps: Vec<Array2<f64>> = (0..n_maps)
1772 .map(|_| Array2::from_elem((height, width), f64::NAN))
1773 .collect();
1774 let mut uncertainty_maps: Vec<Array2<f64>> = (0..n_maps)
1775 .map(|_| Array2::from_elem((height, width), f64::NAN))
1776 .collect();
1777 let mut chi_squared_map = Array2::from_elem((height, width), f64::NAN);
1778 let mut deviance_per_dof_map: Option<Array2<f64>> = if dispatches_to_counts_kl {
1779 Some(Array2::from_elem((height, width), f64::NAN))
1780 } else {
1781 None
1782 };
1783 let mut converged_map = Array2::from_elem((height, width), false);
1784 let mut anorm_map: Option<Array2<f64>> = if has_background_outputs {
1785 Some(Array2::from_elem((height, width), f64::NAN))
1786 } else {
1787 None
1788 };
1789 let mut background_maps: Option<[Array2<f64>; 3]> = if has_background_outputs {
1790 Some([
1791 Array2::from_elem((height, width), f64::NAN),
1792 Array2::from_elem((height, width), f64::NAN),
1793 Array2::from_elem((height, width), f64::NAN),
1794 ])
1795 } else {
1796 None
1797 };
1798 let mut back_d_map: Option<Array2<f64>> = if has_back_d_map {
1799 Some(Array2::from_elem((height, width), f64::NAN))
1800 } else {
1801 None
1802 };
1803 let mut back_f_map: Option<Array2<f64>> = if has_back_f_map {
1804 Some(Array2::from_elem((height, width), f64::NAN))
1805 } else {
1806 None
1807 };
1808 let mut t0_us_map: Option<Array2<f64>> = if config.fit_energy_scale() {
1809 Some(Array2::from_elem((height, width), f64::NAN))
1810 } else {
1811 None
1812 };
1813 let mut l_scale_map: Option<Array2<f64>> = if config.fit_energy_scale() {
1814 Some(Array2::from_elem((height, width), f64::NAN))
1815 } else {
1816 None
1817 };
1818 let mut baseline_maps: Option<[Array2<f64>; 3]> = if has_baseline_maps {
1819 Some([
1820 Array2::from_elem((height, width), f64::NAN),
1821 Array2::from_elem((height, width), f64::NAN),
1822 Array2::from_elem((height, width), f64::NAN),
1823 ])
1824 } else {
1825 None
1826 };
1827 let mut n_converged = 0;
1828 let mut temperature_map: Option<Array2<f64>> = if config.fit_temperature() {
1829 Some(Array2::from_elem((height, width), f64::NAN))
1830 } else {
1831 None
1832 };
1833 let mut temperature_uncertainty_map: Option<Array2<f64>> = if config.fit_temperature() {
1834 Some(Array2::from_elem((height, width), f64::NAN))
1835 } else {
1836 None
1837 };
1838
1839 for ((y, x), result) in &results {
1863 converged_map[[*y, *x]] = result.converged;
1866 if !result.converged {
1867 continue;
1868 }
1869
1870 n_converged += 1;
1871
1872 for i in 0..n_maps {
1873 density_maps[i][[*y, *x]] = result.densities[i];
1874 if let Some(ref unc) = result.uncertainties {
1875 uncertainty_maps[i][[*y, *x]] = unc[i];
1876 }
1877 }
1878 chi_squared_map[[*y, *x]] = result.reduced_chi_squared;
1879 if let (Some(dpd), Some(v)) = (&mut deviance_per_dof_map, result.deviance_per_dof) {
1880 dpd[[*y, *x]] = v;
1881 }
1882 if let (Some(t_map), Some(t)) = (&mut temperature_map, result.temperature_k) {
1883 t_map[[*y, *x]] = t;
1884 }
1885 if let (Some(tu_map), Some(tu)) =
1886 (&mut temperature_uncertainty_map, result.temperature_k_unc)
1887 {
1888 tu_map[[*y, *x]] = tu;
1889 }
1890 if let Some(ref mut a_map) = anorm_map {
1891 a_map[[*y, *x]] = result.anorm;
1892 }
1893 if let Some(ref mut bg_maps) = background_maps {
1894 bg_maps[0][[*y, *x]] = result.background[0];
1895 bg_maps[1][[*y, *x]] = result.background[1];
1896 bg_maps[2][[*y, *x]] = result.background[2];
1897 }
1898 if let Some(ref mut map) = back_d_map {
1908 map[[*y, *x]] = result.back_d.unwrap_or(f64::NAN);
1909 }
1910 if let Some(ref mut map) = back_f_map {
1911 map[[*y, *x]] = result.back_f.unwrap_or(f64::NAN);
1912 }
1913 if let (Some(map), Some(v)) = (&mut t0_us_map, result.t0_us) {
1914 map[[*y, *x]] = v;
1915 }
1916 if let (Some(map), Some(v)) = (&mut l_scale_map, result.l_scale) {
1917 map[[*y, *x]] = v;
1918 }
1919 if let (Some(maps), Some(b)) = (&mut baseline_maps, result.baseline) {
1922 maps[0][[*y, *x]] = b[0];
1923 maps[1][[*y, *x]] = b[1];
1924 maps[2][[*y, *x]] = b[2];
1925 }
1926 }
1927
1928 Ok(SpatialResult {
1929 density_maps,
1930 uncertainty_maps,
1931 chi_squared_map,
1932 deviance_per_dof_map,
1933 converged_map,
1934 temperature_map,
1935 temperature_uncertainty_map,
1936 isotope_labels,
1937 anorm_map,
1938 background_maps,
1939 back_d_map,
1940 back_f_map,
1941 t0_us_map,
1942 l_scale_map,
1943 energy_scale_flight_path_m: config.fit_energy_scale().then(|| config.flight_path_m()),
1944 baseline_global,
1945 baseline_e_ref_ev,
1946 baseline_maps,
1947 warnings,
1948 n_converged,
1949 n_total: pixel_coords.len(),
1950 n_failed: failed_count.load(Ordering::Relaxed),
1951 })
1952}
1953
1954#[cfg(test)]
1957mod tests {
1958 use super::*;
1959 use ndarray::{Array2, Array3};
1960 use nereids_fitting::lm::{FitModel, LmConfig};
1961 use nereids_fitting::poisson::PoissonConfig;
1962 use nereids_fitting::transmission_model::PrecomputedTransmissionModel;
1963
1964 use crate::pipeline::{SolverConfig, UnifiedFitConfig};
1965
1966 fn table_on_data_grid(config: &UnifiedFitConfig, sigma: Vec<Vec<f64>>) -> PrecomputedXs {
1967 PrecomputedXs {
1968 sigma: Arc::new(sigma),
1969 layout: Arc::new(nereids_physics::transmission::WorkingGridLayout::identity(
1970 config.energies(),
1971 )),
1972 }
1973 }
1974 use nereids_endf::resonance::test_support::{
1975 synthetic_single_resonance, u238_single_resonance,
1976 };
1977
1978 fn synthetic_grid_transmission(
1981 res_data: &nereids_endf::resonance::ResonanceData,
1982 true_density: f64,
1983 energies: &[f64],
1984 height: usize,
1985 width: usize,
1986 ) -> (Array3<f64>, Array3<f64>) {
1987 let n_e = energies.len();
1988 let xs = nereids_physics::transmission::broadened_cross_sections(
1989 energies,
1990 std::slice::from_ref(res_data),
1991 0.0,
1992 None,
1993 None,
1994 )
1995 .unwrap();
1996 let model = PrecomputedTransmissionModel {
1997 cross_sections: Arc::new(xs),
1998 density_indices: Arc::new(vec![0]),
1999 instrument: None,
2000 resolution_plan: None,
2001 sparse_cubature_plan: None,
2002 sparse_scalar_plan: None,
2003 layout: Arc::new(nereids_physics::transmission::WorkingGridLayout::identity(
2004 energies,
2005 )),
2006 };
2007 let t_1d = model.evaluate(&[true_density]).unwrap();
2008 let sigma_1d: Vec<f64> = t_1d.iter().map(|&v| 0.01 * v.max(0.01)).collect();
2009
2010 let mut t_3d = Array3::zeros((n_e, height, width));
2011 let mut u_3d = Array3::zeros((n_e, height, width));
2012 for y in 0..height {
2013 for x in 0..width {
2014 for (i, (&t, &s)) in t_1d.iter().zip(sigma_1d.iter()).enumerate() {
2015 t_3d[[i, y, x]] = t;
2016 u_3d[[i, y, x]] = s;
2017 }
2018 }
2019 }
2020 (t_3d, u_3d)
2021 }
2022
2023 fn synthetic_4x4_transmission(
2025 res_data: &nereids_endf::resonance::ResonanceData,
2026 true_density: f64,
2027 energies: &[f64],
2028 ) -> (Array3<f64>, Array3<f64>) {
2029 synthetic_grid_transmission(res_data, true_density, energies, 4, 4)
2030 }
2031
2032 fn synthetic_4x4_transmission_at(
2041 res_data: &nereids_endf::resonance::ResonanceData,
2042 true_density: f64,
2043 energies: &[f64],
2044 temperature_k: f64,
2045 ) -> (Array3<f64>, Array3<f64>) {
2046 let xs = nereids_physics::transmission::broadened_cross_sections(
2047 energies,
2048 std::slice::from_ref(res_data),
2049 temperature_k,
2050 None,
2051 None,
2052 )
2053 .unwrap();
2054 let n_e = energies.len();
2055 let mut t_3d = Array3::zeros((n_e, 4, 4));
2056 let mut u_3d = Array3::zeros((n_e, 4, 4));
2057 for i in 0..n_e {
2058 let t = (-true_density * xs[0][i]).exp();
2059 for y in 0..4 {
2060 for x in 0..4 {
2061 t_3d[[i, y, x]] = t;
2062 u_3d[[i, y, x]] = 0.01;
2063 }
2064 }
2065 }
2066 (t_3d, u_3d)
2067 }
2068
2069 fn synthetic_4x4_counts(
2071 res_data: &nereids_endf::resonance::ResonanceData,
2072 true_density: f64,
2073 energies: &[f64],
2074 i0: f64,
2075 ) -> (Array3<f64>, Array3<f64>) {
2076 let (t_3d, _) = synthetic_4x4_transmission(res_data, true_density, energies);
2077 let n_e = energies.len();
2078 let mut sample = Array3::zeros((n_e, 4, 4));
2079 let mut ob = Array3::zeros((n_e, 4, 4));
2080 for y in 0..4 {
2081 for x in 0..4 {
2082 for i in 0..n_e {
2083 ob[[i, y, x]] = i0;
2084 sample[[i, y, x]] = (t_3d[[i, y, x]] * i0).round().max(0.0);
2085 }
2086 }
2087 }
2088 (sample, ob)
2089 }
2090
2091 #[test]
2092 fn test_spatial_map_typed_transmission_lm() {
2093 let data = u238_single_resonance();
2094 let true_density = 0.0005;
2095 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2096 let (t_3d, u_3d) = synthetic_4x4_transmission(&data, true_density, &energies);
2097
2098 let config = UnifiedFitConfig::new(
2099 energies,
2100 vec![data],
2101 vec!["U-238".into()],
2102 0.0,
2103 None,
2104 vec![0.001],
2105 )
2106 .unwrap()
2107 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2108
2109 let input = InputData3D::Transmission {
2110 transmission: t_3d.view(),
2111 uncertainty: u_3d.view(),
2112 };
2113
2114 let result = spatial_map_typed(&input, &config, None, None, None).unwrap();
2115 assert_eq!(result.n_total, 16);
2116 assert!(result.n_converged >= 14, "Most pixels should converge");
2117
2118 let d = &result.density_maps[0];
2120 let conv = &result.converged_map;
2121 let mean: f64 = d
2122 .iter()
2123 .zip(conv.iter())
2124 .filter(|(_, c)| **c)
2125 .map(|(d, _)| *d)
2126 .sum::<f64>()
2127 / result.n_converged as f64;
2128 assert!(
2129 (mean - true_density).abs() / true_density < 0.05,
2130 "mean density: {mean}, true: {true_density}"
2131 );
2132 }
2133
2134 #[test]
2135 fn test_spatial_map_typed_counts_kl() {
2136 let data = u238_single_resonance();
2137 let true_density = 0.0005;
2138 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2139 let (sample, ob) = synthetic_4x4_counts(&data, true_density, &energies, 1000.0);
2140
2141 let config = UnifiedFitConfig::new(
2142 energies,
2143 vec![data],
2144 vec!["U-238".into()],
2145 0.0,
2146 None,
2147 vec![0.001],
2148 )
2149 .unwrap()
2150 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
2151
2152 let input = InputData3D::Counts {
2153 sample_counts: sample.view(),
2154 open_beam_counts: ob.view(),
2155 };
2156
2157 let result = spatial_map_typed(&input, &config, None, None, None).unwrap();
2158 assert_eq!(result.n_total, 16);
2159 assert!(
2160 result.n_converged >= 14,
2161 "Most pixels should converge with KL"
2162 );
2163
2164 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.max(1) as f64;
2173 assert!(
2174 (mean - true_density).abs() / true_density < 0.10,
2175 "KL mean density: {mean}, true: {true_density}"
2176 );
2177 }
2178
2179 #[test]
2184 fn test_spatial_map_rejects_wrong_shape_precomputed_cross_sections() {
2185 let data = u238_single_resonance();
2186 let energies: Vec<f64> = (0..21).map(|i| 1.0 + (i as f64) * 0.1).collect();
2187 let (t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
2188
2189 let n_e = energies.len();
2191 let bad_xs = vec![vec![1.0; n_e], vec![1.0; n_e]];
2192 let config = UnifiedFitConfig::new(
2193 energies,
2194 vec![data],
2195 vec!["U-238".into()],
2196 0.0,
2197 None,
2198 vec![0.001],
2199 )
2200 .unwrap()
2201 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2202 let bad_xs = table_on_data_grid(&config, bad_xs);
2203 let config = config.with_precomputed_cross_sections(bad_xs);
2204
2205 let input = InputData3D::Transmission {
2206 transmission: t_3d.view(),
2207 uncertainty: u_3d.view(),
2208 };
2209
2210 let err = spatial_map_typed(&input, &config, None, None, None)
2211 .expect_err("wrong-shape precomputed XS must be rejected up front");
2212 assert!(
2213 matches!(err, PipelineError::ShapeMismatch(_)),
2214 "expected ShapeMismatch, got {err:?}"
2215 );
2216 }
2217
2218 #[test]
2233 fn test_spatial_map_mid_run_cancellation_returns_err() {
2234 use std::sync::atomic::{AtomicBool, AtomicUsize};
2235
2236 let data = u238_single_resonance();
2237 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2238 let (t_3d, u_3d) = synthetic_grid_transmission(&data, 0.0005, &energies, 1, 768);
2249
2250 let config = UnifiedFitConfig::new(
2251 energies,
2252 vec![data],
2253 vec!["U-238".into()],
2254 0.0,
2255 None,
2256 vec![0.001],
2257 )
2258 .unwrap()
2259 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2260
2261 let input = InputData3D::Transmission {
2262 transmission: t_3d.view(),
2263 uncertainty: u_3d.view(),
2264 };
2265
2266 let mut saw_cancelled = false;
2275 for _attempt in 0..5 {
2276 let cancel = AtomicBool::new(false);
2277 let progress = AtomicUsize::new(0);
2278
2279 let watcher_ready = AtomicBool::new(false);
2280
2281 let result = std::thread::scope(|s| {
2282 s.spawn(|| {
2285 watcher_ready.store(true, Ordering::Release);
2286 while progress.load(Ordering::Relaxed) < 1 {
2287 std::thread::yield_now();
2291 }
2292 cancel.store(true, Ordering::Relaxed);
2293 });
2294 while !watcher_ready.load(Ordering::Acquire) {
2300 std::thread::yield_now();
2301 }
2302 spatial_map_typed(&input, &config, None, Some(&cancel), Some(&progress))
2303 });
2304
2305 match result {
2306 Err(PipelineError::Cancelled) => {
2307 saw_cancelled = true;
2308 break;
2309 }
2310 Ok(r) if r.n_converged == r.n_total && r.n_failed == 0 => {
2311 continue;
2314 }
2315 other => panic!(
2316 "mid-run cancellation must return Err(Cancelled) (or lose \
2317 the race with a COMPLETE map), got {other:?}"
2318 ),
2319 }
2320 }
2321 assert!(
2322 saw_cancelled,
2323 "all 5 attempts completed the whole sweep before the cancellation \
2324 flip became visible — enlarge the pixel grid for this runner"
2325 );
2326 }
2327
2328 #[test]
2351 fn test_fit_temperature_precompute_cancellation_maps_to_cancelled() {
2352 use std::sync::atomic::AtomicBool;
2353
2354 let data = u238_single_resonance();
2355 let n_e = 100_001usize;
2359 let energies: Vec<f64> = (0..n_e).map(|i| 1.0 + (i as f64) * 2e-4).collect();
2360 let (t_3d, u_3d) = synthetic_grid_transmission(&data, 0.0005, &energies, 2, 2);
2361
2362 let precomputed_xs = vec![vec![0.0f64; n_e]];
2367
2368 let config = UnifiedFitConfig::new(
2369 energies,
2370 vec![data],
2371 vec!["U-238".into()],
2372 293.6,
2373 None,
2374 vec![0.001],
2375 )
2376 .unwrap()
2377 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
2378 .with_fit_temperature(true);
2379 let precomputed_xs = table_on_data_grid(&config, precomputed_xs);
2380 let config = config.with_precomputed_cross_sections(precomputed_xs);
2381
2382 let input = InputData3D::Transmission {
2383 transmission: t_3d.view(),
2384 uncertainty: u_3d.view(),
2385 };
2386
2387 let cancel = AtomicBool::new(false);
2388 let result = std::thread::scope(|s| {
2389 s.spawn(|| {
2390 std::thread::sleep(std::time::Duration::from_millis(5));
2391 cancel.store(true, Ordering::Relaxed);
2392 });
2393 spatial_map_typed(&input, &config, None, Some(&cancel), None)
2394 });
2395
2396 assert!(
2397 matches!(result, Err(PipelineError::Cancelled)),
2398 "cancellation during the fit_temperature precompute must map to \
2399 Err(Cancelled), got {result:?}"
2400 );
2401 }
2402
2403 fn synthetic_tabulated_text() -> String {
2414 "header\n---\n\
2420 5.0 0.0\n\
2421 -0.01 0.0\n\
2422 -0.005 0.5\n\
2423 0.0 1.0\n\
2424 0.005 0.5\n\
2425 0.01 0.0\n\
2426 \n\
2427 200.0 0.0\n\
2428 -0.02 0.0\n\
2429 -0.01 0.5\n\
2430 0.0 1.0\n\
2431 0.01 0.5\n\
2432 0.02 0.0\n"
2433 .to_string()
2434 }
2435
2436 #[test]
2449 fn test_spatial_map_typed_with_resolution_plan_converges_and_is_deterministic() {
2450 use nereids_physics::resolution::{ResolutionFunction, TabulatedResolution};
2451
2452 let data = u238_single_resonance();
2453 let true_density = 0.0005;
2454 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2455 let (t_3d, u_3d) = synthetic_4x4_transmission(&data, true_density, &energies);
2456
2457 let tab = TabulatedResolution::from_text(&synthetic_tabulated_text(), 25.0).unwrap();
2458 let resolution = ResolutionFunction::Tabulated(Arc::new(tab));
2459
2460 let config = UnifiedFitConfig::new(
2461 energies.clone(),
2462 vec![data.clone()],
2463 vec!["U-238".into()],
2464 0.0,
2465 Some(resolution),
2466 vec![0.001],
2467 )
2468 .unwrap()
2469 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2470
2471 let input = InputData3D::Transmission {
2472 transmission: t_3d.view(),
2473 uncertainty: u_3d.view(),
2474 };
2475
2476 let result_with_plan = spatial_map_typed(&input, &config, None, None, None).unwrap();
2477 assert_eq!(result_with_plan.n_total, 16);
2478 assert!(
2479 result_with_plan.n_converged >= 14,
2480 "plan path: {} / 16 pixels converged",
2481 result_with_plan.n_converged,
2482 );
2483
2484 let d = &result_with_plan.density_maps[0];
2485 let conv = &result_with_plan.converged_map;
2486 let mean: f64 = d
2487 .iter()
2488 .zip(conv.iter())
2489 .filter(|(_, c)| **c)
2490 .map(|(d, _)| *d)
2491 .sum::<f64>()
2492 / result_with_plan.n_converged.max(1) as f64;
2493 assert!(
2494 (mean - true_density).abs() / true_density < 0.10,
2495 "mean density with plan: {mean}, true: {true_density}"
2496 );
2497
2498 let reference = d
2504 .iter()
2505 .zip(conv.iter())
2506 .find(|(_, c)| **c)
2507 .map(|(d, _)| *d)
2508 .expect("at least one pixel converged");
2509 for (&cell, &c) in d.iter().zip(conv.iter()) {
2510 if c {
2511 assert_eq!(
2512 cell.to_bits(),
2513 reference.to_bits(),
2514 "plan cache leaked pixel-specific state: density cell {cell} != reference {reference}"
2515 );
2516 }
2517 }
2518 }
2519
2520 #[test]
2521 fn test_spatial_map_typed_gaussian_aux_grid_recovers_density() {
2522 use nereids_physics::resolution::{ResolutionFunction, ResolutionParams};
2523 use nereids_physics::transmission::{SampleParams, forward_model};
2524
2525 let data = u238_single_resonance(); let true_density = 0.0005;
2527 let temperature = 300.0;
2528 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2529 let inst = Arc::new(InstrumentParams {
2530 resolution: ResolutionFunction::Gaussian(
2531 ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2532 ),
2533 });
2534
2535 let sample = SampleParams::new(temperature, vec![(data.clone(), true_density)]).unwrap();
2538 let t_1d = forward_model(&energies, &sample, Some(&inst)).unwrap();
2539
2540 let t_none = forward_model(&energies, &sample, None).unwrap();
2543 let broaden = t_1d
2544 .iter()
2545 .zip(t_none.iter())
2546 .map(|(a, b)| (a - b).abs())
2547 .fold(0.0f64, f64::max);
2548 assert!(
2549 broaden > 1e-4,
2550 "Gaussian kernel must broaden the spectrum non-trivially (got {broaden:.3e})"
2551 );
2552
2553 let n_e = energies.len();
2555 let sigma_1d: Vec<f64> = t_1d.iter().map(|&v| 0.01 * v.max(0.01)).collect();
2556 let mut t_3d = Array3::zeros((n_e, 4, 4));
2557 let mut u_3d = Array3::zeros((n_e, 4, 4));
2558 for y in 0..4 {
2559 for x in 0..4 {
2560 for (i, (&t, &s)) in t_1d.iter().zip(sigma_1d.iter()).enumerate() {
2561 t_3d[[i, y, x]] = t;
2562 u_3d[[i, y, x]] = s;
2563 }
2564 }
2565 }
2566
2567 let config = UnifiedFitConfig::new(
2568 energies.clone(),
2569 vec![data],
2570 vec!["U-238".into()],
2571 temperature,
2572 Some(ResolutionFunction::Gaussian(
2573 ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2574 )),
2575 vec![0.001],
2576 )
2577 .unwrap()
2578 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2579
2580 let input = InputData3D::Transmission {
2581 transmission: t_3d.view(),
2582 uncertainty: u_3d.view(),
2583 };
2584 let result = spatial_map_typed(&input, &config, None, None, None).unwrap();
2585 assert_eq!(result.n_total, 16);
2586 assert!(
2587 result.n_converged >= 14,
2588 "Gaussian aux-grid path: {} / 16 pixels converged",
2589 result.n_converged,
2590 );
2591
2592 let d = &result.density_maps[0];
2594 let conv = &result.converged_map;
2595 let mean: f64 = d
2596 .iter()
2597 .zip(conv.iter())
2598 .filter(|(_, c)| **c)
2599 .map(|(d, _)| *d)
2600 .sum::<f64>()
2601 / result.n_converged.max(1) as f64;
2602 assert!(
2603 (mean - true_density).abs() / true_density < 0.10,
2604 "Gaussian aux-grid mean density: {mean}, true: {true_density}"
2605 );
2606
2607 let reference = d
2610 .iter()
2611 .zip(conv.iter())
2612 .find(|(_, c)| **c)
2613 .map(|(d, _)| *d)
2614 .expect("at least one pixel converged");
2615 for (&cell, &c) in d.iter().zip(conv.iter()) {
2616 if c {
2617 assert_eq!(
2618 cell.to_bits(),
2619 reference.to_bits(),
2620 "aux-grid path leaked pixel-specific state: density cell {cell} != reference {reference}"
2621 );
2622 }
2623 }
2624 }
2625
2626 #[test]
2627 fn test_spatial_map_typed_gaussian_aux_grid_with_precomputed_sigma() {
2628 use nereids_physics::resolution::{ResolutionFunction, ResolutionParams};
2629 use nereids_physics::transmission::{SampleParams, forward_model};
2630
2631 let data = u238_single_resonance();
2632 let true_density = 0.0005;
2633 let temperature = 300.0;
2634 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2635 let inst = Arc::new(InstrumentParams {
2636 resolution: ResolutionFunction::Gaussian(
2637 ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2638 ),
2639 });
2640 let sample = SampleParams::new(temperature, vec![(data.clone(), true_density)]).unwrap();
2641 let t_1d = forward_model(&energies, &sample, Some(&inst)).unwrap();
2642 let n_e = energies.len();
2643 let sigma_1d: Vec<f64> = t_1d.iter().map(|&v| 0.01 * v.max(0.01)).collect();
2644 let mut t_3d = Array3::zeros((n_e, 4, 4));
2645 let mut u_3d = Array3::zeros((n_e, 4, 4));
2646 for y in 0..4 {
2647 for x in 0..4 {
2648 for (i, (&t, &s)) in t_1d.iter().zip(sigma_1d.iter()).enumerate() {
2649 t_3d[[i, y, x]] = t;
2650 u_3d[[i, y, x]] = s;
2651 }
2652 }
2653 }
2654 let resolution =
2655 ResolutionFunction::Gaussian(ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap());
2656 let working = broadened_cross_sections_on_working_grid(
2657 &energies,
2658 std::slice::from_ref(&data),
2659 temperature,
2660 Some(&InstrumentParams {
2661 resolution: resolution.clone(),
2662 }),
2663 None,
2664 )
2665 .unwrap();
2666 let mut working = working;
2667 for row in &mut working.sigma {
2668 for s in row.iter_mut() {
2669 *s *= 2.0;
2670 }
2671 }
2672 let config = UnifiedFitConfig::new(
2673 energies,
2674 vec![data],
2675 vec!["U-238".into()],
2676 temperature,
2677 Some(resolution),
2678 vec![0.001],
2679 )
2680 .unwrap()
2681 .with_precomputed_cross_sections(PrecomputedXs::from(working))
2682 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2683 let input = InputData3D::Transmission {
2684 transmission: t_3d.view(),
2685 uncertainty: u_3d.view(),
2686 };
2687 let result = spatial_map_typed(&input, &config, None, None, None).unwrap();
2688 assert_eq!(result.n_total, 16);
2689 assert!(
2690 result.n_converged >= 14,
2691 "Some(cached)+aux path: {} / 16 pixels converged",
2692 result.n_converged,
2693 );
2694 let d = &result.density_maps[0];
2695 let conv = &result.converged_map;
2696 let mean: f64 = d
2697 .iter()
2698 .zip(conv.iter())
2699 .filter(|(_, c)| **c)
2700 .map(|(d, _)| *d)
2701 .sum::<f64>()
2702 / result.n_converged.max(1) as f64;
2703 let expected = 0.5 * true_density;
2704 assert!(
2705 (mean - expected).abs() / expected < 0.10,
2706 "a table of 2σ must halve the fitted density: mean {mean}, expected {expected}"
2707 );
2708 }
2709
2710 #[test]
2711 fn test_spatial_map_typed_rejects_data_grid_table_under_gaussian() {
2712 use nereids_physics::resolution::{ResolutionFunction, ResolutionParams};
2713
2714 let data = u238_single_resonance();
2715 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2716 let (t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
2717 let resolution =
2718 ResolutionFunction::Gaussian(ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap());
2719 let working = nereids_physics::transmission::resolution_working_grid(
2720 &energies,
2721 Some(&InstrumentParams {
2722 resolution: resolution.clone(),
2723 }),
2724 &[&data],
2725 )
2726 .unwrap();
2727 assert!(!working.is_identity());
2728 let on_data_grid = nereids_physics::transmission::broadened_cross_sections(
2729 &energies,
2730 std::slice::from_ref(&data),
2731 300.0,
2732 None,
2733 None,
2734 )
2735 .unwrap();
2736 let config = UnifiedFitConfig::new(
2737 energies,
2738 vec![data],
2739 vec!["U-238".into()],
2740 300.0,
2741 Some(resolution),
2742 vec![0.001],
2743 )
2744 .unwrap();
2745 let config = config
2746 .clone()
2747 .with_precomputed_cross_sections(table_on_data_grid(&config, on_data_grid));
2748 let input = InputData3D::Transmission {
2749 transmission: t_3d.view(),
2750 uncertainty: u_3d.view(),
2751 };
2752 let err = spatial_map_typed(&input, &config, None, None, None).unwrap_err();
2753 assert!(matches!(err, PipelineError::ShapeMismatch(_)), "{err:?}");
2754 assert!(err.to_string().contains("is not the working grid"), "{err}");
2755 }
2756
2757 #[test]
2758 fn test_spatial_map_typed_counts_kl_low_counts() {
2759 let data = u238_single_resonance();
2761 let true_density = 0.0005;
2762 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2763 let (sample, ob) = synthetic_4x4_counts(&data, true_density, &energies, 10.0);
2764
2765 let config = UnifiedFitConfig::new(
2766 energies,
2767 vec![data],
2768 vec!["U-238".into()],
2769 0.0,
2770 None,
2771 vec![0.001],
2772 )
2773 .unwrap(); let input = InputData3D::Counts {
2776 sample_counts: sample.view(),
2777 open_beam_counts: ob.view(),
2778 };
2779
2780 let result = spatial_map_typed(&input, &config, None, None, None).unwrap();
2781 assert_eq!(result.n_total, 16);
2782 assert!(
2784 result.n_converged >= 10,
2785 "KL at I0=10: only {}/{} converged",
2786 result.n_converged,
2787 result.n_total
2788 );
2789 }
2790
2791 #[test]
2792 fn test_spatial_map_typed_dead_pixels() {
2793 let data = u238_single_resonance();
2794 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
2795 let (t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
2796
2797 let config = UnifiedFitConfig::new(
2798 energies,
2799 vec![data],
2800 vec!["U-238".into()],
2801 0.0,
2802 None,
2803 vec![0.001],
2804 )
2805 .unwrap();
2806
2807 let mut dead = Array2::from_elem((4, 4), false);
2809 for y in 0..2 {
2810 for x in 0..4 {
2811 dead[[y, x]] = true;
2812 }
2813 }
2814
2815 let input = InputData3D::Transmission {
2816 transmission: t_3d.view(),
2817 uncertainty: u_3d.view(),
2818 };
2819
2820 let result = spatial_map_typed(&input, &config, Some(&dead), None, None).unwrap();
2821 assert_eq!(result.n_total, 8, "Only 8 live pixels");
2822 }
2823
2824 #[test]
2834 fn test_spatial_map_rejects_counts_kl_alpha_up_front() {
2835 let data = u238_single_resonance();
2836 let true_density = 0.0005;
2837 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
2838 let (sample, ob) = synthetic_4x4_counts(&data, true_density, &energies, 1000.0);
2839
2840 let config = UnifiedFitConfig::new(
2841 energies,
2842 vec![data],
2843 vec!["U-238".into()],
2844 0.0,
2845 None,
2846 vec![0.001],
2847 )
2848 .unwrap()
2849 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
2850 .with_counts_background(crate::pipeline::CountsBackgroundConfig {
2851 alpha_1_init: 1.0,
2852 alpha_2_init: 1.0,
2853 fit_alpha_1: false,
2854 fit_alpha_2: true,
2855 c: 1.0,
2856 });
2857
2858 let input = InputData3D::Counts {
2859 sample_counts: sample.view(),
2860 open_beam_counts: ob.view(),
2861 };
2862
2863 let err = spatial_map_typed(&input, &config, None, None, None)
2864 .expect_err("counts-KL with fit_alpha_2 must be rejected up-front");
2865 let msg = err.to_string();
2866 assert!(
2867 matches!(err, PipelineError::InvalidParameter(_)),
2868 "expected InvalidParameter, got {err:?}"
2869 );
2870 assert!(
2871 msg.contains("fit_alpha_1") || msg.contains("fit_alpha_2"),
2872 "error must name the offending flag, got: {msg}"
2873 );
2874 }
2875
2876 #[test]
2879 fn test_spatial_map_grouped() {
2880 let rd1 = synthetic_single_resonance(92, 235, 233.025, 5.0);
2881 let rd2 = synthetic_single_resonance(92, 238, 236.006, 7.0);
2882
2883 let iso1 = nereids_core::types::Isotope::new(92, 235).unwrap();
2884 let iso2 = nereids_core::types::Isotope::new(92, 238).unwrap();
2885 let group = nereids_core::types::IsotopeGroup::custom(
2886 "U (60/40)".into(),
2887 vec![(iso1, 0.6), (iso2, 0.4)],
2888 )
2889 .unwrap();
2890
2891 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
2892 let n_e = energies.len();
2893 let true_density = 0.0005;
2894
2895 let sample = nereids_physics::transmission::SampleParams::new(
2897 0.0,
2898 vec![
2899 (rd1.clone(), true_density * 0.6),
2900 (rd2.clone(), true_density * 0.4),
2901 ],
2902 )
2903 .unwrap();
2904 let t_1d = nereids_physics::transmission::forward_model(&energies, &sample, None).unwrap();
2905 let s_1d: Vec<f64> = t_1d.iter().map(|&v| 0.01 * v.max(0.01)).collect();
2906
2907 let mut t_3d = Array3::zeros((n_e, 2, 2));
2909 let mut u_3d = Array3::zeros((n_e, 2, 2));
2910 for y in 0..2 {
2911 for x in 0..2 {
2912 for (i, (&t, &s)) in t_1d.iter().zip(s_1d.iter()).enumerate() {
2913 t_3d[[i, y, x]] = t;
2914 u_3d[[i, y, x]] = s;
2915 }
2916 }
2917 }
2918
2919 let config = UnifiedFitConfig::new(
2920 energies,
2921 vec![rd1.clone()],
2922 vec!["placeholder".into()],
2923 0.0,
2924 None,
2925 vec![0.001],
2926 )
2927 .unwrap()
2928 .with_groups(&[(&group, &[rd1, rd2])], vec![0.001])
2929 .unwrap()
2930 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2931
2932 let input = InputData3D::Transmission {
2933 transmission: t_3d.view(),
2934 uncertainty: u_3d.view(),
2935 };
2936
2937 let result = spatial_map_typed(&input, &config, None, None, None).unwrap();
2938
2939 assert_eq!(
2941 result.density_maps.len(),
2942 1,
2943 "should have 1 group density map"
2944 );
2945 assert_eq!(result.isotope_labels, vec!["U (60/40)"]);
2946 assert_eq!(result.n_total, 4);
2947
2948 for y in 0..2 {
2950 for x in 0..2 {
2951 let fitted = result.density_maps[0][[y, x]];
2952 let rel_error = (fitted - true_density).abs() / true_density;
2953 assert!(
2954 rel_error < 0.05,
2955 "pixel ({y},{x}): fitted={fitted}, true={true_density}, rel_error={rel_error}"
2956 );
2957 }
2958 }
2959 }
2960
2961 #[test]
2965 fn test_spatial_lm_populates_density_uncertainty() {
2966 let rd = u238_single_resonance();
2967 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2968 let (mut t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
2969 for y in 0..4 {
2972 for x in 0..4 {
2973 for e in 0..energies.len() {
2974 let noise = 0.002 * ((e * 7 + y * 13 + x * 29) % 17) as f64 / 17.0 - 0.001;
2975 t_3d[[e, y, x]] = (t_3d[[e, y, x]] + noise).max(0.001);
2976 }
2977 }
2978 }
2979 let data = InputData3D::Transmission {
2980 transmission: t_3d.view(),
2981 uncertainty: u_3d.view(),
2982 };
2983 let config = UnifiedFitConfig::new(
2984 energies,
2985 vec![rd],
2986 vec!["U-238".into()],
2987 0.0,
2988 None,
2989 vec![0.0005],
2990 )
2991 .unwrap()
2992 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2993
2994 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
2995 assert!(result.n_converged > 0, "some pixels should converge");
2996 let unc_map = &result.uncertainty_maps[0];
2998 let conv_map = &result.converged_map;
2999 let mut n_finite = 0;
3000 for y in 0..4 {
3001 for x in 0..4 {
3002 if conv_map[[y, x]] {
3003 let u = unc_map[[y, x]];
3004 assert!(
3005 u.is_finite() && u > 0.0,
3006 "LM density unc at ({y},{x}) should be finite+positive, got {u}"
3007 );
3008 n_finite += 1;
3009 }
3010 }
3011 }
3012 assert!(
3013 n_finite > 0,
3014 "at least one converged pixel should have finite unc"
3015 );
3016 }
3017
3018 #[test]
3020 fn test_spatial_kl_populates_density_uncertainty() {
3021 let rd = u238_single_resonance();
3022 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
3023 let (t_3d, _) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3024 let ob_3d = Array3::from_elem(t_3d.raw_dim(), 1000.0);
3026 let sample_3d = &t_3d * &ob_3d;
3027 let data = InputData3D::Counts {
3028 sample_counts: sample_3d.view(),
3029 open_beam_counts: ob_3d.view(),
3030 };
3031 let config = UnifiedFitConfig::new(
3032 energies,
3033 vec![rd],
3034 vec!["U-238".into()],
3035 0.0,
3036 None,
3037 vec![0.0005],
3038 )
3039 .unwrap()
3040 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
3041
3042 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
3043 assert!(result.n_converged > 0);
3044 let unc_map = &result.uncertainty_maps[0];
3045 let conv_map = &result.converged_map;
3046 let mut n_finite = 0;
3047 for y in 0..4 {
3048 for x in 0..4 {
3049 if conv_map[[y, x]] {
3050 let u = unc_map[[y, x]];
3051 assert!(
3052 u.is_finite() && u > 0.0,
3053 "KL density unc at ({y},{x}) should be finite+positive, got {u}"
3054 );
3055 n_finite += 1;
3056 }
3057 }
3058 }
3059 assert!(n_finite > 0);
3060 }
3061
3062 #[test]
3064 fn test_spatial_temperature_uncertainty_map() {
3065 let rd = u238_single_resonance();
3066 let energies: Vec<f64> = (0..101).map(|i| 4.0 + (i as f64) * 0.05).collect();
3067 let (mut t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3068 for y in 0..4 {
3070 for x in 0..4 {
3071 for e in 0..energies.len() {
3072 let noise = 0.002 * ((e * 7 + y * 13 + x * 29) % 17) as f64 / 17.0 - 0.001;
3073 t_3d[[e, y, x]] = (t_3d[[e, y, x]] + noise).max(0.001);
3074 }
3075 }
3076 }
3077 let data = InputData3D::Transmission {
3078 transmission: t_3d.view(),
3079 uncertainty: u_3d.view(),
3080 };
3081 let config = UnifiedFitConfig::new(
3082 energies,
3083 vec![rd],
3084 vec!["U-238".into()],
3085 300.0,
3086 None,
3087 vec![0.0005],
3088 )
3089 .unwrap()
3090 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
3091 .with_fit_temperature(true);
3092
3093 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
3094 assert!(result.temperature_map.is_some());
3095 let tu_map = result
3096 .temperature_uncertainty_map
3097 .as_ref()
3098 .expect("temperature_uncertainty_map should be Some when fit_temperature=true");
3099 assert_eq!(tu_map.shape(), [4, 4]);
3100 let mut n_finite = 0;
3102 for y in 0..4 {
3103 for x in 0..4 {
3104 if result.converged_map[[y, x]] {
3105 let tu = tu_map[[y, x]];
3106 if tu.is_finite() && tu > 0.0 {
3107 n_finite += 1;
3108 }
3109 }
3110 }
3111 }
3112 assert!(
3113 n_finite > 0,
3114 "at least one converged pixel should have finite temperature uncertainty"
3115 );
3116 }
3117
3118 #[test]
3127 fn test_spatial_unconverged_pixels_are_nan() {
3128 let rd = u238_single_resonance();
3129 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
3130 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3137 let data = InputData3D::Transmission {
3138 transmission: t_3d.view(),
3139 uncertainty: u_3d.view(),
3140 };
3141 let config = UnifiedFitConfig::new(
3142 energies,
3143 vec![rd],
3144 vec!["U-238".into()],
3145 0.0,
3146 None,
3147 vec![0.1], )
3149 .unwrap()
3150 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig {
3151 max_iter: 1,
3152 ..Default::default()
3153 }))
3154 .with_transmission_background(crate::pipeline::BackgroundConfig::default());
3155
3156 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
3157
3158 let unconverged_pixel = (0..4)
3163 .flat_map(|y| (0..4).map(move |x| (y, x)))
3164 .find(|(y, x)| !result.converged_map[[*y, *x]]);
3165 let (uy, ux) = match unconverged_pixel {
3166 Some(p) => p,
3167 None => panic!(
3168 "every pixel converged in max_iter=1 + 100×-off initial density setup — \
3169 test is no longer exercising the un-converged aggregation path; \
3170 tighten the setup (larger offset or fewer iterations)"
3171 ),
3172 };
3173
3174 for (i, m) in result.density_maps.iter().enumerate() {
3176 let v = m[[uy, ux]];
3177 assert!(
3178 v.is_nan(),
3179 "density_maps[{i}] at unconverged pixel ({uy},{ux}) must be NaN, got {v}"
3180 );
3181 }
3182 for (i, m) in result.uncertainty_maps.iter().enumerate() {
3183 let v = m[[uy, ux]];
3184 assert!(
3185 v.is_nan(),
3186 "uncertainty_maps[{i}] at unconverged pixel ({uy},{ux}) must be NaN, got {v}"
3187 );
3188 }
3189 let chi2 = result.chi_squared_map[[uy, ux]];
3190 assert!(
3191 chi2.is_nan(),
3192 "chi_squared_map at unconverged pixel ({uy},{ux}) must be NaN, got {chi2}"
3193 );
3194 if let Some(ref a_map) = result.anorm_map {
3195 let v = a_map[[uy, ux]];
3196 assert!(
3197 v.is_nan(),
3198 "anorm_map at unconverged pixel ({uy},{ux}) must be NaN, got {v}"
3199 );
3200 }
3201 if let Some(ref bg) = result.background_maps {
3202 for (i, m) in bg.iter().enumerate() {
3203 let v = m[[uy, ux]];
3204 assert!(
3205 v.is_nan(),
3206 "background_maps[{i}] at unconverged pixel ({uy},{ux}) must be NaN, got {v}"
3207 );
3208 }
3209 }
3210 if let Some(ref m) = result.back_d_map {
3211 let v = m[[uy, ux]];
3212 assert!(
3213 v.is_nan(),
3214 "back_d_map at unconverged pixel ({uy},{ux}) must be NaN, got {v}"
3215 );
3216 }
3217 if let Some(ref m) = result.back_f_map {
3218 let v = m[[uy, ux]];
3219 assert!(
3220 v.is_nan(),
3221 "back_f_map at unconverged pixel ({uy},{ux}) must be NaN, got {v}"
3222 );
3223 }
3224 }
3225
3226 #[test]
3231 fn test_spatial_map_back_d_f_maps_none_when_fit_disabled() {
3232 let rd = u238_single_resonance();
3233 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3234 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3235 let data = InputData3D::Transmission {
3236 transmission: t_3d.view(),
3237 uncertainty: u_3d.view(),
3238 };
3239 let config = UnifiedFitConfig::new(
3240 energies,
3241 vec![rd],
3242 vec!["U-238".into()],
3243 0.0,
3244 None,
3245 vec![0.001],
3246 )
3247 .unwrap()
3248 .with_transmission_background(crate::pipeline::BackgroundConfig::default());
3251
3252 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
3253 assert!(
3254 result.background_maps.is_some(),
3255 "background_maps should be Some when transmission_background is attached"
3256 );
3257 assert!(
3258 result.back_d_map.is_none(),
3259 "back_d_map must be None when fit_back_d=false"
3260 );
3261 assert!(
3262 result.back_f_map.is_none(),
3263 "back_f_map must be None when fit_back_f=false"
3264 );
3265 }
3266
3267 #[test]
3278 fn test_spatial_map_back_d_f_maps_some_when_fit_enabled() {
3279 let rd = u238_single_resonance();
3280 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
3282 let true_density = 0.0005;
3283 let true_back_d = 0.03;
3284 let true_back_f = 2.0;
3285 let (mut t_3d, u_3d) = synthetic_4x4_transmission(&rd, true_density, &energies);
3291 for (i, &e) in energies.iter().enumerate() {
3292 let inv_sqrt_e = 1.0 / e.sqrt();
3293 let tail = true_back_d * (-true_back_f * inv_sqrt_e).exp();
3294 for y in 0..4 {
3295 for x in 0..4 {
3296 t_3d[[i, y, x]] += tail;
3297 }
3298 }
3299 }
3300 let data = InputData3D::Transmission {
3301 transmission: t_3d.view(),
3302 uncertainty: u_3d.view(),
3303 };
3304 let bg = crate::pipeline::BackgroundConfig {
3309 fit_back_d: true,
3310 fit_back_f: true,
3311 back_d_init: 0.01,
3312 back_f_init: 1.0,
3313 ..crate::pipeline::BackgroundConfig::default()
3314 };
3315 let config = UnifiedFitConfig::new(
3316 energies,
3317 vec![rd],
3318 vec!["U-238".into()],
3319 0.0,
3320 None,
3321 vec![true_density],
3322 )
3323 .unwrap()
3324 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig {
3325 max_iter: 500,
3326 ..LmConfig::default()
3327 }))
3328 .with_transmission_background(bg);
3329
3330 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
3331 let bd = result
3332 .back_d_map
3333 .as_ref()
3334 .expect("back_d_map should be Some when fit_back_d=true");
3335 let bf = result
3336 .back_f_map
3337 .as_ref()
3338 .expect("back_f_map should be Some when fit_back_f=true");
3339 assert_eq!(bd.shape(), [4, 4]);
3340 assert_eq!(bf.shape(), [4, 4]);
3341 assert!(
3342 result.n_converged > 0,
3343 "no pixels converged with LM + 7-param transmission background \
3344 on synthetic data carrying an exponential tail — test fixture \
3345 is no longer exercising the gating contract"
3346 );
3347 let mut n_finite_d = 0;
3350 let mut n_finite_f = 0;
3351 for y in 0..4 {
3352 for x in 0..4 {
3353 if result.converged_map[[y, x]] {
3354 if bd[[y, x]].is_finite() {
3355 n_finite_d += 1;
3356 }
3357 if bf[[y, x]].is_finite() {
3358 n_finite_f += 1;
3359 }
3360 } else {
3361 assert!(
3362 bd[[y, x]].is_nan(),
3363 "back_d_map at unconverged ({y},{x}) must be NaN"
3364 );
3365 assert!(
3366 bf[[y, x]].is_nan(),
3367 "back_f_map at unconverged ({y},{x}) must be NaN"
3368 );
3369 }
3370 }
3371 }
3372 assert!(
3375 n_finite_d > 0 && n_finite_f > 0,
3376 "at least one converged pixel must produce finite back_d/back_f \
3377 (n_converged={}, n_finite_d={n_finite_d}, n_finite_f={n_finite_f})",
3378 result.n_converged
3379 );
3380 }
3381
3382 #[test]
3387 fn test_spatial_map_counts_kl_back_d_f_maps_are_none() {
3388 let rd = u238_single_resonance();
3389 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
3390 let (sample, ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
3391 let data = InputData3D::Counts {
3392 sample_counts: sample.view(),
3393 open_beam_counts: ob.view(),
3394 };
3395 let config = UnifiedFitConfig::new(
3396 energies,
3397 vec![rd],
3398 vec!["U-238".into()],
3399 0.0,
3400 None,
3401 vec![0.001],
3402 )
3403 .unwrap()
3404 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
3405 .with_counts_background(crate::pipeline::CountsBackgroundConfig::default());
3406 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
3407 assert!(
3408 result.back_d_map.is_none(),
3409 "back_d_map must be None on the counts-KL path"
3410 );
3411 assert!(
3412 result.back_f_map.is_none(),
3413 "back_f_map must be None on the counts-KL path"
3414 );
3415 }
3416
3417 #[test]
3422 fn test_spatial_map_back_d_f_unpaired_rejected_up_front() {
3423 let rd = u238_single_resonance();
3424 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3425 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3426 let data = InputData3D::Transmission {
3427 transmission: t_3d.view(),
3428 uncertainty: u_3d.view(),
3429 };
3430 let bg = crate::pipeline::BackgroundConfig {
3431 fit_back_d: true,
3432 fit_back_f: false, back_d_init: 0.01,
3434 back_f_init: 1.0,
3435 ..crate::pipeline::BackgroundConfig::default()
3436 };
3437 let config = UnifiedFitConfig::new(
3438 energies,
3439 vec![rd],
3440 vec!["U-238".into()],
3441 0.0,
3442 None,
3443 vec![0.001],
3444 )
3445 .unwrap()
3446 .with_transmission_background(bg);
3447 let err = spatial_map_typed(&data, &config, None, None, None)
3448 .expect_err("unpaired fit_back_d/fit_back_f must be rejected up-front");
3449 let msg = err.to_string();
3450 assert!(
3451 msg.contains("fit_back_d") && msg.contains("fit_back_f"),
3452 "error message must reference both fit flags, got: {msg}"
3453 );
3454 }
3455
3456 #[test]
3460 fn test_spatial_map_back_d_init_non_positive_rejected() {
3461 let rd = u238_single_resonance();
3462 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3463 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3464 let data = InputData3D::Transmission {
3465 transmission: t_3d.view(),
3466 uncertainty: u_3d.view(),
3467 };
3468 let bg = crate::pipeline::BackgroundConfig {
3469 fit_back_d: true,
3470 fit_back_f: true,
3471 back_d_init: 0.0, back_f_init: 1.0,
3473 ..crate::pipeline::BackgroundConfig::default()
3474 };
3475 let config = UnifiedFitConfig::new(
3476 energies,
3477 vec![rd],
3478 vec!["U-238".into()],
3479 0.0,
3480 None,
3481 vec![0.001],
3482 )
3483 .unwrap()
3484 .with_transmission_background(bg);
3485 let err = spatial_map_typed(&data, &config, None, None, None)
3486 .expect_err("back_d_init=0.0 with fit_back_d=true must be rejected up-front");
3487 assert!(
3488 err.to_string().contains("back_d_init"),
3489 "error must reference back_d_init, got: {err}"
3490 );
3491 }
3492
3493 #[test]
3497 fn test_spatial_map_back_f_init_non_positive_rejected() {
3498 let rd = u238_single_resonance();
3499 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3500 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3501 let data = InputData3D::Transmission {
3502 transmission: t_3d.view(),
3503 uncertainty: u_3d.view(),
3504 };
3505 let bg = crate::pipeline::BackgroundConfig {
3506 fit_back_d: true,
3507 fit_back_f: true,
3508 back_d_init: 0.01,
3509 back_f_init: -1.0, ..crate::pipeline::BackgroundConfig::default()
3511 };
3512 let config = UnifiedFitConfig::new(
3513 energies,
3514 vec![rd],
3515 vec!["U-238".into()],
3516 0.0,
3517 None,
3518 vec![0.001],
3519 )
3520 .unwrap()
3521 .with_transmission_background(bg);
3522 let err = spatial_map_typed(&data, &config, None, None, None)
3523 .expect_err("back_f_init=-1.0 with fit_back_f=true must be rejected up-front");
3524 assert!(
3525 err.to_string().contains("back_f_init"),
3526 "error must reference back_f_init, got: {err}"
3527 );
3528 }
3529
3530 #[test]
3535 fn test_spatial_map_back_d_init_nan_rejected() {
3536 let rd = u238_single_resonance();
3537 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3538 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3539 let data = InputData3D::Transmission {
3540 transmission: t_3d.view(),
3541 uncertainty: u_3d.view(),
3542 };
3543 let bg = crate::pipeline::BackgroundConfig {
3544 fit_back_d: true,
3545 fit_back_f: true,
3546 back_d_init: f64::NAN, back_f_init: 1.0,
3548 ..crate::pipeline::BackgroundConfig::default()
3549 };
3550 let config = UnifiedFitConfig::new(
3551 energies,
3552 vec![rd],
3553 vec!["U-238".into()],
3554 0.0,
3555 None,
3556 vec![0.001],
3557 )
3558 .unwrap()
3559 .with_transmission_background(bg);
3560 let err = spatial_map_typed(&data, &config, None, None, None)
3561 .expect_err("NaN back_d_init must be rejected up-front");
3562 let msg = err.to_string();
3563 assert!(
3564 msg.contains("back_d_init") && (msg.contains("finite") || msg.contains("NaN")),
3565 "error must mention finite/NaN for back_d_init, got: {msg}"
3566 );
3567 }
3568
3569 #[test]
3573 fn test_spatial_map_back_f_init_inf_rejected() {
3574 let rd = u238_single_resonance();
3575 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3576 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3577 let data = InputData3D::Transmission {
3578 transmission: t_3d.view(),
3579 uncertainty: u_3d.view(),
3580 };
3581 let bg = crate::pipeline::BackgroundConfig {
3582 fit_back_d: true,
3583 fit_back_f: true,
3584 back_d_init: 0.01,
3585 back_f_init: f64::INFINITY, ..crate::pipeline::BackgroundConfig::default()
3587 };
3588 let config = UnifiedFitConfig::new(
3589 energies,
3590 vec![rd],
3591 vec!["U-238".into()],
3592 0.0,
3593 None,
3594 vec![0.001],
3595 )
3596 .unwrap()
3597 .with_transmission_background(bg);
3598 let err = spatial_map_typed(&data, &config, None, None, None)
3599 .expect_err("+inf back_f_init must be rejected up-front");
3600 let msg = err.to_string();
3601 assert!(
3602 msg.contains("back_f_init") && (msg.contains("finite") || msg.contains("inf")),
3603 "error must mention finite/inf for back_f_init, got: {msg}"
3604 );
3605 }
3606
3607 #[test]
3613 fn test_spatial_map_counts_kl_plus_back_d_rejected_up_front() {
3614 let rd = u238_single_resonance();
3615 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3616 let (sample, ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
3617 let data = InputData3D::Counts {
3618 sample_counts: sample.view(),
3619 open_beam_counts: ob.view(),
3620 };
3621 let bg = crate::pipeline::BackgroundConfig {
3622 fit_back_d: true,
3623 fit_back_f: true,
3624 back_d_init: 0.01,
3625 back_f_init: 1.0,
3626 ..crate::pipeline::BackgroundConfig::default()
3627 };
3628 let config = UnifiedFitConfig::new(
3629 energies,
3630 vec![rd],
3631 vec!["U-238".into()],
3632 0.0,
3633 None,
3634 vec![0.001],
3635 )
3636 .unwrap()
3637 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
3638 .with_transmission_background(bg);
3639 let err = spatial_map_typed(&data, &config, None, None, None)
3640 .expect_err("counts-KL + fit_back_d/fit_back_f must be rejected up-front");
3641 let msg = err.to_string();
3642 assert!(
3643 msg.contains("counts-KL") || msg.contains("joint-Poisson"),
3644 "error must reference the counts-KL incompatibility, got: {msg}"
3645 );
3646 }
3647
3648 #[test]
3653 fn spatial_counts_resolution_requires_exact_count_response() {
3654 use nereids_physics::resolution::{ResolutionFunction, ResolutionParams};
3655
3656 let rd = u238_single_resonance();
3657 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3658 let (sample, ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
3659 let flux = Array3::from_elem((energies.len(), 4, 4), 1000.0);
3660 let background = Array3::zeros((energies.len(), 4, 4));
3661 let config = UnifiedFitConfig::new(
3662 energies,
3663 vec![rd],
3664 vec!["U-238".into()],
3665 293.6,
3666 Some(ResolutionFunction::Gaussian(
3667 ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
3668 )),
3669 vec![0.001],
3670 )
3671 .unwrap();
3672
3673 let inputs = [
3674 InputData3D::Counts {
3675 sample_counts: sample.view(),
3676 open_beam_counts: ob.view(),
3677 },
3678 InputData3D::CountsWithNuisance {
3679 sample_counts: sample.view(),
3680 flux: flux.view(),
3681 background: background.view(),
3682 },
3683 ];
3684 for input in inputs {
3685 let err = spatial_map_typed(&input, &config, None, None, None)
3686 .expect_err("spatial counts + resolution must fail at preflight");
3687 let msg = err.to_string();
3688 assert!(
3689 msg.contains("instrument resolution") && msg.contains("separate-arm model"),
3690 "expected physical counts-response rejection, got: {msg}"
3691 );
3692 }
3693 }
3694
3695 #[test]
3701 fn test_spatial_map_counts_with_nuisance_plus_lm_rejected_up_front() {
3702 use ndarray::Array3;
3703 let rd = u238_single_resonance();
3704 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3705 let (sample, _ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
3706 let flux: Array3<f64> = Array3::from_elem((energies.len(), 4, 4), 1000.0);
3710 let background: Array3<f64> = Array3::from_elem((energies.len(), 4, 4), 0.0);
3711 let data = InputData3D::CountsWithNuisance {
3712 sample_counts: sample.view(),
3713 flux: flux.view(),
3714 background: background.view(),
3715 };
3716 let config = UnifiedFitConfig::new(
3717 energies,
3718 vec![rd],
3719 vec!["U-238".into()],
3720 0.0,
3721 None,
3722 vec![0.001],
3723 )
3724 .unwrap()
3725 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
3726 let err = spatial_map_typed(&data, &config, None, None, None)
3727 .expect_err("CountsWithNuisance + LM must be rejected up-front");
3728 let msg = err.to_string();
3729 assert!(
3730 msg.contains("counts") && msg.contains("least-squares") && msg.contains("Poisson"),
3731 "error must explain the count-domain engine requirement, got: {msg}"
3732 );
3733 }
3734
3735 #[test]
3745 fn test_spatial_map_reports_solver_mismatch_before_fit_range_gate() {
3746 use ndarray::Array3;
3747 let rd = u238_single_resonance();
3748 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3749 let (sample, _ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
3750 let flux: Array3<f64> = Array3::from_elem((energies.len(), 4, 4), 1000.0);
3751 let background: Array3<f64> = Array3::from_elem((energies.len(), 4, 4), 0.0);
3752 let data = InputData3D::CountsWithNuisance {
3753 sample_counts: sample.view(),
3754 flux: flux.view(),
3755 background: background.view(),
3756 };
3757 let config = UnifiedFitConfig::new(
3765 energies,
3766 vec![rd],
3767 vec!["U-238".into()],
3768 0.0,
3769 None,
3770 vec![0.001],
3771 )
3772 .unwrap()
3773 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
3774 .with_fit_energy_range(Some((5.0, 5.05)))
3775 .unwrap();
3776
3777 let err = spatial_map_typed(&data, &config, None, None, None)
3778 .expect_err("CountsWithNuisance + LM + narrow fit_energy_range must be rejected");
3779 let msg = err.to_string();
3780 assert!(
3781 matches!(err, PipelineError::InvalidParameter(_)),
3782 "expected InvalidParameter, got {err:?}"
3783 );
3784 assert!(
3785 msg.contains("counts") && msg.contains("least-squares") && msg.contains("Poisson"),
3786 "error must surface the solver mismatch (not the fit-range gate), got: {msg}"
3787 );
3788 assert!(
3789 !msg.contains("active bin"),
3790 "error must not be the downstream fit-range diagnostic, got: {msg}"
3791 );
3792 }
3793
3794 #[test]
3801 fn test_spatial_map_typed_counts_kl_populates_deviance_per_dof_map() {
3802 let data = u238_single_resonance();
3803 let true_density = 0.0005;
3804 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
3805 let (t_3d, _) = synthetic_4x4_transmission(&data, true_density, &energies);
3806 let n_e = energies.len();
3807
3808 let c_val = 2.0_f64;
3810 let lam_ob = 500.0_f64;
3811 let mut sample = Array3::zeros((n_e, 4, 4));
3812 let mut open_beam = Array3::from_elem((n_e, 4, 4), lam_ob);
3813 for y in 0..4 {
3814 for x in 0..4 {
3815 for (i, _) in energies.iter().enumerate() {
3816 open_beam[[i, y, x]] = lam_ob;
3817 sample[[i, y, x]] = c_val * lam_ob * t_3d[[i, y, x]];
3818 }
3819 }
3820 }
3821
3822 let config = UnifiedFitConfig::new(
3823 energies,
3824 vec![data],
3825 vec!["U-238".into()],
3826 0.0,
3827 None,
3828 vec![0.001],
3829 )
3830 .unwrap()
3831 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
3832 .with_counts_background(crate::pipeline::CountsBackgroundConfig {
3833 c: c_val,
3834 ..Default::default()
3835 });
3836
3837 let input = InputData3D::Counts {
3838 sample_counts: sample.view(),
3839 open_beam_counts: open_beam.view(),
3840 };
3841 let r = spatial_map_typed(&input, &config, None, None, None).unwrap();
3842 let dpd = r
3844 .deviance_per_dof_map
3845 .as_ref()
3846 .expect("counts-KL spatial should populate deviance_per_dof_map");
3847 assert_eq!(dpd.shape(), &[4, 4]);
3848 let sample_val = dpd[[0, 0]];
3849 assert!(
3850 sample_val.is_finite(),
3851 "deviance_per_dof_map[0,0] = {sample_val} (should be finite)"
3852 );
3853 let density_mean: f64 = r.density_maps[0].iter().copied().sum::<f64>() / 16.0;
3855 assert!(
3856 (density_mean - true_density).abs() / true_density < 0.05,
3857 "mean density {density_mean} vs truth {true_density}",
3858 );
3859 }
3860
3861 #[test]
3867 fn test_apply_spatial_polish_default_multi_pixel_auto_disables() {
3868 let data = u238_single_resonance();
3871 let energies: Vec<f64> = (0..10).map(|i| 1.0 + i as f64).collect();
3872 let cfg = UnifiedFitConfig::new(
3873 energies,
3874 vec![data],
3875 vec!["U-238".into()],
3876 0.0,
3877 None,
3878 vec![0.001],
3879 )
3880 .unwrap();
3881
3882 assert_eq!(cfg.counts_enable_polish(), None);
3884 let resolved = apply_spatial_polish_default(cfg.clone(), 16);
3885 assert_eq!(
3886 resolved.counts_enable_polish(),
3887 Some(false),
3888 "multi-pixel with no override should auto-disable polish"
3889 );
3890
3891 let resolved = apply_spatial_polish_default(cfg.clone(), 1);
3893 assert_eq!(
3894 resolved.counts_enable_polish(),
3895 None,
3896 "single-pixel should preserve the caller's unset state"
3897 );
3898
3899 let cfg_forced_on = cfg.clone().with_counts_enable_polish(Some(true));
3901 let resolved = apply_spatial_polish_default(cfg_forced_on, 16);
3902 assert_eq!(
3903 resolved.counts_enable_polish(),
3904 Some(true),
3905 "caller override Some(true) must be preserved for multi-pixel"
3906 );
3907
3908 let cfg_forced_off = cfg.with_counts_enable_polish(Some(false));
3910 let resolved = apply_spatial_polish_default(cfg_forced_off, 16);
3911 assert_eq!(resolved.counts_enable_polish(), Some(false));
3912 }
3913
3914 #[test]
3919 fn test_spatial_map_typed_counts_kl_populates_map_without_polish_regression() {
3920 let data = u238_single_resonance();
3921 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.1).collect();
3922 let (t_3d, _) = synthetic_4x4_transmission(&data, 0.0005, &energies);
3923 let n_e = energies.len();
3924
3925 let mut sample = Array3::zeros((n_e, 4, 4));
3926 let open_beam = Array3::from_elem((n_e, 4, 4), 500.0);
3927 for y in 0..4 {
3928 for x in 0..4 {
3929 for i in 0..n_e {
3930 sample[[i, y, x]] = 500.0 * t_3d[[i, y, x]];
3931 }
3932 }
3933 }
3934
3935 let config = UnifiedFitConfig::new(
3936 energies,
3937 vec![data],
3938 vec!["U-238".into()],
3939 0.0,
3940 None,
3941 vec![0.001],
3942 )
3943 .unwrap()
3944 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
3945
3946 let input = InputData3D::Counts {
3947 sample_counts: sample.view(),
3948 open_beam_counts: open_beam.view(),
3949 };
3950 let r = spatial_map_typed(&input, &config, None, None, None).unwrap();
3951 assert!(r.deviance_per_dof_map.is_some());
3952 let dpd = r.deviance_per_dof_map.as_ref().unwrap();
3954 assert!(dpd.iter().all(|v| v.is_finite()));
3955 }
3956
3957 #[test]
3960 fn test_spatial_map_typed_counts_lm_is_rejected() {
3961 let data = u238_single_resonance();
3962 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.1).collect();
3963 let (t_3d, _) = synthetic_4x4_transmission(&data, 0.0005, &energies);
3964 let n_e = energies.len();
3965 let mut sample = Array3::zeros((n_e, 4, 4));
3966 let open_beam = Array3::from_elem((n_e, 4, 4), 500.0);
3967 for y in 0..4 {
3968 for x in 0..4 {
3969 for i in 0..n_e {
3970 sample[[i, y, x]] = 500.0 * t_3d[[i, y, x]];
3971 }
3972 }
3973 }
3974
3975 let config = UnifiedFitConfig::new(
3976 energies,
3977 vec![data],
3978 vec!["U-238".into()],
3979 0.0,
3980 None,
3981 vec![0.001],
3982 )
3983 .unwrap()
3984 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
3985
3986 let input = InputData3D::Counts {
3987 sample_counts: sample.view(),
3988 open_beam_counts: open_beam.view(),
3989 };
3990 let err = spatial_map_typed(&input, &config, None, None, None)
3991 .expect_err("counts + LM must be rejected at the spatial boundary");
3992 let msg = err.to_string();
3993 assert!(
3994 msg.contains("counts") && msg.contains("least-squares") && msg.contains("Poisson"),
3995 "error must explain the count-domain engine requirement, got: {msg}"
3996 );
3997 }
3998
3999 #[test]
4002 fn test_spatial_map_typed_transmission_no_deviance_map() {
4003 let data = u238_single_resonance();
4004 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.1).collect();
4005 let (t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
4006
4007 let config = UnifiedFitConfig::new(
4008 energies,
4009 vec![data],
4010 vec!["U-238".into()],
4011 0.0,
4012 None,
4013 vec![0.001],
4014 )
4015 .unwrap();
4016 let input = InputData3D::Transmission {
4017 transmission: t_3d.view(),
4018 uncertainty: u_3d.view(),
4019 };
4020 let r = spatial_map_typed(&input, &config, None, None, None).unwrap();
4021 assert!(r.deviance_per_dof_map.is_none());
4022 }
4023
4024 #[test]
4031 fn test_spatial_map_typed_fit_energy_scale_populates_maps() {
4032 let rd = u238_single_resonance();
4033 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
4034 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
4035 let data = InputData3D::Transmission {
4036 transmission: t_3d.view(),
4037 uncertainty: u_3d.view(),
4038 };
4039 let config = UnifiedFitConfig::new(
4040 energies,
4041 vec![rd],
4042 vec!["U-238".into()],
4043 0.0,
4044 None,
4045 vec![0.0005],
4046 )
4047 .unwrap()
4048 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4049 .with_energy_scale(0.0, 1.0, 25.0);
4050
4051 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
4052 let t0_map = result
4053 .t0_us_map
4054 .as_ref()
4055 .expect("t0_us_map must be Some when fit_energy_scale=true");
4056 let l_map = result
4057 .l_scale_map
4058 .as_ref()
4059 .expect("l_scale_map must be Some when fit_energy_scale=true");
4060 assert_eq!(t0_map.shape(), [4, 4]);
4061 assert_eq!(l_map.shape(), [4, 4]);
4062 for y in 0..4 {
4070 for x in 0..4 {
4071 let converged = result.converged_map[[y, x]];
4072 let t0 = t0_map[[y, x]];
4073 let ls = l_map[[y, x]];
4074 if converged {
4075 assert!(
4076 t0.is_finite() && ls.is_finite(),
4077 "converged pixel ({y},{x}) must have finite t0/L, got t0={t0}, L={ls}"
4078 );
4079 } else {
4080 assert!(
4081 t0.is_nan() && ls.is_nan(),
4082 "un-converged pixel ({y},{x}) must have NaN t0/L (B1 gating), got t0={t0}, L={ls}"
4083 );
4084 }
4085 }
4086 }
4087 }
4088
4089 #[test]
4091 fn test_spatial_map_typed_no_energy_scale_no_maps() {
4092 let rd = u238_single_resonance();
4093 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4094 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
4095 let data = InputData3D::Transmission {
4096 transmission: t_3d.view(),
4097 uncertainty: u_3d.view(),
4098 };
4099 let config = UnifiedFitConfig::new(
4100 energies,
4101 vec![rd],
4102 vec!["U-238".into()],
4103 0.0,
4104 None,
4105 vec![0.0005],
4106 )
4107 .unwrap()
4108 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
4109
4110 let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
4111 assert!(result.t0_us_map.is_none());
4112 assert!(result.l_scale_map.is_none());
4113 }
4114
4115 #[test]
4119 fn test_spatial_map_typed_rejects_counts_lm_with_energy_scale() {
4120 let rd = u238_single_resonance();
4121 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4122 let (sample, ob) = synthetic_4x4_counts(&rd, 0.001, &energies, 1000.0);
4123 let data = InputData3D::Counts {
4124 sample_counts: sample.view(),
4125 open_beam_counts: ob.view(),
4126 };
4127 let config = UnifiedFitConfig::new(
4128 energies,
4129 vec![rd],
4130 vec!["U-238".into()],
4131 0.0,
4132 None,
4133 vec![0.0005],
4134 )
4135 .unwrap()
4136 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4137 .with_energy_scale(0.0, 1.0, 25.0);
4138
4139 let err = spatial_map_typed(&data, &config, None, None, None)
4140 .expect_err("LM + counts + fit_energy_scale must be rejected");
4141 let msg = err.to_string();
4142 assert!(
4143 msg.contains("counts") && msg.contains("least-squares") && msg.contains("Poisson"),
4144 "error must explain the count-domain engine requirement, got: {msg}"
4145 );
4146 }
4147
4148 #[test]
4151 fn test_spatial_map_typed_allows_counts_kl_with_energy_scale() {
4152 let rd = u238_single_resonance();
4153 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4154 let (sample, ob) = synthetic_4x4_counts(&rd, 0.001, &energies, 1000.0);
4155 let data = InputData3D::Counts {
4156 sample_counts: sample.view(),
4157 open_beam_counts: ob.view(),
4158 };
4159 let config = UnifiedFitConfig::new(
4160 energies,
4161 vec![rd],
4162 vec!["U-238".into()],
4163 0.0,
4164 None,
4165 vec![0.0005],
4166 )
4167 .unwrap()
4168 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4169 .with_energy_scale(0.0, 1.0, 25.0);
4170
4171 let result = spatial_map_typed(&data, &config, None, None, None)
4172 .expect("KL + counts + fit_energy_scale must be allowed");
4173 assert!(result.t0_us_map.is_some());
4174 }
4175
4176 #[test]
4185 fn test_spatial_map_typed_allows_energy_scale_with_temperature() {
4186 let rd = u238_single_resonance();
4187 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4188 let (t_3d, u_3d) = synthetic_4x4_transmission_at(&rd, 0.001, &energies, 300.0);
4190 let data = InputData3D::Transmission {
4191 transmission: t_3d.view(),
4192 uncertainty: u_3d.view(),
4193 };
4194 let config = UnifiedFitConfig::new(
4195 energies,
4196 vec![rd],
4197 vec!["U-238".into()],
4198 300.0,
4199 None,
4200 vec![0.0005],
4201 )
4202 .unwrap()
4203 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4204 .with_fit_temperature(true)
4205 .with_energy_scale(0.0, 1.0, 25.0);
4206
4207 let result = spatial_map_typed(&data, &config, None, None, None)
4208 .expect("fit_energy_scale + fit_temperature is now supported (#634)");
4209 assert_eq!(result.n_total, 16, "4×4 map");
4210 assert!(
4213 result.n_converged >= 14,
4214 "joint fit should converge on (nearly) all pixels, got {}/16",
4215 result.n_converged
4216 );
4217 let finite_count = |m: &Option<ndarray::Array2<f64>>| {
4220 m.as_ref()
4221 .expect("map allocated when its flag is set")
4222 .iter()
4223 .filter(|v| v.is_finite())
4224 .count()
4225 };
4226 for (name, map) in [
4227 ("temperature_map", &result.temperature_map),
4228 ("t0_us_map", &result.t0_us_map),
4229 ("l_scale_map", &result.l_scale_map),
4230 ] {
4231 let n_finite = finite_count(map);
4232 assert!(
4233 n_finite >= result.n_converged,
4234 "{name}: {n_finite} finite entries < {} converged pixels — \
4235 converged pixels must write finite values",
4236 result.n_converged
4237 );
4238 }
4239 }
4240
4241 #[test]
4247 fn test_spatial_map_typed_allows_transmission_lm_with_energy_scale() {
4248 let rd = u238_single_resonance();
4249 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4250 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
4251 let data = InputData3D::Transmission {
4252 transmission: t_3d.view(),
4253 uncertainty: u_3d.view(),
4254 };
4255 let config = UnifiedFitConfig::new(
4256 energies,
4257 vec![rd],
4258 vec!["U-238".into()],
4259 0.0,
4260 None,
4261 vec![0.0005],
4262 )
4263 .unwrap()
4264 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4265 .with_energy_scale(0.0, 1.0, 25.0);
4266
4267 let result = spatial_map_typed(&data, &config, None, None, None)
4268 .expect("LM + transmission + fit_energy_scale must be allowed");
4269 assert!(result.t0_us_map.is_some());
4270 }
4271
4272 #[test]
4284 fn test_spatial_map_rejects_fit_temperature_below_one_up_front() {
4285 let rd = u238_single_resonance();
4286 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4287 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
4288 let data = InputData3D::Transmission {
4289 transmission: t_3d.view(),
4290 uncertainty: u_3d.view(),
4291 };
4292 let config = UnifiedFitConfig::new(
4296 energies,
4297 vec![rd],
4298 vec!["U-238".into()],
4299 0.5,
4300 None,
4301 vec![0.001],
4302 )
4303 .unwrap()
4304 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4305 .with_fit_temperature(true);
4306
4307 let err = spatial_map_typed(&data, &config, None, None, None)
4308 .expect_err("fit_temperature with temperature_k < 1.0 must be rejected up-front");
4309 let msg = err.to_string();
4310 assert!(
4311 matches!(err, PipelineError::InvalidParameter(_)),
4312 "expected InvalidParameter, got {err:?}"
4313 );
4314 assert!(
4315 msg.contains("temperature") && msg.contains("1.0"),
4316 "error must mention the 1.0 K floor, got: {msg}"
4317 );
4318 }
4319
4320 #[test]
4321 fn test_spatial_map_transmission_poisson_rejected_up_front() {
4322 let rd = u238_single_resonance();
4323 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4324 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
4325 let data = InputData3D::Transmission {
4326 transmission: t_3d.view(),
4327 uncertainty: u_3d.view(),
4328 };
4329 let config = UnifiedFitConfig::new(
4332 energies,
4333 vec![rd],
4334 vec!["U-238".into()],
4335 0.0,
4336 None,
4337 vec![0.001],
4338 )
4339 .unwrap()
4340 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
4341
4342 let err = spatial_map_typed(&data, &config, None, None, None)
4343 .expect_err("transmission + Poisson-KL must be rejected up-front");
4344 let msg = err.to_string();
4345 assert!(
4346 matches!(err, PipelineError::InvalidParameter(_)),
4347 "expected InvalidParameter, got {err:?}"
4348 );
4349 assert!(
4350 msg.contains("normalized transmission") && msg.contains("Poisson"),
4351 "error must name the incompatibility, got: {msg}"
4352 );
4353 }
4354
4355 #[test]
4356 fn test_spatial_map_lm_rejects_too_narrow_fit_energy_range_up_front() {
4357 let rd = u238_single_resonance();
4358 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4360 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
4361 let data = InputData3D::Transmission {
4362 transmission: t_3d.view(),
4363 uncertainty: u_3d.view(),
4364 };
4365 let config = UnifiedFitConfig::new(
4368 energies,
4369 vec![rd],
4370 vec!["U-238".into()],
4371 0.0,
4372 None,
4373 vec![0.001],
4374 )
4375 .unwrap()
4376 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4377 .with_fit_energy_range(Some((5.0, 5.05)))
4378 .unwrap();
4379
4380 let err = spatial_map_typed(&data, &config, None, None, None)
4381 .expect_err("LM with too-narrow fit_energy_range must be rejected up-front");
4382 let msg = err.to_string();
4383 assert!(
4384 matches!(err, PipelineError::InvalidParameter(_)),
4385 "expected InvalidParameter, got {err:?}"
4386 );
4387 assert!(
4388 msg.contains("active bin") && msg.contains("LM transmission"),
4389 "error must mention narrow active-bin count for the LM path, got: {msg}"
4390 );
4391 }
4392
4393 #[test]
4394 fn test_spatial_map_counts_kl_rejects_too_narrow_fit_energy_range_up_front() {
4395 let rd = u238_single_resonance();
4396 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4397 let (sample, ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
4398 let data = InputData3D::Counts {
4399 sample_counts: sample.view(),
4400 open_beam_counts: ob.view(),
4401 };
4402 let config = UnifiedFitConfig::new(
4403 energies,
4404 vec![rd],
4405 vec!["U-238".into()],
4406 0.0,
4407 None,
4408 vec![0.001],
4409 )
4410 .unwrap()
4411 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4412 .with_fit_energy_range(Some((5.0, 5.05)))
4413 .unwrap();
4414
4415 let err = spatial_map_typed(&data, &config, None, None, None)
4416 .expect_err("counts-KL with too-narrow fit_energy_range must be rejected up-front");
4417 let msg = err.to_string();
4418 assert!(
4419 matches!(err, PipelineError::InvalidParameter(_)),
4420 "expected InvalidParameter, got {err:?}"
4421 );
4422 assert!(
4423 msg.contains("active bin") && msg.contains("joint-Poisson"),
4424 "error must mention narrow active-bin count for the joint-Poisson path, got: {msg}"
4425 );
4426 }
4427
4428 #[test]
4429 fn test_spatial_map_counts_kl_rejects_invalid_c_up_front() {
4430 let rd = u238_single_resonance();
4431 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4432 let (sample, ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
4433 let data = InputData3D::Counts {
4434 sample_counts: sample.view(),
4435 open_beam_counts: ob.view(),
4436 };
4437 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_counts_background(crate::pipeline::CountsBackgroundConfig {
4452 c: -1.0,
4453 ..Default::default()
4454 });
4455
4456 let err = spatial_map_typed(&data, &config, None, None, None)
4457 .expect_err("counts-KL with non-positive c must be rejected up-front");
4458 let msg = err.to_string();
4459 assert!(
4460 matches!(err, PipelineError::InvalidParameter(_)),
4461 "expected InvalidParameter, got {err:?}"
4462 );
4463 assert!(
4464 msg.contains("finite c > 0"),
4465 "error must mention the c > 0 requirement, got: {msg}"
4466 );
4467 }
4468
4469 #[test]
4470 fn test_spatial_map_counts_kl_requires_back_a_for_back_b_c_up_front() {
4471 let rd = u238_single_resonance();
4472 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4473 let (sample, ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
4474 let data = InputData3D::Counts {
4475 sample_counts: sample.view(),
4476 open_beam_counts: ob.view(),
4477 };
4478 let bg = crate::pipeline::BackgroundConfig {
4483 fit_back_a: false,
4484 fit_back_b: true,
4485 fit_back_c: false,
4486 fit_back_d: false,
4487 fit_back_f: false,
4488 ..crate::pipeline::BackgroundConfig::default()
4489 };
4490 let config = UnifiedFitConfig::new(
4491 energies,
4492 vec![rd],
4493 vec!["U-238".into()],
4494 0.0,
4495 None,
4496 vec![0.001],
4497 )
4498 .unwrap()
4499 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4500 .with_transmission_background(bg);
4501
4502 let err = spatial_map_typed(&data, &config, None, None, None)
4503 .expect_err("counts-KL with B_B but no B_A must be rejected up-front");
4504 let msg = err.to_string();
4505 assert!(
4506 matches!(err, PipelineError::InvalidParameter(_)),
4507 "expected InvalidParameter, got {err:?}"
4508 );
4509 assert!(
4510 msg.contains("B_A") && msg.contains("fit_back_a"),
4511 "error must name the B_A requirement, got: {msg}"
4512 );
4513 }
4514
4515 #[test]
4525 fn test_spatial_map_rejects_underdetermined_fit_range() {
4526 let rd = u238_single_resonance();
4527 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4528 let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
4529 let data = InputData3D::Transmission {
4530 transmission: t_3d.view(),
4531 uncertainty: u_3d.view(),
4532 };
4533 let bg = crate::pipeline::BackgroundConfig::default();
4543 let config = UnifiedFitConfig::new(
4544 energies,
4545 vec![rd],
4546 vec!["U-238".into()],
4547 293.0,
4551 None,
4552 vec![0.001],
4553 )
4554 .unwrap()
4555 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4556 .with_fit_temperature(true)
4557 .with_transmission_background(bg)
4558 .with_fit_energy_range(Some((5.0, 5.5)))
4559 .unwrap();
4560
4561 let err = spatial_map_typed(&data, &config, None, None, None)
4562 .expect_err("underdetermined fit_energy_range must be rejected up-front");
4563 let msg = err.to_string();
4564 assert!(
4565 matches!(err, PipelineError::InvalidParameter(_)),
4566 "expected InvalidParameter, got {err:?}"
4567 );
4568 assert!(
4572 msg.contains("active bin")
4573 && msg.contains("free parameter")
4574 && msg.contains("underdetermined"),
4575 "error must explain the underdetermined condition, got: {msg}"
4576 );
4577 }
4578
4579 fn lm_transmission_config(
4589 energies: Vec<f64>,
4590 data: nereids_endf::resonance::ResonanceData,
4591 ) -> UnifiedFitConfig {
4592 UnifiedFitConfig::new(
4593 energies,
4594 vec![data],
4595 vec!["U-238".into()],
4596 0.0,
4597 None,
4598 vec![0.001],
4599 )
4600 .unwrap()
4601 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4602 }
4603
4604 fn kl_counts_config(
4605 energies: Vec<f64>,
4606 data: nereids_endf::resonance::ResonanceData,
4607 ) -> UnifiedFitConfig {
4608 UnifiedFitConfig::new(
4609 energies,
4610 vec![data],
4611 vec!["U-238".into()],
4612 0.0,
4613 None,
4614 vec![0.001],
4615 )
4616 .unwrap()
4617 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4618 }
4619
4620 #[test]
4621 fn test_spatial_rejects_bad_transmission_value() {
4622 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4623 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
4624 let data = u238_single_resonance();
4625 let (mut t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
4626 t_3d[[10, 1, 2]] = bad;
4627 let config = lm_transmission_config(energies.clone(), data);
4628 let input = InputData3D::Transmission {
4629 transmission: t_3d.view(),
4630 uncertainty: u_3d.view(),
4631 };
4632 let err = spatial_map_typed(&input, &config, None, None, None)
4633 .expect_err("non-finite transmission value must be rejected up-front");
4634 assert!(
4635 matches!(err, PipelineError::InvalidParameter(_)),
4636 "got {err:?}"
4637 );
4638 let msg = err.to_string();
4639 assert!(
4640 msg.contains("transmission") && msg.contains("(y="),
4641 "error must name the cube and (y, x, e): {msg}"
4642 );
4643 }
4644 }
4645
4646 #[test]
4647 fn test_spatial_rejects_bad_uncertainty() {
4648 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4652 for bad in [f64::NAN, f64::INFINITY, 0.0, -1.0] {
4653 let data = u238_single_resonance();
4654 let (t_3d, mut u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
4655 u_3d[[9, 1, 0]] = bad;
4656 let config = lm_transmission_config(energies.clone(), data);
4657 let input = InputData3D::Transmission {
4658 transmission: t_3d.view(),
4659 uncertainty: u_3d.view(),
4660 };
4661 let err = spatial_map_typed(&input, &config, None, None, None)
4662 .expect_err("bad uncertainty must be rejected up-front");
4663 assert!(
4664 matches!(err, PipelineError::InvalidParameter(_)),
4665 "got {err:?}"
4666 );
4667 assert!(
4668 err.to_string().contains("uncertainty"),
4669 "error must name the uncertainty cube, got: {err}"
4670 );
4671 }
4672 }
4673
4674 #[test]
4675 fn test_spatial_accepts_negative_transmission_value() {
4676 let data = u238_single_resonance();
4679 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4680 let (mut t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
4681 t_3d[[12, 2, 2]] = -0.05;
4682 let config = lm_transmission_config(energies, data);
4683 let input = InputData3D::Transmission {
4684 transmission: t_3d.view(),
4685 uncertainty: u_3d.view(),
4686 };
4687 let result = spatial_map_typed(&input, &config, None, None, None)
4688 .expect("a finite negative transmission value must not be rejected");
4689 assert_eq!(result.n_total, 16);
4690 }
4691
4692 #[test]
4693 fn test_spatial_rejects_bad_sample_counts() {
4694 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4695 for bad in [f64::NAN, f64::INFINITY, -1.0] {
4696 let data = u238_single_resonance();
4697 let (mut sample, ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4698 sample[[8, 0, 3]] = bad;
4699 let config = kl_counts_config(energies.clone(), data);
4700 let input = InputData3D::Counts {
4701 sample_counts: sample.view(),
4702 open_beam_counts: ob.view(),
4703 };
4704 let err = spatial_map_typed(&input, &config, None, None, None)
4705 .expect_err("bad sample count must be rejected up-front");
4706 assert!(
4707 matches!(err, PipelineError::InvalidParameter(_)),
4708 "got {err:?}"
4709 );
4710 assert!(
4711 err.to_string().contains("sample_counts"),
4712 "error must name the sample_counts cube, got: {err}"
4713 );
4714 }
4715 }
4716
4717 #[test]
4718 fn test_spatial_rejects_bad_open_beam() {
4719 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4723 for bad in [f64::NAN, f64::INFINITY, -1.0] {
4724 let data = u238_single_resonance();
4725 let (sample, mut ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4726 ob[[6, 3, 1]] = bad;
4727 let config = kl_counts_config(energies.clone(), data);
4728 let input = InputData3D::Counts {
4729 sample_counts: sample.view(),
4730 open_beam_counts: ob.view(),
4731 };
4732 let err = spatial_map_typed(&input, &config, None, None, None)
4733 .expect_err("bad open-beam must be rejected up-front");
4734 assert!(
4735 matches!(err, PipelineError::InvalidParameter(_)),
4736 "got {err:?}"
4737 );
4738 assert!(
4739 err.to_string().contains("open_beam_counts"),
4740 "error must name the open_beam_counts cube, got: {err}"
4741 );
4742 }
4743 }
4744
4745 #[test]
4746 fn test_spatial_counts_with_nuisance_rejects_bad_flux() {
4747 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4748 for bad in [f64::NAN, -1.0] {
4749 let data = u238_single_resonance();
4750 let (sample, _ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4751 let mut flux = Array3::from_elem((energies.len(), 4, 4), 1000.0);
4752 let background = Array3::from_elem((energies.len(), 4, 4), 0.0);
4753 flux[[4, 2, 1]] = bad;
4754 let config = kl_counts_config(energies.clone(), data);
4755 let input = InputData3D::CountsWithNuisance {
4756 sample_counts: sample.view(),
4757 flux: flux.view(),
4758 background: background.view(),
4759 };
4760 let err = spatial_map_typed(&input, &config, None, None, None)
4761 .expect_err("bad flux must be rejected up-front");
4762 assert!(
4763 matches!(err, PipelineError::InvalidParameter(_)),
4764 "got {err:?}"
4765 );
4766 assert!(
4767 err.to_string().contains("flux"),
4768 "error must name the flux cube, got: {err}"
4769 );
4770 }
4771 }
4772
4773 #[test]
4774 fn test_spatial_counts_with_nuisance_rejects_nonfinite_background() {
4775 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4779 for bad in [f64::NAN, f64::INFINITY] {
4780 let data = u238_single_resonance();
4781 let (sample, _ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4782 let flux = Array3::from_elem((energies.len(), 4, 4), 1000.0);
4783 let mut background = Array3::from_elem((energies.len(), 4, 4), 0.0);
4784 background[[2, 3, 3]] = bad;
4785 let config = kl_counts_config(energies.clone(), data);
4786 let input = InputData3D::CountsWithNuisance {
4787 sample_counts: sample.view(),
4788 flux: flux.view(),
4789 background: background.view(),
4790 };
4791 let err = spatial_map_typed(&input, &config, None, None, None)
4792 .expect_err("non-finite background must be rejected up-front");
4793 assert!(
4794 matches!(err, PipelineError::InvalidParameter(_)),
4795 "got {err:?}"
4796 );
4797 assert!(
4798 err.to_string().contains("background"),
4799 "error must name the background cube, got: {err}"
4800 );
4801 }
4802 }
4803
4804 #[test]
4810 fn test_spatial_counts_with_nuisance_rejects_nonzero_live_background() {
4811 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4812 for nonzero_background in [1.0, 5.0e-13] {
4813 let data = u238_single_resonance();
4814 let (sample, _ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4815 let flux = Array3::from_elem((energies.len(), 4, 4), 1000.0);
4816 let mut background = Array3::from_elem((energies.len(), 4, 4), 0.0);
4817 background[[2, 3, 3]] = nonzero_background;
4818 let config = kl_counts_config(energies.clone(), data);
4819 let input = InputData3D::CountsWithNuisance {
4820 sample_counts: sample.view(),
4821 flux: flux.view(),
4822 background: background.view(),
4823 };
4824
4825 let err = spatial_map_typed(&input, &config, None, None, None)
4826 .expect_err("unsupported detector background must fail before pixel fitting");
4827 assert!(
4828 matches!(err, PipelineError::InvalidParameter(_)),
4829 "got {err:?}"
4830 );
4831 assert!(
4832 err.to_string().contains("non-zero detector_background"),
4833 "error must name the unsupported detector background, got: {err}"
4834 );
4835 }
4836 }
4837
4838 #[test]
4842 fn test_spatial_uniform_nonzero_background_errors_instead_of_all_nan_map() {
4843 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4844 let data = u238_single_resonance();
4845 let (sample, _ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4846 let flux = Array3::from_elem((energies.len(), 4, 4), 1000.0);
4847 let background = Array3::from_elem((energies.len(), 4, 4), 2.0);
4848 let config = kl_counts_config(energies.clone(), data);
4849 let input = InputData3D::CountsWithNuisance {
4850 sample_counts: sample.view(),
4851 flux: flux.view(),
4852 background: background.view(),
4853 };
4854
4855 let err = spatial_map_typed(&input, &config, None, None, None)
4856 .expect_err("a wholly unsupported background must not report success");
4857 assert!(
4858 err.to_string().contains("non-zero detector_background"),
4859 "got: {err}"
4860 );
4861 }
4862
4863 #[test]
4864 fn test_spatial_transmission_tolerates_nan_in_inactive_bin() {
4865 let data = u238_single_resonance();
4870 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4871 let (mut t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
4872 t_3d[[0, 1, 1]] = f64::NAN;
4874 let config = lm_transmission_config(energies, data)
4875 .with_fit_energy_range(Some((3.0, 9.0)))
4876 .unwrap();
4877 let input = InputData3D::Transmission {
4878 transmission: t_3d.view(),
4879 uncertainty: u_3d.view(),
4880 };
4881 let result = spatial_map_typed(&input, &config, None, None, None)
4882 .expect("NaN in an inactive (out-of-range) bin must be tolerated");
4883 assert!(
4884 result.n_converged > 0,
4885 "the active-bin fit should still converge"
4886 );
4887 }
4888
4889 #[test]
4890 fn test_spatial_rejects_nan_transmission_in_active_bin_with_range() {
4891 let data = u238_single_resonance();
4894 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4895 let (mut t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
4896 t_3d[[20, 0, 0]] = f64::NAN;
4898 let config = lm_transmission_config(energies, data)
4899 .with_fit_energy_range(Some((3.0, 9.0)))
4900 .unwrap();
4901 let input = InputData3D::Transmission {
4902 transmission: t_3d.view(),
4903 uncertainty: u_3d.view(),
4904 };
4905 let err = spatial_map_typed(&input, &config, None, None, None)
4906 .expect_err("NaN in an active bin must be rejected up-front");
4907 assert!(
4908 matches!(err, PipelineError::InvalidParameter(_)),
4909 "got {err:?}"
4910 );
4911 assert!(
4912 err.to_string().contains("transmission"),
4913 "error must name the transmission cube, got: {err}"
4914 );
4915 }
4916
4917 #[test]
4918 fn test_spatial_accepts_bad_value_in_dead_pixel() {
4919 let data = u238_single_resonance();
4922 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4923 let (mut sample, ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4924 sample[[5, 0, 0]] = f64::NAN;
4925 let config = kl_counts_config(energies, data);
4926 let mut dead = Array2::from_elem((4, 4), false);
4927 dead[[0, 0]] = true;
4928 let input = InputData3D::Counts {
4929 sample_counts: sample.view(),
4930 open_beam_counts: ob.view(),
4931 };
4932 let result = spatial_map_typed(&input, &config, Some(&dead), None, None)
4933 .expect("a bad value in a dead-masked pixel must be tolerated");
4934 assert!(
4935 result.n_converged > 0,
4936 "the remaining live pixels should still fit"
4937 );
4938 }
4939
4940 #[test]
4941 fn test_spatial_accepts_zero_counts_and_open_beam() {
4942 let data = u238_single_resonance();
4945 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4946 let (mut sample, mut ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4947 sample[[3, 2, 2]] = 0.0;
4948 ob[[7, 1, 1]] = 0.0;
4949 let config = kl_counts_config(energies, data);
4950 let input = InputData3D::Counts {
4951 sample_counts: sample.view(),
4952 open_beam_counts: ob.view(),
4953 };
4954 let result = spatial_map_typed(&input, &config, None, None, None)
4955 .expect("zero counts / zero open-beam are legitimate and must not be rejected");
4956 assert_eq!(result.n_total, 16);
4957 }
4958
4959 #[test]
4960 fn test_spatial_rejects_open_beam_flux_overflow() {
4961 let data = u238_single_resonance();
4968 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4969 let (sample, mut ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4970 for y in 0..4 {
4971 for x in 0..4 {
4972 ob[[5, y, x]] = f64::MAX;
4973 }
4974 }
4975 let config = kl_counts_config(energies, data);
4976 let input = InputData3D::Counts {
4977 sample_counts: sample.view(),
4978 open_beam_counts: ob.view(),
4979 };
4980 let err = spatial_map_typed(&input, &config, None, None, None)
4981 .expect_err("an overflowing averaged open-beam flux must be rejected up-front");
4982 assert!(
4983 matches!(err, PipelineError::InvalidParameter(_)),
4984 "got {err:?}"
4985 );
4986 assert!(
4987 err.to_string().contains("averaged open-beam flux"),
4988 "error must name the averaged-flux overflow, got: {err}"
4989 );
4990 }
4991
4992 const SPATIAL_BL_TRUE: [f64; 3] = [1.02, -0.03, 0.01];
4998
4999 fn spatial_baseline_at(e: f64, e_ref: f64) -> f64 {
5000 let z = (e / e_ref).ln();
5001 SPATIAL_BL_TRUE[0] + SPATIAL_BL_TRUE[1] * z + SPATIAL_BL_TRUE[2] * z * z
5002 }
5003
5004 fn baseline_thermometry_cube(
5010 energies: &[f64],
5011 true_density: f64,
5012 true_temp: f64,
5013 i0: f64,
5014 ) -> (Array3<f64>, Array3<f64>) {
5015 let data = u238_single_resonance();
5016 let xs = nereids_physics::transmission::broadened_cross_sections(
5017 energies,
5018 std::slice::from_ref(&data),
5019 true_temp,
5020 None,
5021 None,
5022 )
5023 .unwrap();
5024 let model = PrecomputedTransmissionModel {
5025 cross_sections: Arc::new(xs),
5026 density_indices: Arc::new(vec![0]),
5027 instrument: None,
5028 resolution_plan: None,
5029 sparse_cubature_plan: None,
5030 sparse_scalar_plan: None,
5031 layout: Arc::new(nereids_physics::transmission::WorkingGridLayout::identity(
5032 energies,
5033 )),
5034 };
5035 let t_1d = model.evaluate(&[true_density]).unwrap();
5036 let e_ref = nereids_fitting::transmission_model::baseline_reference_energy(energies);
5037 let n_e = energies.len();
5038 let mut sample = Array3::zeros((n_e, 3, 3));
5039 let mut ob = Array3::zeros((n_e, 3, 3));
5040 for y in 0..3 {
5041 for x in 0..3 {
5042 for (i, (&t, &e)) in t_1d.iter().zip(energies.iter()).enumerate() {
5043 let lam = i0 * spatial_baseline_at(e, e_ref) * t;
5044 let g = (1.7 * (i as f64) + 7.9 * (y as f64) + 13.3 * (x as f64)).sin();
5046 sample[[i, y, x]] = (lam + lam.sqrt() * g).round().max(0.0);
5047 ob[[i, y, x]] = i0;
5048 }
5049 }
5050 }
5051 (sample, ob)
5052 }
5053
5054 #[test]
5055 fn spatial_global_baseline_recovers_truth_and_beats_unmodeled_control() {
5056 let true_density = 0.002;
5057 let true_temp = 600.0;
5058 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
5059 let (sample, ob) = baseline_thermometry_cube(&energies, true_density, true_temp, 400.0);
5060
5061 let base_config = UnifiedFitConfig::new(
5064 energies.clone(),
5065 vec![u238_single_resonance()],
5066 vec!["U-238".into()],
5067 500.0,
5068 None,
5069 vec![true_density],
5070 )
5071 .unwrap()
5072 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
5073 .with_fit_temperature(true)
5074 .with_fix_densities(true);
5075
5076 let input = InputData3D::Counts {
5077 sample_counts: sample.view(),
5078 open_beam_counts: ob.view(),
5079 };
5080
5081 let with_bl = base_config
5083 .clone()
5084 .with_multiplicative_baseline(crate::pipeline::MultiplicativeBaselineConfig::default());
5085 let r = spatial_map_typed(&input, &with_bl, None, None, None).unwrap();
5086 assert_eq!(r.n_converged, 9, "all 9 pixels converge in global mode");
5087 assert!(
5088 r.warnings.is_empty(),
5089 "no degenerate trio here: {:?}",
5090 r.warnings
5091 );
5092 assert!(
5093 r.baseline_maps.is_none(),
5094 "global mode reports a scalar baseline, not maps"
5095 );
5096
5097 let bg = r.baseline_global.expect("global baseline populated");
5101 for (i, (&fitted, &truth)) in bg.iter().zip(SPATIAL_BL_TRUE.iter()).enumerate() {
5102 assert!(
5103 (fitted - truth).abs() < 0.01,
5104 "baseline_global[{i}] = {fitted} vs truth {truth}"
5105 );
5106 }
5107 let e_ref_expected =
5108 nereids_fitting::transmission_model::baseline_reference_energy(&energies);
5109 let e_ref = r.baseline_e_ref_ev.expect("E_ref reported");
5110 assert!(
5111 (e_ref - e_ref_expected).abs() < 1e-12,
5112 "E_ref {e_ref} != geometric midpoint {e_ref_expected}"
5113 );
5114
5115 let t_map = r.temperature_map.as_ref().unwrap();
5118 let mut temps: Vec<f64> = t_map.iter().copied().filter(|v| v.is_finite()).collect();
5119 temps.sort_by(|a, b| a.partial_cmp(b).unwrap());
5120 let median_t = temps[temps.len() / 2];
5121 assert!(
5122 (median_t - true_temp).abs() < 15.0,
5123 "median fitted T = {median_t} vs truth {true_temp}"
5124 );
5125
5126 let control = spatial_map_typed(&input, &base_config, None, None, None).unwrap();
5134 let mean_dpd = |res: &SpatialResult| -> f64 {
5135 let m = res.deviance_per_dof_map.as_ref().unwrap();
5136 let v: Vec<f64> = m.iter().copied().filter(|v| v.is_finite()).collect();
5137 v.iter().sum::<f64>() / v.len() as f64
5138 };
5139 let dpd_baseline = mean_dpd(&r);
5140 let dpd_control = mean_dpd(&control);
5141 assert!(
5142 dpd_baseline < dpd_control,
5143 "modeling the baseline must improve the fit: D/dof {dpd_baseline} \
5144 (baseline) vs {dpd_control} (unmodeled control)"
5145 );
5146 }
5147
5148 #[test]
5149 fn spatial_per_pixel_baseline_mode_populates_maps() {
5150 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
5151 let (sample, ob) = baseline_thermometry_cube(&energies, 0.002, 600.0, 400.0);
5152 let config = UnifiedFitConfig::new(
5153 energies,
5154 vec![u238_single_resonance()],
5155 vec!["U-238".into()],
5156 500.0,
5157 None,
5158 vec![0.002],
5159 )
5160 .unwrap()
5161 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
5162 .with_fit_temperature(true)
5163 .with_fix_densities(true)
5164 .with_multiplicative_baseline(crate::pipeline::MultiplicativeBaselineConfig {
5165 spatial_global: false,
5166 ..Default::default()
5167 });
5168 let input = InputData3D::Counts {
5169 sample_counts: sample.view(),
5170 open_beam_counts: ob.view(),
5171 };
5172 let r = spatial_map_typed(&input, &config, None, None, None).unwrap();
5173 assert!(
5174 r.baseline_global.is_none(),
5175 "per-pixel mode has no global baseline"
5176 );
5177 assert!(
5178 r.baseline_e_ref_ev.is_some(),
5179 "E_ref reported in both modes"
5180 );
5181 let maps = r.baseline_maps.as_ref().expect("per-pixel baseline maps");
5182 for y in 0..3 {
5183 for x in 0..3 {
5184 if !r.converged_map[[y, x]] {
5185 continue;
5186 }
5187 let b0 = maps[0][[y, x]];
5188 assert!(
5189 (b0 - SPATIAL_BL_TRUE[0]).abs() < 0.05,
5190 "per-pixel b0[{y},{x}] = {b0} vs truth {}",
5191 SPATIAL_BL_TRUE[0]
5192 );
5193 assert!(maps[1][[y, x]].is_finite() && maps[2][[y, x]].is_finite());
5194 }
5195 }
5196 assert!(r.n_converged > 0, "at least some pixels converge");
5197 }
5198
5199 #[test]
5207 fn spatial_global_baseline_as_only_free_block_rejected_up_front() {
5208 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
5209 let (sample, ob) = baseline_thermometry_cube(&energies, 0.002, 600.0, 400.0);
5210 let config = UnifiedFitConfig::new(
5214 energies,
5215 vec![u238_single_resonance()],
5216 vec!["U-238".into()],
5217 600.0,
5218 None,
5219 vec![0.002],
5220 )
5221 .unwrap()
5222 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
5223 .with_fix_densities(true)
5224 .with_multiplicative_baseline(crate::pipeline::MultiplicativeBaselineConfig::default());
5225 let input = InputData3D::Counts {
5226 sample_counts: sample.view(),
5227 open_beam_counts: ob.view(),
5228 };
5229 let err = spatial_map_typed(&input, &config, None, None, None).expect_err(
5230 "global-baseline-only config must be a whole-map rejection, not \
5231 an Ok(all-NaN) result",
5232 );
5233 let msg = err.to_string();
5234 assert!(
5235 msg.contains("only free parameter block"),
5236 "error must explain the stage-2 freeze consequence, got: {msg}"
5237 );
5238
5239 let per_pixel =
5242 config.with_multiplicative_baseline(crate::pipeline::MultiplicativeBaselineConfig {
5243 spatial_global: false,
5244 ..Default::default()
5245 });
5246 let r = spatial_map_typed(&input, &per_pixel, None, None, None)
5247 .expect("per-pixel baseline-only fits are well-posed");
5248 assert!(r.n_converged > 0, "per-pixel baseline-only fits converge");
5249 }
5250
5251 #[test]
5252 fn spatial_stage1_nonconvergence_is_hard_error() {
5253 let data = u238_single_resonance();
5257 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
5258 let (t_3d, sigma_3d) = synthetic_grid_transmission(&data, 0.002, &energies, 2, 2);
5259 let e_ref = nereids_fitting::transmission_model::baseline_reference_energy(&energies);
5260 let mut t_bl = t_3d.clone();
5261 for y in 0..2 {
5262 for x in 0..2 {
5263 for (i, &e) in energies.iter().enumerate() {
5264 t_bl[[i, y, x]] = t_3d[[i, y, x]] * spatial_baseline_at(e, e_ref);
5265 }
5266 }
5267 }
5268 let config = UnifiedFitConfig::new(
5269 energies,
5270 vec![data],
5271 vec!["U-238".into()],
5272 0.0,
5273 None,
5274 vec![0.001],
5275 )
5276 .unwrap()
5277 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig {
5278 max_iter: 1,
5279 ..LmConfig::default()
5280 }))
5281 .with_multiplicative_baseline(crate::pipeline::MultiplicativeBaselineConfig::default());
5282 let input = InputData3D::Transmission {
5283 transmission: t_bl.view(),
5284 uncertainty: sigma_3d.view(),
5285 };
5286 let err = spatial_map_typed(&input, &config, None, None, None)
5287 .expect_err("non-converged stage 1 must be a hard error");
5288 assert!(
5289 err.to_string().contains("stage 1 did not converge"),
5290 "error must name stage 1, got: {err}"
5291 );
5292 }
5293
5294 #[test]
5295 fn spatial_rejects_free_anorm_with_baseline_up_front() {
5296 let data = u238_single_resonance();
5297 let energies: Vec<f64> = (0..11).map(|i| 1.0 + (i as f64) * 0.1).collect();
5298 let (t_3d, sigma_3d) = synthetic_grid_transmission(&data, 0.002, &energies, 2, 2);
5299 let config = UnifiedFitConfig::new(
5300 energies,
5301 vec![data],
5302 vec!["U-238".into()],
5303 0.0,
5304 None,
5305 vec![0.001],
5306 )
5307 .unwrap()
5308 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
5309 .with_transmission_background(crate::pipeline::BackgroundConfig::default())
5311 .with_multiplicative_baseline(crate::pipeline::MultiplicativeBaselineConfig::default());
5312 let input = InputData3D::Transmission {
5313 transmission: t_3d.view(),
5314 uncertainty: sigma_3d.view(),
5315 };
5316 let err = spatial_map_typed(&input, &config, None, None, None)
5317 .expect_err("free Anorm + baseline must be hoisted to a whole-map rejection");
5318 assert!(
5319 err.to_string().contains("Anorm"),
5320 "rejection must name the degeneracy, got: {err}"
5321 );
5322 }
5323
5324 #[test]
5325 fn spatial_result_carries_degenerate_trio_warning() {
5326 let data = u238_single_resonance();
5330 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
5331 let (t_3d, sigma_3d) = synthetic_grid_transmission(&data, 0.002, &energies, 2, 2);
5332 let config = UnifiedFitConfig::new(
5333 energies,
5334 vec![data],
5335 vec!["U-238".into()],
5336 300.0,
5337 None,
5338 vec![0.001],
5339 )
5340 .unwrap()
5341 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig {
5342 max_iter: 2,
5343 ..LmConfig::default()
5344 }))
5345 .with_fit_temperature(true)
5346 .with_transmission_background(crate::pipeline::BackgroundConfig::default());
5347 let input = InputData3D::Transmission {
5348 transmission: t_3d.view(),
5349 uncertainty: sigma_3d.view(),
5350 };
5351 let r = spatial_map_typed(&input, &config, None, None, None).unwrap();
5352 assert!(
5353 r.warnings.iter().any(|w| w.contains("degenerate")),
5354 "spatial result must carry the degenerate-trio warning, got {:?}",
5355 r.warnings
5356 );
5357 }
5358}