1use std::fmt;
11use std::sync::Arc;
12
13use nereids_core::constants::{EV_TO_JOULES, NEUTRON_MASS_KG};
14use nereids_endf::resonance::ResonanceData;
15use nereids_fitting::joint_poisson::{self, JointPoissonFitConfig, JointPoissonObjective};
16use nereids_fitting::lm::{self, FitModel, LmConfig, LmResult};
17use nereids_fitting::parameters::{FitParameter, ParameterSet};
18use nereids_fitting::poisson::{self, PoissonConfig};
19use nereids_fitting::transmission_model::{
20 EnergyScaleTransmissionModel, NormalizedTransmissionModel, PrecomputedTransmissionModel,
21 TransmissionFitModel,
22};
23use nereids_physics::resolution::ResolutionFunction;
24use nereids_physics::transmission::InstrumentParams;
25
26use crate::error::PipelineError;
27
28type PrecomputedWorkXs = (
33 Arc<Vec<Vec<f64>>>,
34 Arc<nereids_physics::transmission::WorkingGridLayout>,
35);
36
37#[derive(Debug, Clone)]
51pub struct BackgroundConfig {
52 pub anorm_init: f64,
54 pub back_a_init: f64,
56 pub back_b_init: f64,
58 pub back_c_init: f64,
60 pub back_d_init: f64,
65 pub back_f_init: f64,
70 pub fit_anorm: bool,
72 pub fit_back_a: bool,
74 pub fit_back_b: bool,
76 pub fit_back_c: bool,
78 pub fit_back_d: bool,
80 pub fit_back_f: bool,
82}
83
84impl Default for BackgroundConfig {
85 fn default() -> Self {
86 Self {
87 anorm_init: 1.0,
88 back_a_init: 0.0,
89 back_b_init: 0.0,
90 back_c_init: 0.0,
91 back_d_init: 0.01,
92 back_f_init: 1.0,
93 fit_anorm: true,
94 fit_back_a: true,
95 fit_back_b: true,
96 fit_back_c: true,
97 fit_back_d: false,
98 fit_back_f: false,
99 }
100 }
101}
102
103#[derive(Debug, Clone, Copy)]
108struct BackgroundIndices {
109 anorm: usize,
110 back_a: usize,
111 back_b: usize,
112 back_c: usize,
113 back_d: Option<usize>,
114 back_f: Option<usize>,
115}
116
117#[derive(Debug, Clone)]
129pub enum InputData {
130 Transmission {
134 transmission: Vec<f64>,
136 uncertainty: Vec<f64>,
138 },
139 Counts {
145 sample_counts: Vec<f64>,
147 open_beam_counts: Vec<f64>,
149 },
150 CountsWithNuisance {
154 sample_counts: Vec<f64>,
156 flux: Vec<f64>,
158 background: Vec<f64>,
160 },
161}
162
163impl InputData {
164 pub fn n_energies(&self) -> usize {
166 match self {
167 Self::Transmission { transmission, .. } => transmission.len(),
168 Self::Counts { sample_counts, .. } => sample_counts.len(),
169 Self::CountsWithNuisance { sample_counts, .. } => sample_counts.len(),
170 }
171 }
172
173 pub fn is_counts(&self) -> bool {
175 matches!(self, Self::Counts { .. } | Self::CountsWithNuisance { .. })
176 }
177}
178
179#[derive(Debug, Clone, Default)]
184pub enum SolverConfig {
185 LevenbergMarquardt(LmConfig),
187 PoissonKL(PoissonConfig),
200 #[default]
203 Auto,
204}
205
206#[derive(Debug, Clone)]
235pub struct CountsBackgroundConfig {
236 pub alpha_1_init: f64,
242 pub alpha_2_init: f64,
245 pub fit_alpha_1: bool,
249 pub fit_alpha_2: bool,
251 pub c: f64,
261}
262
263impl Default for CountsBackgroundConfig {
264 fn default() -> Self {
265 Self {
266 alpha_1_init: 1.0,
267 alpha_2_init: 1.0,
268 fit_alpha_1: false,
269 fit_alpha_2: false,
270 c: 1.0,
271 }
272 }
273}
274
275#[derive(Debug, Clone)]
282pub struct UnifiedFitConfig {
283 energies: Vec<f64>,
285 resonance_data: Vec<ResonanceData>,
286 isotope_names: Vec<String>,
287 temperature_k: f64,
288 resolution: Option<ResolutionFunction>,
289 initial_densities: Vec<f64>,
290 fit_temperature: bool,
291 compute_covariance: bool,
292
293 solver: SolverConfig,
295
296 transmission_background: Option<BackgroundConfig>,
299 counts_background: Option<CountsBackgroundConfig>,
301
302 counts_enable_polish: Option<bool>,
309
310 precomputed_cross_sections: Option<Arc<Vec<Vec<f64>>>>,
312 precomputed_work_cross_sections: Option<PrecomputedWorkXs>,
330 precomputed_base_xs: Option<Arc<Vec<Vec<f64>>>>,
331 precomputed_resolution_plan: Option<Arc<nereids_physics::resolution::ResolutionPlan>>,
340 precomputed_sparse_cubature_plan:
351 Option<Arc<nereids_physics::surrogate::SparseEmpiricalCubaturePlan>>,
352 precomputed_sparse_scalar_plan: Option<Arc<nereids_physics::surrogate::ScalarSurrogatePlan>>,
356
357 fit_energy_scale: bool,
362 t0_init_us: f64,
364 l_scale_init: f64,
366 flight_path_m: f64,
368 tzero_jacobian_method: Option<nereids_fitting::transmission_model::EnergyScaleJacobianMethod>,
374
375 fit_energy_range: Option<(f64, f64)>,
395
396 pub(crate) density_indices: Option<Vec<usize>>,
400 pub(crate) density_ratios: Option<Vec<f64>>,
403 n_density_params: Option<usize>,
406}
407
408impl UnifiedFitConfig {
409 pub fn new(
411 energies: Vec<f64>,
412 resonance_data: Vec<ResonanceData>,
413 isotope_names: Vec<String>,
414 temperature_k: f64,
415 resolution: Option<ResolutionFunction>,
416 initial_densities: Vec<f64>,
417 ) -> Result<Self, FitConfigError> {
418 if energies.is_empty() {
419 return Err(FitConfigError::EmptyEnergies);
420 }
421 if resonance_data.is_empty() {
422 return Err(FitConfigError::EmptyResonanceData);
423 }
424 if initial_densities.len() != resonance_data.len() {
425 return Err(FitConfigError::DensityCountMismatch {
426 densities: initial_densities.len(),
427 isotopes: resonance_data.len(),
428 });
429 }
430 if isotope_names.len() != resonance_data.len() {
431 return Err(FitConfigError::NameCountMismatch {
432 names: isotope_names.len(),
433 isotopes: resonance_data.len(),
434 });
435 }
436 if !temperature_k.is_finite() {
437 return Err(FitConfigError::NonFiniteTemperature(temperature_k));
438 }
439 if temperature_k < 0.0 {
440 return Err(FitConfigError::NegativeTemperature(temperature_k));
441 }
442 Ok(Self {
443 energies,
444 resonance_data,
445 isotope_names,
446 temperature_k,
447 resolution,
448 initial_densities,
449 fit_temperature: false,
450 compute_covariance: true,
451 solver: SolverConfig::Auto,
452 transmission_background: None,
453 counts_background: None,
454 counts_enable_polish: None,
455 precomputed_cross_sections: None,
456 precomputed_work_cross_sections: None,
457 precomputed_base_xs: None,
458 precomputed_resolution_plan: None,
459 precomputed_sparse_cubature_plan: None,
460 precomputed_sparse_scalar_plan: None,
461 fit_energy_scale: false,
462 t0_init_us: 0.0,
463 l_scale_init: 1.0,
464 flight_path_m: 25.0,
465 density_indices: None,
466 density_ratios: None,
467 n_density_params: None,
468 tzero_jacobian_method: None,
469 fit_energy_range: None,
470 })
471 }
472
473 #[must_use]
479 pub fn with_tzero_jacobian_method(
480 mut self,
481 method: Option<nereids_fitting::transmission_model::EnergyScaleJacobianMethod>,
482 ) -> Self {
483 self.tzero_jacobian_method = method;
484 self
485 }
486
487 #[must_use]
490 pub fn with_solver(mut self, solver: SolverConfig) -> Self {
491 self.solver = solver;
492 self
493 }
494
495 #[must_use]
496 pub fn with_fit_temperature(mut self, v: bool) -> Self {
497 self.fit_temperature = v;
498 self
499 }
500
501 #[must_use]
502 pub fn with_compute_covariance(mut self, v: bool) -> Self {
503 self.compute_covariance = v;
504 self
505 }
506
507 #[must_use]
513 pub fn with_energy_scale(
514 mut self,
515 t0_init_us: f64,
516 l_scale_init: f64,
517 flight_path_m: f64,
518 ) -> Self {
519 self.fit_energy_scale = true;
520 self.t0_init_us = t0_init_us;
521 self.l_scale_init = l_scale_init;
522 self.flight_path_m = flight_path_m;
523 self
524 }
525
526 pub fn with_fit_energy_range(
539 mut self,
540 range: Option<(f64, f64)>,
541 ) -> Result<Self, FitConfigError> {
542 if let Some((lo, hi)) = range {
543 if !lo.is_finite() || !hi.is_finite() {
544 return Err(FitConfigError::InvalidFitEnergyRange(
545 "fit_energy_range bounds must be finite",
546 ));
547 }
548 if lo >= hi {
549 return Err(FitConfigError::InvalidFitEnergyRange(
550 "fit_energy_range min must be strictly less than max",
551 ));
552 }
553 }
554 self.fit_energy_range = range;
555 Ok(self)
556 }
557
558 #[must_use]
559 pub fn with_transmission_background(mut self, bg: BackgroundConfig) -> Self {
560 self.transmission_background = Some(bg);
561 self
562 }
563
564 #[must_use]
565 pub fn with_counts_background(mut self, bg: CountsBackgroundConfig) -> Self {
566 self.counts_background = Some(bg);
567 self
568 }
569
570 #[must_use]
575 pub fn with_counts_enable_polish(mut self, v: Option<bool>) -> Self {
576 self.counts_enable_polish = v;
577 self
578 }
579
580 #[must_use]
581 pub fn with_precomputed_cross_sections(mut self, xs: Arc<Vec<Vec<f64>>>) -> Self {
582 self.precomputed_cross_sections = Some(xs);
583 self.precomputed_sparse_cubature_plan = None;
588 self.precomputed_sparse_scalar_plan = None;
589 self.precomputed_work_cross_sections = None;
593 self
594 }
595
596 #[must_use]
615 pub fn with_precomputed_work_cross_sections(
616 mut self,
617 xs: Arc<Vec<Vec<f64>>>,
618 layout: Arc<nereids_physics::transmission::WorkingGridLayout>,
619 ) -> Self {
620 self.precomputed_work_cross_sections = Some((xs, layout));
621 self
622 }
623
624 #[must_use]
625 pub fn with_precomputed_base_xs(mut self, xs: Arc<Vec<Vec<f64>>>) -> Self {
626 self.precomputed_base_xs = Some(xs);
627 self.precomputed_sparse_cubature_plan = None;
631 self.precomputed_sparse_scalar_plan = None;
632 self
633 }
634
635 #[must_use]
643 pub fn with_precomputed_resolution_plan(
644 mut self,
645 plan: Arc<nereids_physics::resolution::ResolutionPlan>,
646 ) -> Self {
647 self.precomputed_resolution_plan = Some(plan);
648 self
649 }
650
651 #[must_use]
669 pub fn with_precomputed_sparse_cubature_plan(
670 mut self,
671 plan: Arc<nereids_physics::surrogate::SparseEmpiricalCubaturePlan>,
672 ) -> Self {
673 self.precomputed_sparse_cubature_plan = Some(plan);
674 self
675 }
676
677 #[must_use]
683 pub fn with_precomputed_sparse_scalar_plan(
684 mut self,
685 plan: Arc<nereids_physics::surrogate::ScalarSurrogatePlan>,
686 ) -> Self {
687 self.precomputed_sparse_scalar_plan = Some(plan);
688 self
689 }
690
691 pub fn with_groups(
700 mut self,
701 groups: &[(&nereids_core::types::IsotopeGroup, &[ResonanceData])],
702 initial_densities: Vec<f64>,
703 ) -> Result<Self, FitConfigError> {
704 if groups.is_empty() {
705 return Err(FitConfigError::EmptyResonanceData);
706 }
707 if initial_densities.len() != groups.len() {
708 return Err(FitConfigError::DensityCountMismatch {
709 densities: initial_densities.len(),
710 isotopes: groups.len(),
711 });
712 }
713 let mut all_resonance_data = Vec::new();
714 let mut all_indices = Vec::new();
715 let mut all_ratios = Vec::new();
716 let mut names = Vec::new();
717 for (g_idx, (group, rd_list)) in groups.iter().enumerate() {
718 if rd_list.len() != group.n_members() {
719 return Err(FitConfigError::GroupMemberCountMismatch {
720 group_name: group.name().to_string(),
721 rd_count: rd_list.len(),
722 member_count: group.n_members(),
723 });
724 }
725 names.push(group.name().to_string());
726 for ((isotope, ratio), rd) in group.members().iter().zip(rd_list.iter()) {
727 if rd.isotope != *isotope {
729 return Err(FitConfigError::GroupMemberIsotopeMismatch {
730 group_name: group.name().to_string(),
731 expected_z: isotope.z(),
732 expected_a: isotope.a(),
733 got_z: rd.isotope.z(),
734 got_a: rd.isotope.a(),
735 });
736 }
737 all_resonance_data.push(rd.clone());
738 all_indices.push(g_idx);
739 all_ratios.push(*ratio);
740 }
741 }
742 self.resonance_data = all_resonance_data;
743 self.isotope_names = names;
744 self.initial_densities = initial_densities;
745 self.n_density_params = Some(groups.len());
746 self.density_indices = Some(all_indices);
747 self.density_ratios = Some(all_ratios);
748 self.precomputed_cross_sections = None;
750 self.precomputed_work_cross_sections = None;
751 self.precomputed_base_xs = None;
752 self.precomputed_sparse_cubature_plan = None;
759 self.precomputed_sparse_scalar_plan = None;
760 Ok(self)
761 }
762
763 pub fn precomputed_sparse_cubature_plan(
770 &self,
771 ) -> Option<&Arc<nereids_physics::surrogate::SparseEmpiricalCubaturePlan>> {
772 self.precomputed_sparse_cubature_plan.as_ref()
773 }
774
775 pub fn precomputed_sparse_scalar_plan(
778 &self,
779 ) -> Option<&Arc<nereids_physics::surrogate::ScalarSurrogatePlan>> {
780 self.precomputed_sparse_scalar_plan.as_ref()
781 }
782
783 pub fn energies(&self) -> &[f64] {
784 &self.energies
785 }
786 pub fn resonance_data(&self) -> &[ResonanceData] {
787 &self.resonance_data
788 }
789 pub fn isotope_names(&self) -> &[String] {
790 &self.isotope_names
791 }
792 pub fn temperature_k(&self) -> f64 {
793 self.temperature_k
794 }
795 pub fn resolution(&self) -> Option<&ResolutionFunction> {
796 self.resolution.as_ref()
797 }
798 pub fn initial_densities(&self) -> &[f64] {
799 &self.initial_densities
800 }
801 pub fn solver(&self) -> &SolverConfig {
802 &self.solver
803 }
804 pub fn fit_temperature(&self) -> bool {
805 self.fit_temperature
806 }
807 pub fn transmission_background(&self) -> Option<&BackgroundConfig> {
808 self.transmission_background.as_ref()
809 }
810 pub fn counts_background(&self) -> Option<&CountsBackgroundConfig> {
811 self.counts_background.as_ref()
812 }
813 pub fn counts_enable_polish(&self) -> Option<bool> {
815 self.counts_enable_polish
816 }
817 pub fn fit_energy_scale(&self) -> bool {
820 self.fit_energy_scale
821 }
822 pub fn fit_energy_range(&self) -> Option<(f64, f64)> {
826 self.fit_energy_range
827 }
828 pub fn precomputed_cross_sections(&self) -> Option<&Arc<Vec<Vec<f64>>>> {
829 self.precomputed_cross_sections.as_ref()
830 }
831 pub fn n_density_params(&self) -> usize {
833 self.n_density_params.unwrap_or(self.resonance_data.len())
834 }
835
836 pub(crate) fn effective_solver(&self, input: &InputData) -> SolverConfig {
838 match &self.solver {
839 SolverConfig::Auto => {
840 if input.is_counts() {
841 SolverConfig::PoissonKL(PoissonConfig::default())
842 } else {
843 SolverConfig::LevenbergMarquardt(LmConfig::default())
844 }
845 }
846 other => other.clone(),
847 }
848 }
849}
850
851pub fn fit_spectrum_typed(
864 input: &InputData,
865 config: &UnifiedFitConfig,
866) -> Result<SpectrumFitResult, PipelineError> {
867 let n_e = config.energies().len();
868
869 if config.fit_temperature && config.temperature_k < 1.0 {
871 return Err(PipelineError::InvalidParameter(format!(
872 "temperature must be >= 1.0 K when fit_temperature is true, got {}",
873 config.temperature_k,
874 )));
875 }
876
877 if input.n_energies() != n_e {
879 return Err(PipelineError::ShapeMismatch(format!(
880 "input data has {} energy bins but config.energies has {}",
881 input.n_energies(),
882 n_e,
883 )));
884 }
885
886 match input {
888 InputData::Transmission {
889 transmission,
890 uncertainty,
891 } => {
892 if uncertainty.len() != transmission.len() {
893 return Err(PipelineError::ShapeMismatch(format!(
894 "uncertainty length {} != transmission length {}",
895 uncertainty.len(),
896 transmission.len(),
897 )));
898 }
899 }
900 InputData::Counts {
901 sample_counts,
902 open_beam_counts,
903 } => {
904 if open_beam_counts.len() != sample_counts.len() {
905 return Err(PipelineError::ShapeMismatch(format!(
906 "open_beam_counts length {} != sample_counts length {}",
907 open_beam_counts.len(),
908 sample_counts.len(),
909 )));
910 }
911 }
912 InputData::CountsWithNuisance {
913 sample_counts,
914 flux,
915 background,
916 } => {
917 if flux.len() != sample_counts.len() {
918 return Err(PipelineError::ShapeMismatch(format!(
919 "flux length {} != sample_counts length {}",
920 flux.len(),
921 sample_counts.len(),
922 )));
923 }
924 if background.len() != sample_counts.len() {
925 return Err(PipelineError::ShapeMismatch(format!(
926 "background length {} != sample_counts length {}",
927 background.len(),
928 sample_counts.len(),
929 )));
930 }
931 }
932 }
933
934 validate_precomputed_cross_sections(config)?;
938
939 let effective_solver = config.effective_solver(input);
940
941 match (input, &effective_solver) {
942 (
944 InputData::Transmission {
945 transmission,
946 uncertainty,
947 },
948 SolverConfig::LevenbergMarquardt(lm_cfg),
949 ) => fit_transmission_lm(transmission, uncertainty, config, lm_cfg),
950
951 (
953 InputData::Transmission {
954 transmission,
955 uncertainty,
956 },
957 SolverConfig::PoissonKL(poisson_cfg),
958 ) => fit_transmission_poisson(transmission, uncertainty, config, poisson_cfg),
959
960 (
969 InputData::Counts {
970 sample_counts,
971 open_beam_counts,
972 },
973 SolverConfig::PoissonKL(poisson_cfg),
974 ) => {
975 let bg = vec![0.0f64; n_e];
976 fit_counts_joint_poisson(
977 sample_counts,
978 open_beam_counts,
979 &bg,
980 config,
981 &poisson_to_joint_poisson_config(poisson_cfg, config),
982 )
983 }
984
985 (
987 InputData::CountsWithNuisance {
988 sample_counts,
989 flux,
990 background,
991 },
992 SolverConfig::PoissonKL(poisson_cfg),
993 ) => fit_counts_joint_poisson(
994 sample_counts,
995 flux,
996 background,
997 config,
998 &poisson_to_joint_poisson_config(poisson_cfg, config),
999 ),
1000
1001 (
1010 InputData::Counts {
1011 sample_counts,
1012 open_beam_counts,
1013 },
1014 SolverConfig::LevenbergMarquardt(lm_cfg),
1015 ) => {
1016 let (transmission, uncertainty) =
1017 counts_to_transmission(sample_counts, open_beam_counts);
1018 fit_transmission_lm(&transmission, &uncertainty, config, lm_cfg)
1019 }
1020
1021 (InputData::CountsWithNuisance { .. }, SolverConfig::LevenbergMarquardt(_)) => {
1023 Err(PipelineError::InvalidParameter(
1024 "CountsWithNuisance requires a counts-domain solver (LM cannot use nuisance parameters)"
1025 .into(),
1026 ))
1027 }
1028
1029 (_, SolverConfig::Auto) => unreachable!("Auto should be resolved before dispatch"),
1031 }
1032}
1033
1034fn poisson_to_joint_poisson_config(
1040 poisson_cfg: &PoissonConfig,
1041 config: &UnifiedFitConfig,
1042) -> JointPoissonFitConfig {
1043 let mut jp_cfg = JointPoissonFitConfig {
1044 max_iter: poisson_cfg.max_iter,
1045 ..Default::default()
1046 };
1047 if let Some(override_val) = config.counts_enable_polish() {
1048 jp_cfg.enable_polish = override_val;
1049 }
1050 jp_cfg
1051}
1052
1053fn counts_to_transmission(sample: &[f64], open_beam: &[f64]) -> (Vec<f64>, Vec<f64>) {
1067 let transmission: Vec<f64> = sample
1068 .iter()
1069 .zip(open_beam.iter())
1070 .map(|(&s, &ob)| if ob > 0.0 { s / ob } else { 0.0 })
1071 .collect();
1072 let uncertainty: Vec<f64> = sample
1073 .iter()
1074 .zip(open_beam.iter())
1075 .map(|(&s, &ob)| {
1076 if ob <= 0.0 {
1077 1e30
1079 } else if s <= 0.0 {
1080 1e10
1082 } else {
1083 s.max(1.0).sqrt() / ob.max(1e-10)
1084 }
1085 })
1086 .collect();
1087 (transmission, uncertainty)
1088}
1089
1090fn fit_transmission_lm(
1092 measured_t: &[f64],
1093 sigma: &[f64],
1094 config: &UnifiedFitConfig,
1095 lm_config: &LmConfig,
1096) -> Result<SpectrumFitResult, PipelineError> {
1097 let n_density_params = config.n_density_params();
1098
1099 let mut param_vec = build_density_params(config);
1101
1102 if config.fit_energy_scale && config.fit_temperature {
1105 return Err(PipelineError::InvalidParameter(
1106 "fit_energy_scale and fit_temperature cannot both be true: \
1107 EnergyScaleTransmissionModel does not support temperature fitting yet"
1108 .into(),
1109 ));
1110 }
1111
1112 let _temperature_index = append_temperature_param(&mut param_vec, config);
1113 let energy_scale_indices = append_energy_scale_params(&mut param_vec, config);
1114
1115 seed_energy_scale_in_params(&mut param_vec, energy_scale_indices, measured_t, config);
1119
1120 if let Some(bg) = config.transmission_background.as_ref() {
1123 validate_transmission_background(bg)?;
1124 }
1125 let bg_indices = config
1126 .transmission_background
1127 .as_ref()
1128 .map(|bg| append_background_params(&mut param_vec, bg));
1129
1130 let mut params = ParameterSet::new(param_vec);
1131 let mut lm_cfg = lm_config.clone();
1132 lm_cfg.compute_covariance = config.compute_covariance;
1133
1134 let model: Box<dyn FitModel> = if let Some((t0_idx, ls_idx)) = energy_scale_indices {
1136 build_energy_scale_transmission_model(config, t0_idx, ls_idx)?
1137 } else {
1138 build_transmission_model(config, n_density_params, _temperature_index)?
1139 };
1140
1141 let active_mask = nereids_fitting::active_mask::build_active_mask(
1145 config.energies(),
1146 config.fit_energy_range(),
1147 );
1148
1149 if let Some((e_min, e_max)) = config.fit_energy_range() {
1160 let n_active = nereids_fitting::active_mask::active_count(
1161 active_mask.as_deref(),
1162 config.energies().len(),
1163 );
1164 let required = required_active_bins(config);
1165 if n_active < required {
1166 return Err(PipelineError::InvalidParameter(format!(
1167 "fit_energy_range [{e_min}, {e_max}] eV selects {n_active} active bin(s) \
1168 on the configured energy grid; at least {required} active bin(s) are \
1169 required for LM transmission fitting with {n_free} free parameter(s) \
1170 (underdetermined when n_active < n_free)",
1171 n_free = count_free_params(config),
1172 )));
1173 }
1174 }
1175
1176 let result = if let Some(bi) = bg_indices {
1178 let wrapped = if let (Some(di), Some(fi)) = (bi.back_d, bi.back_f) {
1179 NormalizedTransmissionModel::new_with_exponential(
1180 &*model,
1181 config.energies(),
1182 bi.anorm,
1183 bi.back_a,
1184 bi.back_b,
1185 bi.back_c,
1186 di,
1187 fi,
1188 )
1189 } else {
1190 NormalizedTransmissionModel::new(
1191 &*model,
1192 config.energies(),
1193 bi.anorm,
1194 bi.back_a,
1195 bi.back_b,
1196 bi.back_c,
1197 )
1198 };
1199 lm::levenberg_marquardt_with_mask(
1200 &wrapped,
1201 measured_t,
1202 sigma,
1203 &mut params,
1204 &lm_cfg,
1205 active_mask.as_deref(),
1206 )?
1207 } else {
1208 lm::levenberg_marquardt_with_mask(
1209 &*model,
1210 measured_t,
1211 sigma,
1212 &mut params,
1213 &lm_cfg,
1214 active_mask.as_deref(),
1215 )?
1216 };
1217
1218 let mut sr = extract_result(config, &result, n_density_params, bg_indices)?;
1219
1220 if let Some((t0_idx, ls_idx)) = energy_scale_indices {
1222 sr.t0_us = Some(result.params[t0_idx]);
1223 sr.l_scale = Some(result.params[ls_idx]);
1224 }
1225
1226 Ok(sr)
1227}
1228
1229fn fit_transmission_poisson(
1236 measured_t: &[f64],
1237 sigma: &[f64],
1238 config: &UnifiedFitConfig,
1239 poisson_cfg: &PoissonConfig,
1240) -> Result<SpectrumFitResult, PipelineError> {
1241 if config.fit_energy_range().is_some() {
1249 return Err(PipelineError::InvalidParameter(
1250 "fit_energy_range is not supported for the transmission + \
1251 Poisson-KL solver path. Use joint-Poisson (provide sample + \
1252 open-beam counts) or switch to the LM transmission solver."
1253 .into(),
1254 ));
1255 }
1256
1257 let mut poisson_cfg = poisson_cfg.clone();
1258 poisson_cfg.compute_covariance = config.compute_covariance;
1259 let poisson_cfg = &poisson_cfg;
1260
1261 let n_density_params = config.n_density_params();
1262 let mut param_vec = build_density_params(config);
1263
1264 if config.fit_temperature && config.fit_energy_scale {
1265 return Err(PipelineError::InvalidParameter(
1266 "fit_energy_scale and fit_temperature cannot both be true: \
1267 EnergyScaleTransmissionModel does not support temperature fitting yet"
1268 .into(),
1269 ));
1270 }
1271 let temperature_index = append_temperature_param(&mut param_vec, config);
1272 let energy_scale_indices = append_energy_scale_params(&mut param_vec, config);
1273
1274 seed_energy_scale_in_params(&mut param_vec, energy_scale_indices, measured_t, config);
1276
1277 if let Some(bg) = config.transmission_background.as_ref() {
1280 validate_transmission_background(bg)?;
1281 }
1282 let bg_indices = config
1283 .transmission_background
1284 .as_ref()
1285 .map(|bg| append_background_params(&mut param_vec, bg));
1286
1287 let mut params = ParameterSet::new(param_vec);
1288
1289 let model: Box<dyn FitModel> = if let Some((t0_idx, ls_idx)) = energy_scale_indices {
1291 build_energy_scale_transmission_model(config, t0_idx, ls_idx)?
1292 } else {
1293 build_transmission_model(config, n_density_params, temperature_index)?
1294 };
1295
1296 let result = if let Some(bi) = bg_indices {
1298 let wrapped = if let (Some(di), Some(fi)) = (bi.back_d, bi.back_f) {
1299 NormalizedTransmissionModel::new_with_exponential(
1300 &*model,
1301 config.energies(),
1302 bi.anorm,
1303 bi.back_a,
1304 bi.back_b,
1305 bi.back_c,
1306 di,
1307 fi,
1308 )
1309 } else {
1310 NormalizedTransmissionModel::new(
1311 &*model,
1312 config.energies(),
1313 bi.anorm,
1314 bi.back_a,
1315 bi.back_b,
1316 bi.back_c,
1317 )
1318 };
1319 let pr = poisson::poisson_fit(&wrapped, measured_t, &mut params, poisson_cfg)?;
1320 poisson_to_lm_result(&wrapped, measured_t, sigma, &pr, ¶ms)
1321 } else {
1322 let pr = poisson::poisson_fit(&*model, measured_t, &mut params, poisson_cfg)?;
1323 poisson_to_lm_result(&*model, measured_t, sigma, &pr, ¶ms)
1324 }?;
1325
1326 let mut sr = extract_result(config, &result, n_density_params, bg_indices)?;
1327
1328 if let Some(idx) = temperature_index {
1330 sr.temperature_k = Some(result.params[idx]);
1331 sr.temperature_k_unc = temperature_index.and_then(|i| {
1332 result
1333 .uncertainties
1334 .as_ref()
1335 .and_then(|u| u.get(i).copied())
1336 });
1337 }
1338
1339 if let Some((t0_idx, ls_idx)) = energy_scale_indices {
1341 sr.t0_us = Some(result.params[t0_idx]);
1342 sr.l_scale = Some(result.params[ls_idx]);
1343 }
1344
1345 Ok(sr)
1346}
1347
1348fn fit_counts_joint_poisson(
1367 sample_counts: &[f64],
1368 flux: &[f64],
1369 detector_background: &[f64],
1370 config: &UnifiedFitConfig,
1371 jp_cfg: &JointPoissonFitConfig,
1372) -> Result<SpectrumFitResult, PipelineError> {
1373 if let Some(bg) = config.counts_background()
1375 && (bg.fit_alpha_1 || bg.fit_alpha_2)
1376 {
1377 return Err(PipelineError::InvalidParameter(
1378 "joint-Poisson solver does not support fit_alpha_1/fit_alpha_2: \
1379 the profile lambda-hat absorbs the global flux scale (alpha_1 redundant); \
1380 alpha_2 / B_det wiring is deferred to memo 35 §P3."
1381 .into(),
1382 ));
1383 }
1384 if detector_background.iter().any(|&v| v.abs() > 1e-12) {
1385 return Err(PipelineError::InvalidParameter(
1386 "joint-Poisson solver with non-zero detector_background is not yet supported \
1387 (B_det wiring deferred to memo 35 §P3.2)."
1388 .into(),
1389 ));
1390 }
1391
1392 if let Some(bg) = config.transmission_background.as_ref() {
1394 if bg.fit_back_d || bg.fit_back_f {
1395 return Err(PipelineError::InvalidParameter(
1396 "joint-Poisson solver does not support the BackD/BackF exponential \
1397 tail (memo 35 §P4-deferred)."
1398 .into(),
1399 ));
1400 }
1401 if (bg.fit_back_b || bg.fit_back_c) && !bg.fit_back_a {
1402 return Err(PipelineError::InvalidParameter(
1403 "joint-Poisson transmission_background: B_A (fit_back_a) must be \
1404 enabled whenever any of B_B / B_C is enabled (memo 35 §P2.2 — \
1405 A_n alone cannot absorb a constant offset; EG2 S2 C_An → −23% \
1406 density bias)."
1407 .into(),
1408 ));
1409 }
1410 }
1411
1412 let c = config.counts_background().map(|b| b.c).unwrap_or(1.0);
1413 if !(c.is_finite() && c > 0.0) {
1414 return Err(PipelineError::InvalidParameter(format!(
1415 "joint-Poisson solver requires finite c > 0 in CountsBackgroundConfig, got {c}",
1416 )));
1417 }
1418
1419 let n_density_params = config.n_density_params();
1424 let mut param_vec = build_density_params(config);
1425
1426 if config.fit_temperature && config.fit_energy_scale {
1427 return Err(PipelineError::InvalidParameter(
1428 "fit_energy_scale and fit_temperature cannot both be true: \
1429 EnergyScaleTransmissionModel does not support temperature fitting yet"
1430 .into(),
1431 ));
1432 }
1433 let temperature_index = append_temperature_param(&mut param_vec, config);
1434 let energy_scale_indices = append_energy_scale_params(&mut param_vec, config);
1435
1436 if energy_scale_indices.is_some() {
1441 let t_proxy: Vec<f64> = sample_counts
1442 .iter()
1443 .zip(flux.iter())
1444 .zip(detector_background.iter())
1445 .map(|((&s, &f), &b)| {
1446 let den = f - b;
1447 if den > 0.0 {
1448 ((s - b).max(0.0) / den).min(2.0)
1449 } else {
1450 1.0
1451 }
1452 })
1453 .collect();
1454 seed_energy_scale_in_params(&mut param_vec, energy_scale_indices, &t_proxy, config);
1455 }
1456
1457 let bg_indices = config
1461 .transmission_background
1462 .as_ref()
1463 .map(|bg| append_background_params(&mut param_vec, bg));
1464
1465 let mut params = ParameterSet::new(param_vec);
1466
1467 let t_model: Box<dyn FitModel> = if let Some((t0_idx, ls_idx)) = energy_scale_indices {
1469 build_energy_scale_transmission_model(config, t0_idx, ls_idx)?
1470 } else {
1471 build_transmission_model(config, n_density_params, temperature_index)?
1472 };
1473
1474 let active_mask = nereids_fitting::active_mask::build_active_mask(
1485 config.energies(),
1486 config.fit_energy_range(),
1487 );
1488 let active_mask_slice = active_mask.as_deref();
1489
1490 if let Some((e_min, e_max)) = config.fit_energy_range() {
1500 let n_active =
1501 nereids_fitting::active_mask::active_count(active_mask_slice, config.energies().len());
1502 let required = required_active_bins(config);
1503 if n_active < required {
1504 return Err(PipelineError::InvalidParameter(format!(
1505 "fit_energy_range [{e_min}, {e_max}] eV selects {n_active} active bin(s) \
1506 on the configured energy grid; at least {required} active bin(s) are \
1507 required for joint-Poisson fitting with {n_free} free parameter(s) \
1508 (underdetermined when n_active < n_free)",
1509 n_free = count_free_params(config),
1510 )));
1511 }
1512 }
1513
1514 let result;
1515 if let Some(bi) = bg_indices {
1516 let wrapped = NormalizedTransmissionModel::new(
1517 &*t_model,
1518 config.energies(),
1519 bi.anorm,
1520 bi.back_a,
1521 bi.back_b,
1522 bi.back_c,
1523 );
1524 let objective = JointPoissonObjective {
1525 model: &wrapped,
1526 o: flux,
1527 s: sample_counts,
1528 c,
1529 active_mask: active_mask_slice,
1530 };
1531 let mut cfg = jp_cfg.clone();
1532 cfg.compute_covariance = config.compute_covariance;
1533 result = joint_poisson::joint_poisson_fit(&objective, &mut params, &cfg).map_err(|e| {
1534 PipelineError::InvalidParameter(format!("joint-Poisson fit failed: {e}"))
1535 })?;
1536 } else {
1537 let objective = JointPoissonObjective {
1538 model: &*t_model,
1539 o: flux,
1540 s: sample_counts,
1541 c,
1542 active_mask: active_mask_slice,
1543 };
1544 let mut cfg = jp_cfg.clone();
1545 cfg.compute_covariance = config.compute_covariance;
1546 result = joint_poisson::joint_poisson_fit(&objective, &mut params, &cfg).map_err(|e| {
1547 PipelineError::InvalidParameter(format!("joint-Poisson fit failed: {e}"))
1548 })?;
1549 }
1550
1551 let densities: Vec<f64> = (0..n_density_params).map(|i| result.params[i]).collect();
1553
1554 let (uncertainties, temperature_k_unc) = if let Some(ref unc_all) = result.uncertainties {
1555 let dens_unc: Vec<f64> = (0..n_density_params)
1556 .map(|i| *unc_all.get(i).unwrap_or(&f64::NAN))
1557 .collect();
1558 let t_unc = temperature_index.and_then(|idx| {
1559 params
1560 .free_indices()
1561 .iter()
1562 .position(|&fi| fi == idx)
1563 .and_then(|pos| unc_all.get(pos).copied())
1564 });
1565 (Some(dens_unc), t_unc)
1566 } else {
1567 (None, None)
1568 };
1569 let fitted_temp = temperature_index.map(|idx| result.params[idx]);
1570
1571 let converged = result.gn_converged || result.polish_converged;
1576
1577 let (anorm_out, bg_abc_out) = if let Some(bi) = bg_indices {
1582 (
1583 result.params[bi.anorm],
1584 [
1585 result.params[bi.back_a],
1586 result.params[bi.back_b],
1587 result.params[bi.back_c],
1588 ],
1589 )
1590 } else {
1591 (1.0, [0.0, 0.0, 0.0])
1592 };
1593
1594 Ok(SpectrumFitResult {
1595 densities,
1596 uncertainties,
1597 reduced_chi_squared: result.deviance_per_dof,
1600 converged,
1601 iterations: result.gn_iterations + result.polish_iterations,
1602 temperature_k: fitted_temp,
1603 temperature_k_unc,
1604 anorm: anorm_out,
1605 background: bg_abc_out,
1606 back_d: None,
1611 back_f: None,
1612 t0_us: energy_scale_indices.map(|(t0_idx, _)| result.params[t0_idx]),
1613 l_scale: energy_scale_indices.map(|(_, ls_idx)| result.params[ls_idx]),
1614 deviance_per_dof: Some(result.deviance_per_dof),
1615 })
1616}
1617
1618fn build_density_params(config: &UnifiedFitConfig) -> Vec<FitParameter> {
1621 config
1622 .initial_densities
1623 .iter()
1624 .enumerate()
1625 .map(|(i, &d)| {
1626 FitParameter::non_negative(
1627 config
1628 .isotope_names
1629 .get(i)
1630 .cloned()
1631 .unwrap_or_else(|| format!("isotope_{i}")),
1632 d,
1633 )
1634 })
1635 .collect()
1636}
1637
1638fn append_temperature_param(
1644 param_vec: &mut Vec<FitParameter>,
1645 config: &UnifiedFitConfig,
1646) -> Option<usize> {
1647 if !config.fit_temperature {
1648 return None;
1649 }
1650 let idx = param_vec.len();
1651 param_vec.push(FitParameter {
1652 name: "temperature_k".into(),
1653 value: config.temperature_k,
1654 lower: 1.0,
1655 upper: 5000.0,
1656 fixed: false,
1657 });
1658 Some(idx)
1659}
1660
1661fn detect_transmission_dips(measured: &[f64], energies: &[f64]) -> Vec<(f64, f64)> {
1667 let n = measured.len();
1668 if n < 5 || energies.len() != n {
1669 return Vec::new();
1670 }
1671 let mut sm = measured.to_vec();
1672 for i in 1..n - 1 {
1673 sm[i] = (measured[i - 1] + measured[i] + measured[i + 1]) / 3.0;
1674 }
1675 let mut sorted = sm.clone();
1677 sorted.sort_by(f64::total_cmp);
1678 let baseline = sorted[((0.9 * (n as f64 - 1.0)).round() as usize).min(n - 1)];
1679 let mut dips: Vec<(f64, f64)> = Vec::new();
1680 let mut max_depth = 0.0f64;
1681 for i in 1..n - 1 {
1682 if sm[i] < sm[i - 1] && sm[i] <= sm[i + 1] {
1683 let depth = baseline - sm[i];
1684 if depth > 0.0 {
1685 dips.push((energies[i], depth));
1686 max_depth = max_depth.max(depth);
1687 }
1688 }
1689 }
1690 dips.retain(|&(_, d)| d >= 0.2 * max_depth);
1693 dips
1694}
1695
1696fn peak_match_energy_scale_seed(
1712 measured: &[f64],
1713 energies: &[f64],
1714 config: &UnifiedFitConfig,
1715 flight_path_m: f64,
1716 t0_bounds: (f64, f64),
1717 l_scale_bounds: (f64, f64),
1718) -> Option<(f64, f64)> {
1719 let n = energies.len();
1720 if n < 5 || measured.len() != n {
1721 return None;
1722 }
1723 let refs: Vec<&ResonanceData> = config.resonance_data().iter().collect();
1725 let (e_lo, e_hi) = (
1726 energies[0].min(energies[n - 1]),
1727 energies[0].max(energies[n - 1]),
1728 );
1729 let res_e: Vec<f64> = nereids_physics::transmission::resonance_center_energies(&refs)
1730 .into_iter()
1731 .filter(|&e| e > e_lo && e < e_hi)
1732 .collect();
1733 if res_e.len() < 2 {
1734 return None;
1735 }
1736 let dips = detect_transmission_dips(measured, energies);
1737 if dips.len() < 2 {
1738 return None;
1739 }
1740 let min_spacing = res_e
1755 .windows(2)
1756 .map(|w| (w[1] - w[0]).abs())
1757 .fold(f64::INFINITY, f64::min);
1758 let mut grid_steps: Vec<f64> = energies.windows(2).map(|w| (w[1] - w[0]).abs()).collect();
1759 grid_steps.sort_by(f64::total_cmp);
1760 let grid_res = grid_steps.get(grid_steps.len() / 2).copied().unwrap_or(0.0);
1761 let match_tol = (0.5 * min_spacing).max(grid_res);
1762 let mut best: Vec<Option<(f64, f64)>> = vec![None; res_e.len()];
1763 for &(e_dip, depth) in &dips {
1764 let Some((k, &re)) = res_e
1765 .iter()
1766 .enumerate()
1767 .min_by(|(_, a), (_, b)| (*a - e_dip).abs().total_cmp(&(*b - e_dip).abs()))
1768 else {
1769 continue;
1770 };
1771 if (re - e_dip).abs() > match_tol {
1772 continue;
1773 }
1774 match best[k] {
1775 Some((_, d)) if d >= depth => {}
1776 _ => best[k] = Some((e_dip, depth)),
1777 }
1778 }
1779 let tof_factor = (0.5 * NEUTRON_MASS_KG / EV_TO_JOULES).sqrt() * 1.0e6;
1783 let c = tof_factor * flight_path_m;
1784 let pairs: Vec<(f64, f64)> = res_e
1785 .iter()
1786 .zip(best.iter())
1787 .filter_map(|(&re, b)| b.map(|(e_dip, _)| (c / re.sqrt(), c / e_dip.sqrt())))
1788 .collect();
1789 if pairs.len() < 2 {
1790 return None;
1791 }
1792 let m = pairs.len() as f64;
1794 let mean_x = pairs.iter().map(|p| p.0).sum::<f64>() / m;
1795 let mean_y = pairs.iter().map(|p| p.1).sum::<f64>() / m;
1796 let mut sxx = 0.0f64;
1797 let mut sxy = 0.0f64;
1798 for &(x, y) in &pairs {
1799 sxx += (x - mean_x) * (x - mean_x);
1800 sxy += (x - mean_x) * (y - mean_y);
1801 }
1802 if sxx <= 0.0 {
1803 return None;
1804 }
1805 let l_scale = sxy / sxx;
1806 let t0 = mean_y - l_scale * mean_x;
1807 if !t0.is_finite() || !l_scale.is_finite() {
1808 return None;
1809 }
1810 if t0 < t0_bounds.0
1815 || t0 > t0_bounds.1
1816 || l_scale < l_scale_bounds.0
1817 || l_scale > l_scale_bounds.1
1818 {
1819 return None;
1820 }
1821 Some((t0, l_scale))
1822}
1823
1824fn seed_energy_scale_in_params(
1828 param_vec: &mut [FitParameter],
1829 energy_scale_indices: Option<(usize, usize)>,
1830 measured_transmission: &[f64],
1831 config: &UnifiedFitConfig,
1832) {
1833 let Some((t0_idx, ls_idx)) = energy_scale_indices else {
1834 return;
1835 };
1836 let t0_b = (param_vec[t0_idx].lower, param_vec[t0_idx].upper);
1837 let ls_b = (param_vec[ls_idx].lower, param_vec[ls_idx].upper);
1838 if let Some((t0_seed, ls_seed)) = peak_match_energy_scale_seed(
1839 measured_transmission,
1840 config.energies(),
1841 config,
1842 config.flight_path_m,
1843 t0_b,
1844 ls_b,
1845 ) {
1846 param_vec[t0_idx].value = t0_seed;
1847 param_vec[ls_idx].value = ls_seed;
1848 }
1849}
1850
1851fn append_energy_scale_params(
1857 param_vec: &mut Vec<FitParameter>,
1858 config: &UnifiedFitConfig,
1859) -> Option<(usize, usize)> {
1860 if !config.fit_energy_scale {
1861 return None;
1862 }
1863 let t0_idx = param_vec.len();
1864 param_vec.push(FitParameter {
1865 name: "t0_us".into(),
1866 value: config.t0_init_us,
1867 lower: -10.0,
1868 upper: 10.0,
1869 fixed: false,
1870 });
1871 let ls_idx = param_vec.len();
1872 param_vec.push(FitParameter {
1873 name: "l_scale".into(),
1874 value: config.l_scale_init,
1875 lower: 0.99,
1876 upper: 1.01,
1877 fixed: false,
1878 });
1879 Some((t0_idx, ls_idx))
1880}
1881
1882pub(crate) fn validate_transmission_background(bg: &BackgroundConfig) -> Result<(), PipelineError> {
1897 if bg.fit_back_d != bg.fit_back_f {
1898 return Err(PipelineError::InvalidParameter(format!(
1899 "transmission_background: fit_back_d ({}) and fit_back_f ({}) \
1900 must both be true or both be false. The exponential tail \
1901 wrapper (BackD · exp(−BackF / √E)) requires both parameters \
1902 together; enabling only one leaves the other registered but \
1903 unused, silently producing the initial value as the fitted \
1904 result. Either enable both (to fit the exponential tail) or \
1905 disable both (4-term wrapper without exponential).",
1906 bg.fit_back_d, bg.fit_back_f,
1907 )));
1908 }
1909 Ok(())
1910}
1911
1912pub(crate) fn validate_precomputed_cross_sections(
1939 config: &UnifiedFitConfig,
1940) -> Result<(), PipelineError> {
1941 let Some(xs) = config.precomputed_cross_sections() else {
1942 return Ok(());
1943 };
1944 if xs.is_empty() {
1945 return Err(PipelineError::ShapeMismatch(
1946 "precomputed_cross_sections must not be empty".into(),
1947 ));
1948 }
1949
1950 let n_e = config.energies().len();
1951 for (i, row) in xs.iter().enumerate() {
1952 if row.len() != n_e {
1953 return Err(PipelineError::ShapeMismatch(format!(
1954 "precomputed_cross_sections row {i} has length {} but config.energies \
1955 has {n_e}",
1956 row.len(),
1957 )));
1958 }
1959 if let Some(j) = row.iter().position(|s| !s.is_finite()) {
1966 return Err(PipelineError::ShapeMismatch(format!(
1967 "precomputed_cross_sections row {i} has non-finite σ at energy index {j}: {}",
1968 row[j],
1969 )));
1970 }
1971 }
1972
1973 let n_params = config.n_density_params();
1974 let member_rows = config.density_indices.as_ref().map(|di| di.len());
1977 let row_ok = xs.len() == n_params || member_rows == Some(xs.len());
1978 if !row_ok {
1979 let expected = match member_rows {
1980 Some(m) if m != n_params => format!("{n_params} (collapsed) or {m} (per-member)"),
1981 _ => format!("{n_params}"),
1982 };
1983 return Err(PipelineError::ShapeMismatch(format!(
1984 "precomputed_cross_sections has {} rows but expected {expected}",
1985 xs.len(),
1986 )));
1987 }
1988
1989 if let Some((work_xs, layout)) = &config.precomputed_work_cross_sections {
2002 let n_work = layout.energies.len();
2003 if work_xs.is_empty() {
2004 return Err(PipelineError::ShapeMismatch(
2005 "precomputed_work_cross_sections must not be empty".into(),
2006 ));
2007 }
2008 for (i, row) in work_xs.iter().enumerate() {
2009 if row.len() != n_work {
2010 return Err(PipelineError::ShapeMismatch(format!(
2011 "precomputed_work_cross_sections row {i} has length {} but \
2012 work_layout has {n_work} working-grid energies",
2013 row.len(),
2014 )));
2015 }
2016 if let Some(j) = row.iter().position(|s| !s.is_finite()) {
2017 return Err(PipelineError::ShapeMismatch(format!(
2018 "precomputed_work_cross_sections row {i} has non-finite σ at \
2019 working-grid index {j}: {}",
2020 row[j],
2021 )));
2022 }
2023 }
2024 if work_xs.len() != xs.len() {
2027 return Err(PipelineError::ShapeMismatch(format!(
2028 "precomputed_work_cross_sections has {} rows but \
2029 precomputed_cross_sections has {} — both index the same density \
2030 mapping and must agree",
2031 work_xs.len(),
2032 xs.len(),
2033 )));
2034 }
2035 if layout.data_indices.len() != n_e {
2039 return Err(PipelineError::ShapeMismatch(format!(
2040 "precomputed_work_cross_sections layout maps {} data points but \
2041 config.energies has {n_e}",
2042 layout.data_indices.len(),
2043 )));
2044 }
2045 if let Some(&bad) = layout.data_indices.iter().find(|&&idx| idx >= n_work) {
2046 return Err(PipelineError::ShapeMismatch(format!(
2047 "precomputed_work_cross_sections layout index {bad} is out of \
2048 range for {n_work} working-grid energies",
2049 )));
2050 }
2051 }
2052 Ok(())
2053}
2054
2055pub(crate) fn count_free_params(config: &UnifiedFitConfig) -> usize {
2081 let mut n_free = config.n_density_params();
2082 if config.fit_temperature {
2083 n_free += 1;
2084 }
2085 if config.fit_energy_scale {
2086 n_free += 2;
2087 }
2088 if let Some(bg) = config.transmission_background.as_ref() {
2089 n_free += usize::from(bg.fit_anorm);
2090 n_free += usize::from(bg.fit_back_a);
2091 n_free += usize::from(bg.fit_back_b);
2092 n_free += usize::from(bg.fit_back_c);
2093 n_free += usize::from(bg.fit_back_d);
2094 n_free += usize::from(bg.fit_back_f);
2095 }
2096 n_free
2097}
2098
2099pub(crate) fn required_active_bins(config: &UnifiedFitConfig) -> usize {
2118 count_free_params(config).max(2)
2119}
2120
2121fn append_background_params(
2122 param_vec: &mut Vec<FitParameter>,
2123 bg: &BackgroundConfig,
2124) -> BackgroundIndices {
2125 let anorm = param_vec.len();
2130 param_vec.push(if bg.fit_anorm {
2131 FitParameter {
2132 name: "anorm".into(),
2133 value: bg.anorm_init,
2134 lower: 0.5,
2135 upper: 2.0,
2136 fixed: false,
2137 }
2138 } else {
2139 FitParameter::fixed("anorm", bg.anorm_init)
2140 });
2141 let back_a = param_vec.len();
2147 param_vec.push(if bg.fit_back_a {
2148 FitParameter {
2149 name: "back_a".into(),
2150 value: bg.back_a_init,
2151 lower: -0.5,
2152 upper: 0.5,
2153 fixed: false,
2154 }
2155 } else {
2156 FitParameter::fixed("back_a", bg.back_a_init)
2157 });
2158 let back_b = param_vec.len();
2159 param_vec.push(if bg.fit_back_b {
2160 FitParameter {
2161 name: "back_b".into(),
2162 value: bg.back_b_init,
2163 lower: -0.5,
2164 upper: 0.5,
2165 fixed: false,
2166 }
2167 } else {
2168 FitParameter::fixed("back_b", bg.back_b_init)
2169 });
2170 let back_c = param_vec.len();
2171 param_vec.push(if bg.fit_back_c {
2172 FitParameter {
2173 name: "back_c".into(),
2174 value: bg.back_c_init,
2175 lower: -0.5,
2176 upper: 0.5,
2177 fixed: false,
2178 }
2179 } else {
2180 FitParameter::fixed("back_c", bg.back_c_init)
2181 });
2182
2183 let back_d = if bg.fit_back_d {
2191 let idx = param_vec.len();
2192 param_vec.push(FitParameter {
2193 name: "back_d".into(),
2194 value: bg.back_d_init,
2195 lower: 0.0,
2196 upper: 1.0,
2197 fixed: false,
2198 });
2199 Some(idx)
2200 } else {
2201 None
2202 };
2203 let back_f = if bg.fit_back_f {
2204 let idx = param_vec.len();
2205 param_vec.push(FitParameter {
2206 name: "back_f".into(),
2207 value: bg.back_f_init,
2208 lower: 0.0,
2209 upper: 100.0,
2210 fixed: false,
2211 });
2212 Some(idx)
2213 } else {
2214 None
2215 };
2216
2217 BackgroundIndices {
2218 anorm,
2219 back_a,
2220 back_b,
2221 back_c,
2222 back_d,
2223 back_f,
2224 }
2225}
2226
2227fn build_energy_scale_transmission_model(
2251 config: &UnifiedFitConfig,
2252 t0_idx: usize,
2253 ls_idx: usize,
2254) -> Result<Box<dyn FitModel>, PipelineError> {
2255 let instrument = config
2256 .resolution
2257 .clone()
2258 .map(|r| Arc::new(InstrumentParams { resolution: r }));
2259 let n_iso = config.resonance_data.len();
2265 let density_indices = config
2266 .density_indices
2267 .clone()
2268 .unwrap_or_else(|| (0..n_iso).collect());
2269 let density_ratios = config
2270 .density_ratios
2271 .clone()
2272 .unwrap_or_else(|| vec![1.0; n_iso]);
2273 let mut es_model = EnergyScaleTransmissionModel::new(
2274 Arc::new(config.resonance_data.clone()),
2275 Arc::new(density_indices),
2276 Arc::new(density_ratios),
2277 config.temperature_k,
2278 config.energies.clone(),
2279 config.flight_path_m,
2280 t0_idx,
2281 ls_idx,
2282 instrument,
2283 );
2284 if let Some(method) = config.tzero_jacobian_method {
2285 es_model = es_model.with_jacobian_method(method);
2286 }
2287 Ok(Box::new(es_model))
2288}
2289
2290fn build_transmission_model(
2292 config: &UnifiedFitConfig,
2293 n_density_params: usize,
2294 temperature_index: Option<usize>,
2295) -> Result<Box<dyn FitModel>, PipelineError> {
2296 let n_params = config.n_density_params();
2297 if !config.fit_temperature
2298 && let Some(xs) = &config.precomputed_cross_sections
2299 {
2300 let collapse_by_groups = |xs: &Arc<Vec<Vec<f64>>>| -> Arc<Vec<Vec<f64>>> {
2305 if let (Some(di), Some(dr)) = (&config.density_indices, &config.density_ratios)
2306 && xs.len() == di.len()
2307 && di.len() == dr.len()
2308 {
2309 let n_e = xs[0].len();
2310 let mut eff = vec![vec![0.0f64; n_e]; n_params];
2311 for ((&idx, &ratio), member_xs) in di.iter().zip(dr.iter()).zip(xs.iter()) {
2312 for (j, &sigma) in member_xs.iter().enumerate() {
2313 eff[idx][j] += ratio * sigma;
2314 }
2315 }
2316 return Arc::new(eff);
2317 }
2318 Arc::clone(xs)
2319 };
2320
2321 let (effective_xs, work_layout): (
2329 Arc<Vec<Vec<f64>>>,
2330 Option<Arc<nereids_physics::transmission::WorkingGridLayout>>,
2331 ) = match &config.precomputed_work_cross_sections {
2332 Some((work_xs, layout)) => (collapse_by_groups(work_xs), Some(Arc::clone(layout))),
2333 None => (collapse_by_groups(xs), None),
2334 };
2335 let instrument = config
2338 .resolution
2339 .clone()
2340 .map(|r| Arc::new(InstrumentParams { resolution: r }));
2341 let resolution_plan = if instrument.is_some() {
2342 config.precomputed_resolution_plan.clone()
2343 } else {
2344 None
2345 };
2346 let sparse_cubature_plan = if instrument.is_some() {
2347 config.precomputed_sparse_cubature_plan.clone()
2348 } else {
2349 None
2350 };
2351 let sparse_scalar_plan = if instrument.is_some() {
2352 config.precomputed_sparse_scalar_plan.clone()
2353 } else {
2354 None
2355 };
2356 return Ok(Box::new(PrecomputedTransmissionModel {
2357 cross_sections: effective_xs,
2358 density_indices: Arc::new((0..n_params).collect()),
2359 energies: instrument
2360 .as_ref()
2361 .map(|_| Arc::new(config.energies.clone())),
2362 instrument,
2363 resolution_plan,
2364 sparse_cubature_plan,
2365 sparse_scalar_plan,
2366 work_layout,
2367 }));
2368 }
2369
2370 let instrument = config
2371 .resolution
2372 .clone()
2373 .map(|r| Arc::new(InstrumentParams { resolution: r }));
2374
2375 let base_xs = config.precomputed_base_xs.clone();
2376 let density_ratios = config
2377 .density_ratios
2378 .clone()
2379 .unwrap_or_else(|| vec![1.0; n_density_params]);
2380 let density_indices = config
2381 .density_indices
2382 .clone()
2383 .unwrap_or_else(|| (0..n_density_params).collect());
2384 let resolution_plan = if instrument.is_some() {
2385 config.precomputed_resolution_plan.clone()
2386 } else {
2387 None
2388 };
2389 let sparse_cubature_plan = if instrument.is_some() {
2390 config.precomputed_sparse_cubature_plan.clone()
2391 } else {
2392 None
2393 };
2394 let sparse_scalar_plan = if instrument.is_some() {
2395 config.precomputed_sparse_scalar_plan.clone()
2396 } else {
2397 None
2398 };
2399 Ok(Box::new(
2400 TransmissionFitModel::new(
2401 config.energies.clone(),
2402 config.resonance_data.clone(),
2403 config.temperature_k,
2404 instrument,
2405 (density_indices, density_ratios),
2406 temperature_index,
2407 base_xs,
2408 )?
2409 .with_resolution_plan(resolution_plan)
2410 .with_sparse_cubature_plan(sparse_cubature_plan)
2411 .with_sparse_scalar_plan(sparse_scalar_plan),
2412 ))
2413}
2414
2415fn poisson_to_lm_result(
2417 model: &dyn FitModel,
2418 measured_t: &[f64],
2419 sigma: &[f64],
2420 pr: &poisson::PoissonResult,
2421 params: &ParameterSet,
2422) -> Result<LmResult, PipelineError> {
2423 let n_free = params.n_free();
2424 let dof = measured_t.len().saturating_sub(n_free).max(1);
2425 let y_model = model.evaluate(&pr.params)?;
2426 let chi_sq: f64 = measured_t
2427 .iter()
2428 .zip(y_model.iter())
2429 .zip(sigma.iter())
2430 .map(|((obs, mdl), s)| {
2431 let residual = obs - mdl;
2432 (residual * residual) / (s * s).max(1e-30)
2433 })
2434 .sum();
2435 Ok(LmResult {
2436 chi_squared: chi_sq,
2437 reduced_chi_squared: chi_sq / dof as f64,
2438 iterations: pr.iterations,
2439 converged: pr.converged,
2440 params: pr.params.clone(),
2441 covariance: pr.covariance.clone(),
2442 uncertainties: pr.uncertainties.clone(),
2443 })
2444}
2445
2446fn extract_result(
2448 config: &UnifiedFitConfig,
2449 result: &LmResult,
2450 n_density_params: usize,
2451 bg_indices: Option<BackgroundIndices>,
2452) -> Result<SpectrumFitResult, PipelineError> {
2453 let densities: Vec<f64> = (0..n_density_params).map(|i| result.params[i]).collect();
2454
2455 let (anorm, background, back_d, back_f): (f64, [f64; 3], Option<f64>, Option<f64>) =
2456 if let Some(bi) = bg_indices {
2457 let bd = bi.back_d.map(|i| result.params[i]);
2460 let bf = bi.back_f.map(|i| result.params[i]);
2461 (
2462 result.params[bi.anorm],
2463 [
2464 result.params[bi.back_a],
2465 result.params[bi.back_b],
2466 result.params[bi.back_c],
2467 ],
2468 bd,
2469 bf,
2470 )
2471 } else {
2472 (1.0, [0.0, 0.0, 0.0], None, None)
2473 };
2474
2475 let (uncertainties, temperature_k, temperature_k_unc) = if result.converged {
2476 match &result.uncertainties {
2477 Some(unc_all) => {
2478 let (temp_k, temp_unc) = if config.fit_temperature {
2479 (
2480 Some(result.params[n_density_params]),
2481 Some(*unc_all.get(n_density_params).unwrap_or(&f64::NAN)),
2482 )
2483 } else {
2484 (None, None)
2485 };
2486 let unc = unc_all
2487 .get(..n_density_params)
2488 .map(|s| s.to_vec())
2489 .unwrap_or_else(|| vec![f64::NAN; n_density_params]);
2490 (Some(unc), temp_k, temp_unc)
2491 }
2492 None => {
2493 let temp_k = if config.fit_temperature {
2494 Some(result.params[n_density_params])
2495 } else {
2496 None
2497 };
2498 (None, temp_k, None)
2499 }
2500 }
2501 } else {
2502 let temp_k = if config.fit_temperature {
2503 Some(result.params[n_density_params])
2504 } else {
2505 None
2506 };
2507 (None, temp_k, None)
2508 };
2509
2510 Ok(SpectrumFitResult {
2511 densities,
2512 uncertainties,
2513 reduced_chi_squared: result.reduced_chi_squared,
2514 converged: result.converged,
2515 iterations: result.iterations,
2516 temperature_k,
2517 temperature_k_unc,
2518 anorm,
2519 background,
2520 back_d,
2521 back_f,
2522 t0_us: None,
2523 l_scale: None,
2524 deviance_per_dof: None,
2525 })
2526}
2527
2528pub struct ModelJacobianResult {
2536 pub jacobian: lm::FlatMatrix,
2538 pub fisher: lm::FlatMatrix,
2540 pub model_prediction: Vec<f64>,
2542 pub param_names: Vec<String>,
2544}
2545
2546pub fn evaluate_jacobian_and_fisher(
2573 config: &UnifiedFitConfig,
2574 flux: &[f64],
2575 background: &[f64],
2576) -> Result<ModelJacobianResult, PipelineError> {
2577 validate_precomputed_cross_sections(config)?;
2586
2587 let n_density_params = config.n_density_params();
2588
2589 let mut param_vec = build_density_params(config);
2596
2597 let temperature_index = if config.fit_temperature {
2598 let idx = param_vec.len();
2599 param_vec.push(FitParameter {
2600 name: "temperature_k".into(),
2601 value: config.temperature_k,
2602 lower: 1.0,
2603 upper: 5000.0,
2604 fixed: false,
2605 });
2606 Some(idx)
2607 } else {
2608 None
2609 };
2610
2611 let kl_bg = if config.transmission_background.is_some() {
2612 let base = param_vec.len();
2613 param_vec.push(FitParameter {
2614 name: "kl_b0".into(),
2615 value: 0.0,
2616 lower: 0.0,
2617 upper: 0.5,
2618 fixed: false,
2619 });
2620 param_vec.push(FitParameter {
2621 name: "kl_b1".into(),
2622 value: 0.0,
2623 lower: 0.0,
2624 upper: 0.5,
2625 fixed: false,
2626 });
2627 Some((base, base + 1))
2628 } else {
2629 None
2630 };
2631
2632 let counts_bg = if let Some(bg) = config.counts_background() {
2633 let alpha1_idx = param_vec.len();
2634 param_vec.push(if bg.fit_alpha_1 {
2635 FitParameter {
2636 name: "alpha_1".into(),
2637 value: bg.alpha_1_init,
2638 lower: 0.0,
2639 upper: 10.0,
2640 fixed: false,
2641 }
2642 } else {
2643 FitParameter::fixed("alpha_1", bg.alpha_1_init)
2644 });
2645 let alpha2_idx = param_vec.len();
2646 param_vec.push(if bg.fit_alpha_2 {
2647 FitParameter {
2648 name: "alpha_2".into(),
2649 value: bg.alpha_2_init,
2650 lower: 0.0,
2651 upper: 10.0,
2652 fixed: false,
2653 }
2654 } else {
2655 FitParameter::fixed("alpha_2", bg.alpha_2_init)
2656 });
2657 Some((alpha1_idx, alpha2_idx))
2658 } else {
2659 None
2660 };
2661
2662 let params = ParameterSet::new(param_vec);
2663 let all_vals = params.all_values();
2664 let free_idx = params.free_indices();
2665 let n_free = free_idx.len();
2666
2667 let param_names: Vec<String> = free_idx
2669 .iter()
2670 .map(|&i| params.params[i].name.to_string())
2671 .collect();
2672
2673 let config_with_xs;
2698 let effective_config =
2699 if config.fit_temperature || config.precomputed_work_cross_sections.is_some() {
2700 config
2701 } else {
2702 let instrument = config
2703 .resolution
2704 .clone()
2705 .map(|r| Arc::new(InstrumentParams { resolution: r }));
2706 let working = nereids_physics::transmission::broadened_cross_sections_on_working_grid(
2707 config.energies(),
2708 &config.resonance_data,
2709 config.temperature_k,
2710 instrument.as_deref(),
2711 None,
2712 )
2713 .map_err(PipelineError::Transmission)?;
2714 config_with_xs = if working.layout.is_identity() {
2715 config
2717 .clone()
2718 .with_precomputed_cross_sections(Arc::new(working.sigma))
2719 } else {
2720 let data_xs: Vec<Vec<f64>> = working
2725 .sigma
2726 .iter()
2727 .map(|s| working.layout.extract(s))
2728 .collect();
2729 config
2730 .clone()
2731 .with_precomputed_cross_sections(Arc::new(data_xs))
2732 .with_precomputed_work_cross_sections(
2733 Arc::new(working.sigma),
2734 Arc::new(working.layout),
2735 )
2736 };
2737 &config_with_xs
2738 };
2739
2740 let t_model = build_transmission_model(effective_config, n_density_params, temperature_index)?;
2742
2743 let evaluate_and_jacobian =
2746 |model: &dyn FitModel| -> Result<(Vec<f64>, lm::FlatMatrix), PipelineError> {
2747 let y_model = model.evaluate(&all_vals)?;
2748 let jac = model
2749 .analytical_jacobian(&all_vals, &free_idx, &y_model)
2750 .ok_or_else(|| {
2751 PipelineError::InvalidParameter(
2752 "analytical Jacobian not available for this model configuration".into(),
2753 )
2754 })?;
2755 Ok((y_model, jac))
2756 };
2757
2758 let (y_model, jac) = if let Some((b0_idx, b1_idx)) = kl_bg {
2759 let inv_sqrt_e: Vec<f64> = config
2760 .energies()
2761 .iter()
2762 .map(|&e| 1.0 / e.max(1e-10).sqrt())
2763 .collect();
2764 let wrapped = poisson::TransmissionKLBackgroundModel {
2765 inner: &*t_model,
2766 inv_sqrt_energies: inv_sqrt_e,
2767 b0_index: b0_idx,
2768 b1_index: b1_idx,
2769 n_params: params.params.len(),
2770 };
2771 if let Some((a1, a2)) = counts_bg {
2772 let cm = poisson::CountsBackgroundScaleModel {
2773 transmission_model: &wrapped,
2774 flux,
2775 background,
2776 alpha1_index: a1,
2777 alpha2_index: a2,
2778 n_params: params.params.len(),
2779 };
2780 evaluate_and_jacobian(&cm)?
2781 } else {
2782 let cm = poisson::CountsModel {
2783 transmission_model: &wrapped,
2784 flux,
2785 background,
2786 n_params: params.params.len(),
2787 };
2788 evaluate_and_jacobian(&cm)?
2789 }
2790 } else if let Some((a1, a2)) = counts_bg {
2791 let cm = poisson::CountsBackgroundScaleModel {
2792 transmission_model: &*t_model,
2793 flux,
2794 background,
2795 alpha1_index: a1,
2796 alpha2_index: a2,
2797 n_params: params.params.len(),
2798 };
2799 evaluate_and_jacobian(&cm)?
2800 } else {
2801 let cm = poisson::CountsModel {
2802 transmission_model: &*t_model,
2803 flux,
2804 background,
2805 n_params: params.params.len(),
2806 };
2807 evaluate_and_jacobian(&cm)?
2808 };
2809
2810 let mut fisher = lm::FlatMatrix::zeros(n_free, n_free);
2812 for (i, &mu_i) in y_model.iter().enumerate() {
2813 let mu_inv = 1.0 / mu_i.max(1e-30);
2814 for a in 0..n_free {
2815 let ja = jac.get(i, a);
2816 for b in 0..=a {
2817 let jb = jac.get(i, b);
2818 *fisher.get_mut(a, b) += ja * jb * mu_inv;
2819 if a != b {
2820 *fisher.get_mut(b, a) += ja * jb * mu_inv;
2821 }
2822 }
2823 }
2824 }
2825
2826 Ok(ModelJacobianResult {
2827 jacobian: jac,
2828 fisher,
2829 model_prediction: y_model,
2830 param_names,
2831 })
2832}
2833
2834#[derive(Debug, PartialEq)]
2838pub enum FitConfigError {
2839 EmptyEnergies,
2841 EmptyResonanceData,
2843 DensityCountMismatch { densities: usize, isotopes: usize },
2845 NameCountMismatch { names: usize, isotopes: usize },
2847 GroupMemberCountMismatch {
2849 group_name: String,
2850 rd_count: usize,
2851 member_count: usize,
2852 },
2853 GroupMemberIsotopeMismatch {
2855 group_name: String,
2856 expected_z: u32,
2857 expected_a: u32,
2858 got_z: u32,
2859 got_a: u32,
2860 },
2861 NonFiniteTemperature(f64),
2863 NegativeTemperature(f64),
2865 InvalidFitEnergyRange(&'static str),
2867}
2868
2869impl fmt::Display for FitConfigError {
2870 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2871 match self {
2872 Self::EmptyEnergies => write!(f, "energy grid must be non-empty"),
2873 Self::EmptyResonanceData => write!(f, "resonance_data must be non-empty"),
2874 Self::DensityCountMismatch {
2875 densities,
2876 isotopes,
2877 } => write!(
2878 f,
2879 "initial_densities length ({densities}) must match number of density parameters ({isotopes})"
2880 ),
2881 Self::NameCountMismatch { names, isotopes } => write!(
2882 f,
2883 "isotope_names length ({names}) must match resonance_data length ({isotopes})"
2884 ),
2885 Self::GroupMemberCountMismatch {
2886 group_name,
2887 rd_count,
2888 member_count,
2889 } => write!(
2890 f,
2891 "group '{group_name}': provided {rd_count} ResonanceData but group has {member_count} members"
2892 ),
2893 Self::GroupMemberIsotopeMismatch {
2894 group_name,
2895 expected_z,
2896 expected_a,
2897 got_z,
2898 got_a,
2899 } => write!(
2900 f,
2901 "group '{group_name}': expected Z={expected_z} A={expected_a} but got Z={got_z} A={got_a}"
2902 ),
2903 Self::NonFiniteTemperature(v) => {
2904 write!(f, "temperature must be finite, got {v}")
2905 }
2906 Self::NegativeTemperature(v) => {
2907 write!(f, "temperature must be non-negative, got {v}")
2908 }
2909 Self::InvalidFitEnergyRange(msg) => {
2910 write!(f, "invalid fit_energy_range: {msg}")
2911 }
2912 }
2913 }
2914}
2915
2916impl std::error::Error for FitConfigError {}
2917
2918#[derive(Debug, Clone)]
2920pub struct SpectrumFitResult {
2921 pub densities: Vec<f64>,
2923 pub uncertainties: Option<Vec<f64>>,
2927 pub reduced_chi_squared: f64,
2929 pub converged: bool,
2931 pub iterations: usize,
2933 pub temperature_k: Option<f64>,
2935 pub temperature_k_unc: Option<f64>,
2937 pub anorm: f64,
2941 pub background: [f64; 3],
2954 pub back_d: Option<f64>,
2962 pub back_f: Option<f64>,
2969 pub t0_us: Option<f64>,
2972 pub l_scale: Option<f64>,
2975 pub deviance_per_dof: Option<f64>,
2984}
2985
2986#[cfg(test)]
2987mod tests {
2988 use super::*;
2989 use nereids_endf::resonance::test_support::{
2990 hf178_mlbw_two_resonances, synthetic_single_resonance, u238_single_resonance,
2991 };
2992 use nereids_fitting::lm::FitModel;
2993 use nereids_physics::transmission as phys_transmission;
2994
2995 #[test]
2998 fn detect_transmission_dips_finds_clear_dips() {
2999 let energies: Vec<f64> = (0..120).map(|i| 1.0 + (i as f64) * 0.5).collect();
3000 let mut t = vec![1.0_f64; 120];
3001 for v in t.iter_mut().take(33).skip(28) {
3003 *v = 0.4;
3004 }
3005 for v in t.iter_mut().take(83).skip(78) {
3006 *v = 0.6;
3007 }
3008 let dips = detect_transmission_dips(&t, &energies);
3009 assert_eq!(dips.len(), 2, "expected 2 dips, got {dips:?}");
3010 let mut de: Vec<f64> = dips.iter().map(|&(e, _)| e).collect();
3011 de.sort_by(f64::total_cmp);
3012 assert!(
3014 (de[0] - energies[30]).abs() <= 1.0,
3015 "first dip at {} eV",
3016 de[0]
3017 );
3018 assert!(
3019 (de[1] - energies[80]).abs() <= 1.0,
3020 "second dip at {} eV",
3021 de[1]
3022 );
3023 }
3024
3025 #[test]
3030 fn peak_match_energy_scale_seed_identity() {
3031 let data = hf178_mlbw_two_resonances(); let energies: Vec<f64> = (0..400).map(|i| 4.0 + (i as f64) * 0.05).collect();
3033 let (t_obs, _sigma) = synthetic_transmission(&data, 0.1, &energies);
3034 let config = UnifiedFitConfig::new(
3035 energies.clone(),
3036 vec![data],
3037 vec!["Hf-178".into()],
3038 293.6,
3039 None,
3040 vec![0.1],
3041 )
3042 .unwrap()
3043 .with_energy_scale(0.0, 1.0, 25.0);
3044 let (t0, l_scale) = peak_match_energy_scale_seed(
3045 &t_obs,
3046 config.energies(),
3047 &config,
3048 25.0,
3049 (-10.0, 10.0),
3050 (0.99, 1.01),
3051 )
3052 .expect("seed should be Some with 2 resonances and detectable dips");
3053 assert!(
3055 t0.abs() < 0.5,
3056 "t0 should be ≈0 for un-shifted data, got {t0}"
3057 );
3058 assert!(
3059 (l_scale - 1.0).abs() < 5e-3,
3060 "L_scale should be ≈1 for un-shifted data, got {l_scale}"
3061 );
3062 }
3063
3064 #[test]
3069 fn peak_match_energy_scale_seed_recovers_nonidentity() {
3070 let data = hf178_mlbw_two_resonances(); let energies: Vec<f64> = (0..900).map(|i| 4.0 + (i as f64) * 0.02).collect();
3074 let density = 0.1_f64;
3075 let flight_path = 25.0_f64;
3076 let (t0_true, l_scale_true) = (2.0_f64, 1.006_f64);
3077 let model = EnergyScaleTransmissionModel::new(
3080 Arc::new(vec![data.clone()]),
3081 Arc::new(vec![0]),
3082 Arc::new(vec![1.0]),
3083 293.6,
3084 energies.clone(),
3085 flight_path,
3086 1, 2, None,
3089 );
3090 let t_obs = model.evaluate(&[density, t0_true, l_scale_true]).unwrap();
3091 let config = UnifiedFitConfig::new(
3092 energies.clone(),
3093 vec![data],
3094 vec!["Hf-178".into()],
3095 293.6,
3096 None,
3097 vec![density],
3098 )
3099 .unwrap()
3100 .with_energy_scale(0.0, 1.0, flight_path);
3101 let (t0, l_scale) = peak_match_energy_scale_seed(
3102 &t_obs,
3103 config.energies(),
3104 &config,
3105 flight_path,
3106 (-10.0, 10.0),
3107 (0.99, 1.01),
3108 )
3109 .expect("seed should be Some for a clean shifted two-resonance spectrum");
3110 assert!(
3114 (t0 - t0_true).abs() < 0.6,
3115 "seed should recover t0 ≈ {t0_true}, got {t0}"
3116 );
3117 assert!(
3118 (l_scale - l_scale_true).abs() < 4e-3,
3119 "seed should recover L_scale ≈ {l_scale_true}, got {l_scale}"
3120 );
3121 assert!(
3123 (t0 - t0_true).abs() < (0.0 - t0_true).abs()
3124 && (l_scale - l_scale_true).abs() < (1.0 - l_scale_true).abs(),
3125 "seed must improve on the cold start"
3126 );
3127 }
3128
3129 #[test]
3134 fn peak_match_energy_scale_seed_none_on_featureless_spectrum() {
3135 let data = hf178_mlbw_two_resonances(); let energies: Vec<f64> = (0..400).map(|i| 4.0 + (i as f64) * 0.05).collect();
3137 let t_obs: Vec<f64> = energies.iter().map(|&e| (-5.0 / e.sqrt()).exp()).collect();
3139 let config = UnifiedFitConfig::new(
3140 energies.clone(),
3141 vec![data],
3142 vec!["Hf-178".into()],
3143 293.6,
3144 None,
3145 vec![0.1],
3146 )
3147 .unwrap()
3148 .with_energy_scale(0.0, 1.0, 25.0);
3149 assert!(
3150 peak_match_energy_scale_seed(
3151 &t_obs,
3152 config.energies(),
3153 &config,
3154 25.0,
3155 (-10.0, 10.0),
3156 (0.99, 1.01),
3157 )
3158 .is_none(),
3159 "a featureless heavily-absorbing spectrum has no resonance dips ⇒ \
3160 seed must return None (cold-start fallback)"
3161 );
3162 }
3163
3164 #[test]
3171 fn peak_match_energy_scale_seed_handles_duplicate_resonance_energies() {
3172 use nereids_physics::transmission::{SampleParams, forward_model};
3173
3174 let iso_a = synthetic_single_resonance(72, 178, 176.0, 7.8);
3176 let iso_b = synthetic_single_resonance(74, 184, 182.0, 7.8); let iso_c = synthetic_single_resonance(40, 90, 89.0, 16.9); let energies: Vec<f64> = (0..900).map(|i| 4.0 + (i as f64) * 0.02).collect();
3179 let density = 0.05_f64;
3180 let sample = SampleParams::new(
3181 293.6,
3182 vec![
3183 (iso_a.clone(), density),
3184 (iso_b.clone(), density),
3185 (iso_c.clone(), density),
3186 ],
3187 )
3188 .unwrap();
3189 let t_obs = forward_model(&energies, &sample, None).unwrap();
3190 let config = UnifiedFitConfig::new(
3191 energies.clone(),
3192 vec![iso_a, iso_b, iso_c],
3193 vec!["A".into(), "B".into(), "C".into()],
3194 293.6,
3195 None,
3196 vec![density, density, density],
3197 )
3198 .unwrap()
3199 .with_energy_scale(0.0, 1.0, 25.0);
3200 let (t0, l_scale) = peak_match_energy_scale_seed(
3201 &t_obs,
3202 config.energies(),
3203 &config,
3204 25.0,
3205 (-10.0, 10.0),
3206 (0.99, 1.01),
3207 )
3208 .expect(
3209 "duplicate resonance energies must not collapse match_tol to 0 — \
3210 seed should be Some (was silently None pre-fix)",
3211 );
3212 assert!(t0.abs() < 0.5, "identity calibration: t0 ≈ 0, got {t0}");
3213 assert!(
3214 (l_scale - 1.0).abs() < 5e-3,
3215 "identity calibration: L_scale ≈ 1, got {l_scale}"
3216 );
3217 }
3218
3219 #[test]
3222 fn test_input_data_transmission_n_energies() {
3223 let data = InputData::Transmission {
3224 transmission: vec![0.9, 0.8, 0.7],
3225 uncertainty: vec![0.01, 0.01, 0.01],
3226 };
3227 assert_eq!(data.n_energies(), 3);
3228 assert!(!data.is_counts());
3229 }
3230
3231 #[test]
3232 fn test_input_data_counts_n_energies() {
3233 let data = InputData::Counts {
3234 sample_counts: vec![10.0, 20.0, 30.0, 40.0],
3235 open_beam_counts: vec![100.0, 100.0, 100.0, 100.0],
3236 };
3237 assert_eq!(data.n_energies(), 4);
3238 assert!(data.is_counts());
3239 }
3240
3241 #[test]
3242 fn test_input_data_counts_with_nuisance() {
3243 let data = InputData::CountsWithNuisance {
3244 sample_counts: vec![5.0, 6.0],
3245 flux: vec![100.0, 100.0],
3246 background: vec![0.5, 0.5],
3247 };
3248 assert_eq!(data.n_energies(), 2);
3249 assert!(data.is_counts());
3250 }
3251
3252 #[test]
3253 fn test_solver_config_default_is_auto() {
3254 let cfg = SolverConfig::default();
3255 assert!(matches!(cfg, SolverConfig::Auto));
3256 }
3257
3258 #[test]
3259 fn test_counts_background_config_default() {
3260 let cfg = CountsBackgroundConfig::default();
3261 assert!((cfg.alpha_1_init - 1.0).abs() < f64::EPSILON);
3262 assert!((cfg.alpha_2_init - 1.0).abs() < f64::EPSILON);
3263 assert!(!cfg.fit_alpha_1);
3264 assert!(!cfg.fit_alpha_2);
3265 }
3266
3267 fn synthetic_transmission(
3271 data: &ResonanceData,
3272 true_density: f64,
3273 energies: &[f64],
3274 ) -> (Vec<f64>, Vec<f64>) {
3275 let model = PrecomputedTransmissionModel {
3276 cross_sections: Arc::new(vec![
3277 phys_transmission::broadened_cross_sections(
3278 energies,
3279 std::slice::from_ref(data),
3280 0.0,
3281 None,
3282 None,
3283 )
3284 .unwrap()
3285 .into_iter()
3286 .next()
3287 .unwrap(),
3288 ]),
3289 density_indices: Arc::new(vec![0]),
3290 energies: None,
3291 instrument: None,
3292 resolution_plan: None,
3293 sparse_cubature_plan: None,
3294 sparse_scalar_plan: None,
3295 work_layout: None,
3296 };
3297 let t = model.evaluate(&[true_density]).unwrap();
3298 let sigma: Vec<f64> = t.iter().map(|&v| 0.01 * v.max(0.01)).collect();
3299 (t, sigma)
3300 }
3301
3302 fn synthetic_counts(
3304 data: &ResonanceData,
3305 true_density: f64,
3306 energies: &[f64],
3307 i0: f64,
3308 ) -> (Vec<f64>, Vec<f64>) {
3309 let (t, _) = synthetic_transmission(data, true_density, energies);
3310 let open_beam: Vec<f64> = vec![i0; energies.len()];
3311 let sample: Vec<f64> = t.iter().map(|&v| (v * i0).round().max(0.0)).collect();
3312 (sample, open_beam)
3313 }
3314
3315 #[test]
3316 fn test_typed_transmission_lm_recovers_density() {
3317 let data = u238_single_resonance();
3318 let true_density = 0.002;
3319 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
3320 let (t, sigma) = synthetic_transmission(&data, true_density, &energies);
3321
3322 let config = UnifiedFitConfig::new(
3323 energies,
3324 vec![data],
3325 vec!["U-238".into()],
3326 0.0,
3327 None,
3328 vec![0.001],
3329 )
3330 .unwrap()
3331 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
3332
3333 let input = InputData::Transmission {
3334 transmission: t,
3335 uncertainty: sigma,
3336 };
3337
3338 let result = fit_spectrum_typed(&input, &config).unwrap();
3339 assert!(result.converged, "LM should converge");
3340 let fitted = result.densities[0];
3341 assert!(
3342 (fitted - true_density).abs() / true_density < 0.05,
3343 "density: fitted={fitted}, true={true_density}"
3344 );
3345 }
3346
3347 fn precomputed_xs_fixture() -> (UnifiedFitConfig, InputData) {
3350 let data = u238_single_resonance();
3351 let energies: Vec<f64> = (0..11).map(|i| 1.0 + (i as f64) * 0.1).collect();
3352 let (t, sigma) = synthetic_transmission(&data, 0.001, &energies);
3353 let config = UnifiedFitConfig::new(
3354 energies,
3355 vec![data],
3356 vec!["U-238".into()],
3357 0.0,
3358 None,
3359 vec![0.001],
3360 )
3361 .unwrap()
3362 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
3363 let input = InputData::Transmission {
3364 transmission: t,
3365 uncertainty: sigma,
3366 };
3367 (config, input)
3368 }
3369
3370 #[test]
3371 fn test_fit_rejects_empty_precomputed_cross_sections() {
3372 let (config, input) = precomputed_xs_fixture();
3375 let config = config.with_precomputed_cross_sections(Arc::new(Vec::new()));
3376 let err = fit_spectrum_typed(&input, &config).unwrap_err();
3377 assert!(
3378 matches!(err, PipelineError::ShapeMismatch(_)),
3379 "expected ShapeMismatch, got {err:?}"
3380 );
3381 assert!(err.to_string().contains("must not be empty"));
3382 }
3383
3384 #[test]
3385 fn test_fit_rejects_wrong_row_count_precomputed_cross_sections() {
3386 let (config, input) = precomputed_xs_fixture();
3388 let n_e = config.energies().len();
3389 let bad = vec![vec![1.0; n_e], vec![1.0; n_e]];
3390 let config = config.with_precomputed_cross_sections(Arc::new(bad));
3391 let err = fit_spectrum_typed(&input, &config).unwrap_err();
3392 assert!(
3393 matches!(err, PipelineError::ShapeMismatch(_)),
3394 "expected ShapeMismatch, got {err:?}"
3395 );
3396 assert!(err.to_string().contains("rows"));
3397 }
3398
3399 #[test]
3400 fn test_fit_rejects_wrong_energy_length_precomputed_cross_sections() {
3401 let (config, input) = precomputed_xs_fixture();
3403 let n_e = config.energies().len();
3404 let bad = vec![vec![1.0; n_e + 3]];
3405 let config = config.with_precomputed_cross_sections(Arc::new(bad));
3406 let err = fit_spectrum_typed(&input, &config).unwrap_err();
3407 assert!(
3408 matches!(err, PipelineError::ShapeMismatch(_)),
3409 "expected ShapeMismatch, got {err:?}"
3410 );
3411 assert!(err.to_string().contains("config.energies"));
3412 }
3413
3414 #[test]
3415 fn test_fit_accepts_correct_precomputed_cross_sections() {
3416 let (config, input) = precomputed_xs_fixture();
3419 let n_e = config.energies().len();
3420 let xs = phys_transmission::broadened_cross_sections(
3421 config.energies(),
3422 config.resonance_data(),
3423 0.0,
3424 None,
3425 None,
3426 )
3427 .unwrap();
3428 assert_eq!(xs.len(), 1);
3429 assert_eq!(xs[0].len(), n_e);
3430 let config = config.with_precomputed_cross_sections(Arc::new(xs));
3431 let result = fit_spectrum_typed(&input, &config);
3434 assert!(
3435 result.is_ok(),
3436 "correctly-shaped precomputed XS must pass validation, got {result:?}"
3437 );
3438 }
3439
3440 #[test]
3441 fn test_fit_rejects_non_finite_precomputed_cross_sections() {
3442 let (config, input) = precomputed_xs_fixture();
3448 let n_e = config.energies().len();
3449 let bad = vec![vec![f64::NAN; n_e]];
3450 let config = config.with_precomputed_cross_sections(Arc::new(bad));
3451 let err = fit_spectrum_typed(&input, &config).unwrap_err();
3452 assert!(
3453 matches!(err, PipelineError::ShapeMismatch(_)),
3454 "expected ShapeMismatch, got {err:?}"
3455 );
3456 assert!(
3457 err.to_string().contains("non-finite"),
3458 "error should mention non-finite σ, got: {err}"
3459 );
3460
3461 let (config, input) = precomputed_xs_fixture();
3463 let mut row = vec![1.0; n_e];
3464 row[n_e / 2] = f64::INFINITY;
3465 let config = config.with_precomputed_cross_sections(Arc::new(vec![row]));
3466 let err = fit_spectrum_typed(&input, &config).unwrap_err();
3467 assert!(
3468 matches!(err, PipelineError::ShapeMismatch(_)),
3469 "expected ShapeMismatch for +inf σ, got {err:?}"
3470 );
3471 }
3472
3473 #[test]
3474 fn test_evaluate_jacobian_rejects_malformed_precomputed_cross_sections() {
3475 let (config, _input) = precomputed_xs_fixture();
3480 let n_e = config.energies().len();
3481 let flux = vec![1.0; n_e];
3482 let background = vec![0.0; n_e];
3483
3484 let empty = config
3487 .clone()
3488 .with_precomputed_cross_sections(Arc::new(Vec::new()));
3489 match evaluate_jacobian_and_fisher(&empty, &flux, &background) {
3490 Err(PipelineError::ShapeMismatch(msg)) => {
3491 assert!(msg.contains("must not be empty"), "got: {msg}");
3492 }
3493 Err(other) => panic!("expected ShapeMismatch for empty XS, got {other:?}"),
3494 Ok(_) => panic!("empty precomputed XS must be rejected"),
3495 }
3496
3497 let nan = config.with_precomputed_cross_sections(Arc::new(vec![vec![f64::NAN; n_e]]));
3499 match evaluate_jacobian_and_fisher(&nan, &flux, &background) {
3500 Err(PipelineError::ShapeMismatch(msg)) => {
3501 assert!(msg.contains("non-finite"), "got: {msg}");
3502 }
3503 Err(other) => panic!("expected ShapeMismatch for NaN σ, got {other:?}"),
3504 Ok(_) => panic!("non-finite precomputed σ must be rejected"),
3505 }
3506 }
3507
3508 #[test]
3509 fn test_extract_result_drops_uncertainties_when_unconverged() {
3510 let data = u238_single_resonance();
3511 let energies: Vec<f64> = (0..21).map(|i| 1.0 + (i as f64) * 0.1).collect();
3512 let config = UnifiedFitConfig::new(
3513 energies,
3514 vec![data],
3515 vec!["U-238".into()],
3516 293.6,
3517 None,
3518 vec![0.001],
3519 )
3520 .unwrap();
3521
3522 let result = LmResult {
3523 chi_squared: 1.0,
3524 reduced_chi_squared: 1.0,
3525 iterations: 5,
3526 converged: false,
3527 params: vec![0.001],
3528 covariance: Some(lm::FlatMatrix::zeros(1, 1)),
3529 uncertainties: Some(vec![0.123]),
3530 };
3531
3532 let extracted = extract_result(&config, &result, 1, None).unwrap();
3533 assert!(!extracted.converged);
3534 assert!(
3535 extracted.uncertainties.is_none(),
3536 "pipeline must not surface uncertainties from an unconverged fit"
3537 );
3538 assert!(extracted.temperature_k_unc.is_none());
3539 }
3540
3541 #[test]
3542 fn test_typed_counts_kl_recovers_density() {
3543 let data = u238_single_resonance();
3544 let true_density = 0.002;
3545 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
3546 let (sample, open_beam) = synthetic_counts(&data, true_density, &energies, 1000.0);
3547
3548 let config = UnifiedFitConfig::new(
3549 energies,
3550 vec![data],
3551 vec!["U-238".into()],
3552 0.0,
3553 None,
3554 vec![0.001],
3555 )
3556 .unwrap()
3557 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
3558
3559 let input = InputData::Counts {
3560 sample_counts: sample,
3561 open_beam_counts: open_beam,
3562 };
3563
3564 let result = fit_spectrum_typed(&input, &config).unwrap();
3565 assert!(result.converged, "KL on counts should converge");
3566 let fitted = result.densities[0];
3567 assert!(
3568 (fitted - true_density).abs() / true_density < 0.10,
3569 "density: fitted={fitted}, true={true_density}"
3570 );
3571 }
3572
3573 #[test]
3574 fn test_typed_counts_kl_low_counts_recovers_density() {
3575 let data = u238_single_resonance();
3577 let true_density = 0.0005;
3578 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
3579 let (sample, open_beam) = synthetic_counts(&data, true_density, &energies, 10.0);
3580
3581 let config = UnifiedFitConfig::new(
3582 energies,
3583 vec![data],
3584 vec!["U-238".into()],
3585 0.0,
3586 None,
3587 vec![0.001],
3588 )
3589 .unwrap()
3590 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
3591
3592 let input = InputData::Counts {
3593 sample_counts: sample,
3594 open_beam_counts: open_beam,
3595 };
3596
3597 let result = fit_spectrum_typed(&input, &config).unwrap();
3598 assert!(result.converged, "KL on low counts should converge");
3599 let fitted = result.densities[0];
3600 assert!(
3602 (fitted - true_density).abs() / true_density < 0.30,
3603 "density: fitted={fitted}, true={true_density}"
3604 );
3605 }
3606
3607 #[test]
3608 fn test_typed_transmission_kl_recovers_density() {
3609 let data = u238_single_resonance();
3610 let true_density = 0.0005;
3611 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
3612 let (t, sigma) = synthetic_transmission(&data, true_density, &energies);
3613
3614 let config = UnifiedFitConfig::new(
3615 energies,
3616 vec![data],
3617 vec!["U-238".into()],
3618 0.0,
3619 None,
3620 vec![0.001],
3621 )
3622 .unwrap()
3623 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
3624
3625 let input = InputData::Transmission {
3626 transmission: t,
3627 uncertainty: sigma,
3628 };
3629
3630 let result = fit_spectrum_typed(&input, &config).unwrap();
3631 assert!(result.converged, "KL on transmission should converge");
3632 let fitted = result.densities[0];
3633 assert!(
3634 (fitted - true_density).abs() / true_density < 0.05,
3635 "density: fitted={fitted}, true={true_density}"
3636 );
3637 }
3638
3639 #[test]
3640 fn test_typed_counts_lm_auto_converts() {
3641 let data = u238_single_resonance();
3643 let true_density = 0.0005;
3644 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
3645 let (sample, open_beam) = synthetic_counts(&data, true_density, &energies, 1000.0);
3646
3647 let config = UnifiedFitConfig::new(
3648 energies,
3649 vec![data],
3650 vec!["U-238".into()],
3651 0.0,
3652 None,
3653 vec![0.001],
3654 )
3655 .unwrap()
3656 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
3657
3658 let input = InputData::Counts {
3659 sample_counts: sample,
3660 open_beam_counts: open_beam,
3661 };
3662
3663 let result = fit_spectrum_typed(&input, &config).unwrap();
3664 assert!(
3665 result.converged,
3666 "LM on auto-converted counts should converge"
3667 );
3668 let fitted = result.densities[0];
3669 assert!(
3670 (fitted - true_density).abs() / true_density < 0.10,
3671 "density: fitted={fitted}, true={true_density}"
3672 );
3673 }
3674
3675 #[test]
3676 fn test_typed_auto_solver_selects_kl_for_counts() {
3677 let data = u238_single_resonance();
3678 let true_density = 0.0005;
3679 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
3680 let (sample, open_beam) = synthetic_counts(&data, true_density, &energies, 1000.0);
3681
3682 let config = UnifiedFitConfig::new(
3684 energies,
3685 vec![data],
3686 vec!["U-238".into()],
3687 0.0,
3688 None,
3689 vec![0.001],
3690 )
3691 .unwrap(); let input = InputData::Counts {
3694 sample_counts: sample,
3695 open_beam_counts: open_beam,
3696 };
3697
3698 let result = fit_spectrum_typed(&input, &config).unwrap();
3699 assert!(
3700 result.converged,
3701 "Auto solver on counts should use KL and converge"
3702 );
3703 }
3704
3705 #[test]
3706 fn test_typed_transmission_with_background_lm() {
3707 let data = u238_single_resonance();
3708 let true_density = 0.0005;
3709 let true_anorm = 0.95;
3710 let true_back_a = 0.02;
3711 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
3712
3713 let (t_pure, _) = synthetic_transmission(&data, true_density, &energies);
3715 let t_bg: Vec<f64> = t_pure
3716 .iter()
3717 .map(|&v| true_anorm * v + true_back_a)
3718 .collect();
3719 let sigma: Vec<f64> = t_bg.iter().map(|&v| 0.01 * v.max(0.01)).collect();
3720
3721 let config = UnifiedFitConfig::new(
3722 energies,
3723 vec![data],
3724 vec!["U-238".into()],
3725 0.0,
3726 None,
3727 vec![0.001],
3728 )
3729 .unwrap()
3730 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig {
3731 max_iter: 500,
3732 ..LmConfig::default()
3733 }))
3734 .with_transmission_background(BackgroundConfig::default());
3735
3736 let input = InputData::Transmission {
3737 transmission: t_bg,
3738 uncertainty: sigma,
3739 };
3740
3741 let result = fit_spectrum_typed(&input, &config).unwrap();
3742 assert!(
3743 result.converged,
3744 "LM+BG should converge on noiseless synthetic data (chi2r={}, iter={})",
3745 result.reduced_chi_squared, result.iterations
3746 );
3747 assert!(
3748 (result.densities[0] - true_density).abs() / true_density < 0.05,
3749 "density: fitted={}, true={true_density}",
3750 result.densities[0]
3751 );
3752 assert!(
3753 (result.anorm - true_anorm).abs() / true_anorm < 0.05,
3754 "anorm: fitted={}, true={true_anorm}",
3755 result.anorm
3756 );
3757 }
3758
3759 #[test]
3760 fn test_typed_counts_with_nuisance_rejects_lm() {
3761 let data = u238_single_resonance();
3762 let energies: Vec<f64> = (0..10).map(|i| 1.0 + (i as f64) * 0.5).collect();
3763
3764 let config = UnifiedFitConfig::new(
3765 energies,
3766 vec![data],
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
3775 let input = InputData::CountsWithNuisance {
3776 sample_counts: vec![10.0; 10],
3777 flux: vec![100.0; 10],
3778 background: vec![0.0; 10],
3779 };
3780
3781 let result = fit_spectrum_typed(&input, &config);
3782 assert!(result.is_err(), "CountsWithNuisance + LM should error");
3783 }
3784
3785 fn synthetic_transmission_at_temp(
3787 data: &ResonanceData,
3788 true_density: f64,
3789 temperature_k: f64,
3790 energies: &[f64],
3791 ) -> (Vec<f64>, Vec<f64>) {
3792 let xs = phys_transmission::broadened_cross_sections(
3793 energies,
3794 std::slice::from_ref(data),
3795 temperature_k,
3796 None,
3797 None,
3798 )
3799 .unwrap();
3800 let model = PrecomputedTransmissionModel {
3801 cross_sections: Arc::new(xs),
3802 density_indices: Arc::new(vec![0]),
3803 energies: None,
3804 instrument: None,
3805 resolution_plan: None,
3806 sparse_cubature_plan: None,
3807 sparse_scalar_plan: None,
3808 work_layout: None,
3809 };
3810 let t = model.evaluate(&[true_density]).unwrap();
3811 let sigma: Vec<f64> = t.iter().map(|&v| 0.01 * v.max(0.01)).collect();
3812 (t, sigma)
3813 }
3814
3815 #[test]
3816 fn test_typed_poisson_kl_with_temperature() {
3817 let data = u238_single_resonance();
3818 let true_density = 0.0005;
3819 let true_temp = 350.0;
3820 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
3821 let (t, sigma) = synthetic_transmission_at_temp(&data, true_density, true_temp, &energies);
3822
3823 let config = UnifiedFitConfig::new(
3824 energies,
3825 vec![data],
3826 vec!["U-238".into()],
3827 300.0, None,
3829 vec![0.001],
3830 )
3831 .unwrap()
3832 .with_solver(SolverConfig::PoissonKL(PoissonConfig {
3833 max_iter: 500,
3834 ..PoissonConfig::default()
3835 }))
3836 .with_fit_temperature(true);
3837
3838 let input = InputData::Transmission {
3839 transmission: t,
3840 uncertainty: sigma,
3841 };
3842
3843 let result = fit_spectrum_typed(&input, &config).unwrap();
3844
3845 let fitted_density = result.densities[0];
3847 assert!(
3848 (fitted_density - true_density).abs() / true_density < 0.01,
3849 "density: fitted={fitted_density}, true={true_density}, ratio={}",
3850 (fitted_density - true_density).abs() / true_density,
3851 );
3852
3853 let fitted_temp = result
3855 .temperature_k
3856 .expect("temperature_k should be Some when fit_temperature=true");
3857 assert!(
3858 (fitted_temp - true_temp).abs() < 1.0,
3859 "temperature: fitted={fitted_temp}, true={true_temp}, delta={}",
3860 (fitted_temp - true_temp).abs(),
3861 );
3862 }
3863
3864 #[test]
3865 fn test_typed_poisson_kl_with_temperature_and_background() {
3866 let data = u238_single_resonance();
3867 let true_density = 0.0005;
3868 let true_temp = 350.0;
3869 let true_b0 = 0.012;
3870 let true_b1 = 0.008;
3871 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
3872 let (t, sigma) = synthetic_transmission_at_temp(&data, true_density, true_temp, &energies);
3873 let measured_t: Vec<f64> = t
3874 .iter()
3875 .zip(energies.iter())
3876 .map(|(&ti, &e)| ti + true_b0 + true_b1 / e.sqrt())
3877 .collect();
3878
3879 let config = UnifiedFitConfig::new(
3880 energies,
3881 vec![data],
3882 vec!["U-238".into()],
3883 300.0,
3884 None,
3885 vec![0.001],
3886 )
3887 .unwrap()
3888 .with_solver(SolverConfig::PoissonKL(PoissonConfig {
3889 max_iter: 120,
3890 gauss_newton_lambda: 1e-4,
3891 ..PoissonConfig::default()
3892 }))
3893 .with_fit_temperature(true)
3894 .with_transmission_background(BackgroundConfig::default());
3895
3896 let input = InputData::Transmission {
3897 transmission: measured_t,
3898 uncertainty: sigma,
3899 };
3900
3901 let result = fit_spectrum_typed(&input, &config).unwrap();
3902
3903 assert!(result.converged, "fit did not converge: {result:?}");
3904 assert!(
3905 result.iterations <= 80,
3906 "expected KL background+temperature fit to converge well before max_iter; got {}",
3907 result.iterations,
3908 );
3909
3910 let fitted_density = result.densities[0];
3911 assert!(
3912 (fitted_density - true_density).abs() / true_density < 0.02,
3913 "density: fitted={fitted_density}, true={true_density}, ratio={}",
3914 (fitted_density - true_density).abs() / true_density,
3915 );
3916
3917 let fitted_temp = result
3918 .temperature_k
3919 .expect("temperature_k should be Some when fit_temperature=true");
3920 assert!(
3921 (fitted_temp - true_temp).abs() < 3.0,
3922 "temperature: fitted={fitted_temp}, true={true_temp}, delta={}",
3923 (fitted_temp - true_temp).abs(),
3924 );
3925
3926 assert!(
3927 (result.background[0] - true_b0).abs() < 5e-3,
3928 "background b0: fitted={}, true={}",
3929 result.background[0],
3930 true_b0,
3931 );
3932 assert!(
3933 (result.background[1] - true_b1).abs() < 5e-3,
3934 "background b1: fitted={}, true={}",
3935 result.background[1],
3936 true_b1,
3937 );
3938 }
3939
3940 #[test]
3944 fn test_grouped_fit_spectrum_round_trip() {
3945 use nereids_core::types::IsotopeGroup;
3946
3947 let rd1 = synthetic_single_resonance(92, 235, 233.025, 5.0);
3949 let rd2 = synthetic_single_resonance(92, 238, 236.006, 7.0);
3950
3951 let iso1 = nereids_core::types::Isotope::new(92, 235).unwrap();
3953 let iso2 = nereids_core::types::Isotope::new(92, 238).unwrap();
3954 let group =
3955 IsotopeGroup::custom("U (60/40)".into(), vec![(iso1, 0.6), (iso2, 0.4)]).unwrap();
3956
3957 let energies: Vec<f64> = (0..301).map(|i| 1.0 + (i as f64) * 0.05).collect();
3958 let true_density = 0.0005;
3959
3960 let sample = nereids_physics::transmission::SampleParams::new(
3962 0.0,
3963 vec![
3964 (rd1.clone(), true_density * 0.6),
3965 (rd2.clone(), true_density * 0.4),
3966 ],
3967 )
3968 .unwrap();
3969 let transmission =
3970 nereids_physics::transmission::forward_model(&energies, &sample, None).unwrap();
3971 let uncertainty: Vec<f64> = transmission.iter().map(|&t| 0.01 * t.max(0.01)).collect();
3972
3973 let config = UnifiedFitConfig::new(
3975 energies.clone(),
3976 vec![rd1.clone()],
3977 vec!["placeholder".into()],
3978 0.0,
3979 None,
3980 vec![0.001],
3981 )
3982 .unwrap()
3983 .with_groups(&[(&group, &[rd1, rd2])], vec![0.001])
3984 .unwrap()
3985 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
3986
3987 let input = InputData::Transmission {
3988 transmission,
3989 uncertainty,
3990 };
3991
3992 let result = fit_spectrum_typed(&input, &config).unwrap();
3993
3994 assert_eq!(result.densities.len(), 1, "should have 1 group density");
3996 let fitted = result.densities[0];
3997 let rel_error = (fitted - true_density).abs() / true_density;
3998 assert!(
3999 rel_error < 0.01,
4000 "group density: fitted={fitted}, true={true_density}, rel_error={rel_error}"
4001 );
4002 assert!(result.converged, "fit should converge");
4003 }
4004
4005 #[test]
4006 fn test_grouped_poisson_kl_with_temperature_and_background_noiseless() {
4007 use nereids_core::types::IsotopeGroup;
4008
4009 let rd1 = synthetic_single_resonance(72, 176, 8.5, 5.0);
4010 let rd2 = synthetic_single_resonance(72, 178, 17.0, 7.5);
4011 let rd3 = synthetic_single_resonance(72, 180, 29.0, 6.0);
4012
4013 let hf176 = nereids_core::types::Isotope::new(72, 176).unwrap();
4014 let hf178 = nereids_core::types::Isotope::new(72, 178).unwrap();
4015 let hf180 = nereids_core::types::Isotope::new(72, 180).unwrap();
4016 let group = IsotopeGroup::custom(
4017 "Hf-like (3 member)".into(),
4018 vec![(hf176, 0.2), (hf178, 0.5), (hf180, 0.3)],
4019 )
4020 .unwrap();
4021
4022 let energies: Vec<f64> = (0..300).map(|i| 1.0 + (49.0 * i as f64) / 299.0).collect();
4023 let true_density = 0.001;
4024 let true_temp = 400.0;
4025 let true_b0 = 0.012;
4026 let true_b1 = 0.008;
4027
4028 let sample = nereids_physics::transmission::SampleParams::new(
4029 true_temp,
4030 vec![
4031 (rd1.clone(), true_density * 0.2),
4032 (rd2.clone(), true_density * 0.5),
4033 (rd3.clone(), true_density * 0.3),
4034 ],
4035 )
4036 .unwrap();
4037 let pure_t =
4038 nereids_physics::transmission::forward_model(&energies, &sample, None).unwrap();
4039 let measured_t: Vec<f64> = pure_t
4040 .iter()
4041 .zip(energies.iter())
4042 .map(|(&t, &e)| t + true_b0 + true_b1 / e.sqrt())
4043 .collect();
4044 let sigma = vec![0.001; energies.len()];
4045
4046 let config = UnifiedFitConfig::new(
4047 energies.clone(),
4048 vec![rd1.clone()],
4049 vec!["placeholder".into()],
4050 293.6,
4051 None,
4052 vec![0.0008],
4053 )
4054 .unwrap()
4055 .with_groups(&[(&group, &[rd1, rd2, rd3])], vec![0.0008])
4056 .unwrap()
4057 .with_solver(SolverConfig::PoissonKL(PoissonConfig {
4058 max_iter: 200,
4059 gauss_newton_lambda: 1e-4,
4060 ..PoissonConfig::default()
4061 }))
4062 .with_fit_temperature(true)
4063 .with_transmission_background(BackgroundConfig::default());
4064
4065 let input = InputData::Transmission {
4066 transmission: measured_t,
4067 uncertainty: sigma,
4068 };
4069
4070 let result = fit_spectrum_typed(&input, &config).unwrap();
4071
4072 assert!(result.converged, "fit did not converge: {result:?}");
4073 assert!(
4076 (result.densities[0] - true_density).abs() / true_density < 0.01,
4077 "density: fitted={}, true={true_density}",
4078 result.densities[0]
4079 );
4080 let fitted_temp = result
4081 .temperature_k
4082 .expect("temperature_k should be Some when fit_temperature=true");
4083 assert!(
4084 (fitted_temp - true_temp).abs() < 8.0,
4085 "temperature: fitted={fitted_temp}, true={true_temp}",
4086 );
4087 let e_mid: f64 = 10.0;
4091 let bg_total = (result.anorm - 1.0)
4092 + result.background[0]
4093 + result.background[1] / e_mid.sqrt()
4094 + result.background[2] * e_mid.sqrt();
4095 let true_bg_mid = true_b0 + true_b1 / e_mid.sqrt();
4096 assert!(
4097 (bg_total - true_bg_mid).abs() < 0.02,
4098 "total bg at E={e_mid}: fitted={bg_total:.6}, true={true_bg_mid:.6}",
4099 );
4100 }
4101
4102 #[test]
4105 fn test_kl_counts_returns_density_uncertainty() {
4106 let data = u238_single_resonance();
4107 let true_density = 0.002;
4108 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
4109 let (sample, open_beam) = synthetic_counts(&data, true_density, &energies, 1000.0);
4110
4111 let config = UnifiedFitConfig::new(
4112 energies,
4113 vec![data],
4114 vec!["U-238".into()],
4115 0.0,
4116 None,
4117 vec![0.001],
4118 )
4119 .unwrap()
4120 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
4121
4122 let input = InputData::Counts {
4123 sample_counts: sample,
4124 open_beam_counts: open_beam,
4125 };
4126 let result = fit_spectrum_typed(&input, &config).unwrap();
4127 assert!(result.converged);
4128 let unc = result
4129 .uncertainties
4130 .as_ref()
4131 .expect("KL 1D fit should return density uncertainties");
4132 assert_eq!(unc.len(), 1);
4133 assert!(
4134 unc[0].is_finite() && unc[0] > 0.0,
4135 "density unc = {}",
4136 unc[0]
4137 );
4138 assert!(
4139 unc[0] < result.densities[0],
4140 "unc ({}) should be < density ({}) for high-count data",
4141 unc[0],
4142 result.densities[0]
4143 );
4144 }
4145
4146 #[test]
4147 fn test_kl_counts_returns_temperature_uncertainty() {
4148 let data = u238_single_resonance();
4149 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.05).collect();
4150 let (sample, open_beam) = synthetic_counts(&data, 0.001, &energies, 1000.0);
4151
4152 let config = UnifiedFitConfig::new(
4153 energies,
4154 vec![data],
4155 vec!["U-238".into()],
4156 350.0,
4157 None,
4158 vec![0.0005],
4159 )
4160 .unwrap()
4161 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4162 .with_fit_temperature(true);
4163
4164 let input = InputData::Counts {
4165 sample_counts: sample,
4166 open_beam_counts: open_beam,
4167 };
4168 let result = fit_spectrum_typed(&input, &config).unwrap();
4169 assert!(result.converged);
4170 let unc = result
4171 .uncertainties
4172 .as_ref()
4173 .expect("KL+temp fit should return density uncertainties");
4174 assert!(
4175 unc[0].is_finite() && unc[0] > 0.0,
4176 "density unc = {}",
4177 unc[0]
4178 );
4179 let t_unc = result
4180 .temperature_k_unc
4181 .expect("KL+temp fit should return temperature uncertainty");
4182 assert!(
4183 t_unc.is_finite() && t_unc > 0.0,
4184 "temperature unc = {t_unc}"
4185 );
4186 }
4187
4188 #[test]
4189 fn test_kl_counts_with_background_returns_uncertainty() {
4190 let data = u238_single_resonance();
4191 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.05).collect();
4192 let (sample, open_beam) = synthetic_counts(&data, 0.001, &energies, 1000.0);
4193
4194 let config = UnifiedFitConfig::new(
4195 energies,
4196 vec![data],
4197 vec!["U-238".into()],
4198 300.0,
4199 None,
4200 vec![0.0005],
4201 )
4202 .unwrap()
4203 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4204 .with_fit_temperature(true)
4205 .with_transmission_background(BackgroundConfig::default());
4206
4207 let input = InputData::Counts {
4208 sample_counts: sample,
4209 open_beam_counts: open_beam,
4210 };
4211 let result = fit_spectrum_typed(&input, &config).unwrap();
4212 assert!(result.converged);
4213 let unc = result
4214 .uncertainties
4215 .as_ref()
4216 .expect("KL+bg fit should return density uncertainties");
4217 assert!(
4218 unc[0].is_finite() && unc[0] > 0.0,
4219 "density unc = {}",
4220 unc[0]
4221 );
4222 }
4223
4224 #[test]
4225 fn test_lm_uncertainty_not_regressed() {
4226 let data = u238_single_resonance();
4227 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
4228 let (t, sigma) = synthetic_transmission(&data, 0.001, &energies);
4229
4230 let config = UnifiedFitConfig::new(
4231 energies,
4232 vec![data],
4233 vec!["U-238".into()],
4234 0.0,
4235 None,
4236 vec![0.0005],
4237 )
4238 .unwrap()
4239 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
4240
4241 let input = InputData::Transmission {
4242 transmission: t,
4243 uncertainty: sigma,
4244 };
4245 let result = fit_spectrum_typed(&input, &config).unwrap();
4246 assert!(result.converged);
4247 let unc = result
4248 .uncertainties
4249 .as_ref()
4250 .expect("LM should still return uncertainties");
4251 assert!(unc[0].is_finite() && unc[0] > 0.0);
4252 }
4253
4254 #[test]
4258 fn test_energy_scale_rejects_temperature() {
4259 let data = u238_single_resonance();
4260 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
4261 let (t_obs, sigma) = synthetic_transmission(&data, 0.002, &energies);
4262
4263 let config = UnifiedFitConfig::new(
4264 energies,
4265 vec![data],
4266 vec!["U-238".into()],
4267 293.6,
4268 None,
4269 vec![0.001],
4270 )
4271 .unwrap()
4272 .with_solver(SolverConfig::LevenbergMarquardt(Default::default()))
4273 .with_fit_temperature(true)
4274 .with_energy_scale(0.0, 1.0, 25.0);
4275
4276 let input = InputData::Transmission {
4277 transmission: t_obs,
4278 uncertainty: sigma,
4279 };
4280 let err = fit_spectrum_typed(&input, &config);
4281 assert!(err.is_err(), "energy_scale + temperature should fail");
4282 let msg = err.unwrap_err().to_string();
4283 assert!(
4284 msg.contains("fit_energy_scale") && msg.contains("fit_temperature"),
4285 "error should mention both flags: {msg}"
4286 );
4287 }
4288
4289 #[test]
4291 fn test_energy_scale_returns_fitted_params() {
4292 let data = u238_single_resonance();
4293 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
4294 let (t_obs, sigma) = synthetic_transmission(&data, 0.002, &energies);
4295
4296 let config = UnifiedFitConfig::new(
4297 energies,
4298 vec![data],
4299 vec!["U-238".into()],
4300 293.6,
4301 None,
4302 vec![0.001],
4303 )
4304 .unwrap()
4305 .with_solver(SolverConfig::LevenbergMarquardt(Default::default()))
4306 .with_transmission_background(BackgroundConfig::default())
4307 .with_energy_scale(0.0, 1.0, 25.0);
4308
4309 let input = InputData::Transmission {
4310 transmission: t_obs,
4311 uncertainty: sigma,
4312 };
4313 let result = fit_spectrum_typed(&input, &config).unwrap();
4314 assert!(result.converged, "Fit should converge");
4315 assert!(
4316 result.t0_us.is_some(),
4317 "t0_us should be Some when energy-scale is fitted"
4318 );
4319 assert!(
4320 result.l_scale.is_some(),
4321 "l_scale should be Some when energy-scale is fitted"
4322 );
4323 let t0 = result.t0_us.unwrap();
4324 let ls = result.l_scale.unwrap();
4325 assert!(t0.is_finite(), "t0 should be finite, got {t0}");
4327 assert!(ls.is_finite(), "l_scale should be finite, got {ls}");
4328 assert!(t0.abs() < 10.0, "t0 should be within bounds, got {t0}");
4329 assert!(
4330 ls > 0.98 && ls < 1.02,
4331 "l_scale should be within bounds, got {ls}"
4332 );
4333 }
4334
4335 #[test]
4337 fn test_no_energy_scale_returns_none() {
4338 let data = u238_single_resonance();
4339 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
4340 let (t_obs, sigma) = synthetic_transmission(&data, 0.002, &energies);
4341
4342 let config = UnifiedFitConfig::new(
4343 energies,
4344 vec![data],
4345 vec!["U-238".into()],
4346 293.6,
4347 None,
4348 vec![0.001],
4349 )
4350 .unwrap()
4351 .with_solver(SolverConfig::LevenbergMarquardt(Default::default()))
4352 .with_transmission_background(BackgroundConfig::default());
4353
4354 let input = InputData::Transmission {
4355 transmission: t_obs,
4356 uncertainty: sigma,
4357 };
4358 let result = fit_spectrum_typed(&input, &config).unwrap();
4359 assert!(
4360 result.t0_us.is_none(),
4361 "t0_us should be None without energy-scale"
4362 );
4363 assert!(
4364 result.l_scale.is_none(),
4365 "l_scale should be None without energy-scale"
4366 );
4367 }
4368
4369 #[test]
4379 fn test_joint_poisson_density_recovery_c_5_98() {
4380 let data = u238_single_resonance();
4381 let true_density = 0.0005_f64;
4382 let c = 5.98_f64;
4383 let lam_ob = 1000.0_f64; let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
4385 let (t, _) = synthetic_transmission(&data, true_density, &energies);
4386
4387 let open_beam_counts: Vec<f64> = vec![lam_ob; energies.len()];
4390 let sample_counts: Vec<f64> = t.iter().map(|&ti| c * lam_ob * ti).collect();
4391
4392 let config = UnifiedFitConfig::new(
4393 energies,
4394 vec![data],
4395 vec!["U-238".into()],
4396 0.0,
4397 None,
4398 vec![0.001],
4399 )
4400 .unwrap()
4401 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4402 .with_counts_background(CountsBackgroundConfig {
4403 c,
4404 ..Default::default()
4405 });
4406
4407 let input = InputData::Counts {
4408 sample_counts,
4409 open_beam_counts,
4410 };
4411 let result = fit_spectrum_typed(&input, &config).unwrap();
4412
4413 let d_per_dof = result
4415 .deviance_per_dof
4416 .expect("joint-Poisson solver must populate deviance_per_dof");
4417 assert!(
4418 d_per_dof.is_finite() && d_per_dof >= 0.0,
4419 "deviance_per_dof = {d_per_dof} is not a valid GOF"
4420 );
4421 assert!(
4425 d_per_dof < 0.5,
4426 "noise-free expected-counts fit should give D/dof ≈ 0, got {d_per_dof}"
4427 );
4428 let rel_bias = (result.densities[0] - true_density) / true_density;
4430 assert!(
4431 rel_bias.abs() < 0.05,
4432 "density bias {rel_bias} > 5%: fitted={} truth={true_density}",
4433 result.densities[0]
4434 );
4435 assert!((result.reduced_chi_squared - d_per_dof).abs() < 1e-12);
4437 }
4438
4439 #[test]
4443 fn test_joint_poisson_rejects_alpha_fit() {
4444 let data = u238_single_resonance();
4445 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.05).collect();
4446 let (t, _) = synthetic_transmission(&data, 0.0005, &energies);
4447 let open_beam_counts: Vec<f64> = vec![500.0; energies.len()];
4448 let sample_counts: Vec<f64> = t.iter().map(|&ti| 500.0 * ti).collect();
4449
4450 let config = UnifiedFitConfig::new(
4451 energies,
4452 vec![data],
4453 vec!["U-238".into()],
4454 0.0,
4455 None,
4456 vec![0.001],
4457 )
4458 .unwrap()
4459 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4460 .with_counts_background(CountsBackgroundConfig {
4461 fit_alpha_1: true,
4462 c: 1.0,
4463 ..Default::default()
4464 });
4465
4466 let input = InputData::Counts {
4467 sample_counts,
4468 open_beam_counts,
4469 };
4470 let err = fit_spectrum_typed(&input, &config).unwrap_err();
4471 let msg = err.to_string();
4472 assert!(
4473 msg.contains("fit_alpha_1") || msg.contains("alpha_1"),
4474 "expected alpha_1 rejection message, got: {msg}"
4475 );
4476 }
4477
4478 #[test]
4482 fn test_transmission_poisson_kl_dispatches_to_transmission_path() {
4483 let data = u238_single_resonance();
4484 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.05).collect();
4485 let (t, u) = synthetic_transmission(&data, 0.0005, &energies);
4486
4487 let config = UnifiedFitConfig::new(
4488 energies,
4489 vec![data],
4490 vec!["U-238".into()],
4491 0.0,
4492 None,
4493 vec![0.001],
4494 )
4495 .unwrap()
4496 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
4497
4498 let input = InputData::Transmission {
4499 transmission: t,
4500 uncertainty: u,
4501 };
4502 let r = fit_spectrum_typed(&input, &config).unwrap();
4503 assert!(r.deviance_per_dof.is_none());
4507 assert!(r.reduced_chi_squared.is_finite());
4508 }
4509
4510 #[test]
4520 fn test_joint_poisson_with_transmission_background() {
4521 let data = u238_single_resonance();
4522 let true_density = 0.0005_f64;
4523 let true_anorm = 0.85_f64;
4524 let true_ba = 0.03_f64;
4525 let true_bb = -0.01_f64;
4526 let true_bc = 0.0_f64;
4527 let c = 5.98_f64;
4528 let lam_ob = 2000.0_f64;
4529 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
4530 let (t_inner, _) = synthetic_transmission(&data, true_density, &energies);
4531 let t_out: Vec<f64> = t_inner
4532 .iter()
4533 .zip(energies.iter())
4534 .map(|(&ti, &e)| true_anorm * ti + true_ba + true_bb / e.sqrt() + true_bc * e.sqrt())
4535 .collect();
4536 let open_beam_counts: Vec<f64> = vec![lam_ob; energies.len()];
4537 let sample_counts: Vec<f64> = t_out.iter().map(|&ti| c * lam_ob * ti).collect();
4538
4539 let bg = BackgroundConfig {
4540 anorm_init: 1.0,
4541 back_a_init: 0.0,
4542 back_b_init: 0.0,
4543 back_c_init: 0.0,
4544 back_d_init: 0.01,
4545 back_f_init: 1.0,
4546 fit_anorm: true,
4547 fit_back_a: true,
4548 fit_back_b: true,
4549 fit_back_c: true,
4550 fit_back_d: false,
4551 fit_back_f: false,
4552 };
4553
4554 let config = UnifiedFitConfig::new(
4555 energies,
4556 vec![data],
4557 vec!["U-238".into()],
4558 0.0,
4559 None,
4560 vec![0.001],
4561 )
4562 .unwrap()
4563 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4564 .with_counts_background(CountsBackgroundConfig {
4565 c,
4566 ..Default::default()
4567 })
4568 .with_transmission_background(bg)
4569 .with_counts_enable_polish(Some(true));
4577
4578 let input = InputData::Counts {
4579 sample_counts,
4580 open_beam_counts,
4581 };
4582 let r = fit_spectrum_typed(&input, &config).unwrap();
4583
4584 let dpd = r.deviance_per_dof.expect("joint-Poisson must report D/dof");
4594 assert!(
4595 dpd < 1.0,
4596 "D/dof = {dpd} unexpectedly large on noise-free fit — bg params not reaching objective?"
4597 );
4598 assert!(r.densities[0] > 1e-5, "density railed: {}", r.densities[0]);
4600 assert!(
4602 (r.anorm - 1.0).abs() > 0.05,
4603 "A_n did not move from init 1.0 (fitted={})",
4604 r.anorm
4605 );
4606 let bg_moved = r.background.iter().any(|v| v.abs() > 1e-4);
4608 assert!(
4609 bg_moved,
4610 "no bg parameter moved from init 0: {:?}",
4611 r.background
4612 );
4613 }
4614
4615 #[test]
4617 fn test_joint_poisson_p2_2_requires_back_a_when_back_b_enabled() {
4618 let data = u238_single_resonance();
4619 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.05).collect();
4620 let (t, _) = synthetic_transmission(&data, 0.0005, &energies);
4621 let ob: Vec<f64> = vec![500.0; energies.len()];
4622 let s: Vec<f64> = t.iter().map(|&ti| 500.0 * ti).collect();
4623
4624 let bg = BackgroundConfig {
4625 fit_anorm: true,
4627 fit_back_a: false,
4628 fit_back_b: true,
4629 fit_back_c: false,
4630 fit_back_d: false,
4631 fit_back_f: false,
4632 ..BackgroundConfig::default()
4633 };
4634
4635 let config = UnifiedFitConfig::new(
4636 energies,
4637 vec![data],
4638 vec!["U-238".into()],
4639 0.0,
4640 None,
4641 vec![0.001],
4642 )
4643 .unwrap()
4644 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4645 .with_counts_background(CountsBackgroundConfig {
4646 c: 1.0,
4647 ..Default::default()
4648 })
4649 .with_transmission_background(bg);
4650
4651 let input = InputData::Counts {
4652 sample_counts: s,
4653 open_beam_counts: ob,
4654 };
4655 let err = fit_spectrum_typed(&input, &config).unwrap_err();
4656 let msg = err.to_string();
4657 assert!(
4658 msg.contains("§P2.2") || msg.contains("B_A"),
4659 "expected §P2.2 rejection message, got: {msg}"
4660 );
4661 }
4662
4663 #[test]
4665 fn test_joint_poisson_rejects_back_d_f() {
4666 let data = u238_single_resonance();
4667 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.05).collect();
4668 let (t, _) = synthetic_transmission(&data, 0.0005, &energies);
4669 let ob: Vec<f64> = vec![500.0; energies.len()];
4670 let s: Vec<f64> = t.iter().map(|&ti| 500.0 * ti).collect();
4671
4672 let bg = BackgroundConfig {
4673 fit_back_d: true,
4674 ..BackgroundConfig::default()
4675 };
4676
4677 let config = UnifiedFitConfig::new(
4678 energies,
4679 vec![data],
4680 vec!["U-238".into()],
4681 0.0,
4682 None,
4683 vec![0.001],
4684 )
4685 .unwrap()
4686 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4687 .with_counts_background(CountsBackgroundConfig {
4688 c: 1.0,
4689 ..Default::default()
4690 })
4691 .with_transmission_background(bg);
4692
4693 let input = InputData::Counts {
4694 sample_counts: s,
4695 open_beam_counts: ob,
4696 };
4697 let err = fit_spectrum_typed(&input, &config).unwrap_err();
4698 assert!(
4699 err.to_string().contains("BackD") || err.to_string().contains("§P4"),
4700 "expected BackD/BackF rejection, got: {err}"
4701 );
4702 }
4703
4704 #[test]
4712 fn test_lm_transmission_rejects_partial_back_d_only() {
4713 let data = u238_single_resonance();
4714 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.05).collect();
4715 let (t, u) = synthetic_transmission(&data, 0.0005, &energies);
4716
4717 let bg = BackgroundConfig {
4718 fit_back_d: true,
4721 fit_back_f: false,
4722 ..BackgroundConfig::default()
4723 };
4724
4725 let config = UnifiedFitConfig::new(
4726 energies,
4727 vec![data],
4728 vec!["U-238".into()],
4729 0.0,
4730 None,
4731 vec![0.001],
4732 )
4733 .unwrap()
4734 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4735 .with_transmission_background(bg);
4736
4737 let input = InputData::Transmission {
4738 transmission: t,
4739 uncertainty: u,
4740 };
4741 let err = fit_spectrum_typed(&input, &config).unwrap_err();
4742 let msg = err.to_string();
4743 assert!(
4744 msg.contains("fit_back_d") && msg.contains("fit_back_f"),
4745 "expected partial-BackD/F rejection mentioning both flags, got: {msg}"
4746 );
4747 }
4748
4749 #[test]
4751 fn test_lm_transmission_rejects_partial_back_f_only() {
4752 let data = u238_single_resonance();
4753 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.05).collect();
4754 let (t, u) = synthetic_transmission(&data, 0.0005, &energies);
4755
4756 let bg = BackgroundConfig {
4757 fit_back_d: false,
4758 fit_back_f: true,
4759 ..BackgroundConfig::default()
4760 };
4761
4762 let config = UnifiedFitConfig::new(
4763 energies,
4764 vec![data],
4765 vec!["U-238".into()],
4766 0.0,
4767 None,
4768 vec![0.001],
4769 )
4770 .unwrap()
4771 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4772 .with_transmission_background(bg);
4773
4774 let input = InputData::Transmission {
4775 transmission: t,
4776 uncertainty: u,
4777 };
4778 let err = fit_spectrum_typed(&input, &config).unwrap_err();
4779 let msg = err.to_string();
4780 assert!(
4781 msg.contains("fit_back_d") && msg.contains("fit_back_f"),
4782 "expected partial-BackD/F rejection mentioning both flags, got: {msg}"
4783 );
4784 }
4785
4786 #[test]
4788 fn test_transmission_poisson_kl_rejects_partial_back_d_f() {
4789 let data = u238_single_resonance();
4790 let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.05).collect();
4791 let (t, u) = synthetic_transmission(&data, 0.0005, &energies);
4792
4793 let bg = BackgroundConfig {
4794 fit_back_d: true,
4795 fit_back_f: false,
4796 ..BackgroundConfig::default()
4797 };
4798
4799 let config = UnifiedFitConfig::new(
4800 energies,
4801 vec![data],
4802 vec!["U-238".into()],
4803 0.0,
4804 None,
4805 vec![0.001],
4806 )
4807 .unwrap()
4808 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4809 .with_transmission_background(bg);
4810
4811 let input = InputData::Transmission {
4812 transmission: t,
4813 uncertainty: u,
4814 };
4815 let err = fit_spectrum_typed(&input, &config).unwrap_err();
4816 assert!(
4817 err.to_string().contains("fit_back_d"),
4818 "expected partial-BackD/F rejection on transmission-PoissonKL path"
4819 );
4820 }
4821
4822 #[test]
4828 fn test_unified_fit_config_fit_energy_range_round_trips() {
4829 let data = u238_single_resonance();
4830 let energies: Vec<f64> = (0..21).map(|i| 1.0 + (i as f64) * 0.1).collect();
4831 let cfg = UnifiedFitConfig::new(
4832 energies,
4833 vec![data],
4834 vec!["U-238".into()],
4835 0.0,
4836 None,
4837 vec![0.001],
4838 )
4839 .unwrap();
4840 assert_eq!(cfg.fit_energy_range(), None);
4841
4842 let cfg = cfg.with_fit_energy_range(Some((5.0, 50.0))).unwrap();
4843 assert_eq!(cfg.fit_energy_range(), Some((5.0, 50.0)));
4844
4845 let cfg = cfg.with_fit_energy_range(None).unwrap();
4847 assert_eq!(cfg.fit_energy_range(), None);
4848 }
4849
4850 #[test]
4854 fn test_unified_fit_config_fit_energy_range_rejects_invalid() {
4855 let data = u238_single_resonance();
4856 let energies: Vec<f64> = (0..21).map(|i| 1.0 + (i as f64) * 0.1).collect();
4857 let cfg = UnifiedFitConfig::new(
4858 energies,
4859 vec![data],
4860 vec!["U-238".into()],
4861 0.0,
4862 None,
4863 vec![0.001],
4864 )
4865 .unwrap();
4866
4867 let err = cfg
4869 .clone()
4870 .with_fit_energy_range(Some((10.0, 5.0)))
4871 .unwrap_err();
4872 assert!(matches!(err, FitConfigError::InvalidFitEnergyRange(_)));
4873
4874 let err = cfg
4876 .clone()
4877 .with_fit_energy_range(Some((5.0, 5.0)))
4878 .unwrap_err();
4879 assert!(matches!(err, FitConfigError::InvalidFitEnergyRange(_)));
4880
4881 let err = cfg
4883 .clone()
4884 .with_fit_energy_range(Some((f64::NAN, 5.0)))
4885 .unwrap_err();
4886 assert!(matches!(err, FitConfigError::InvalidFitEnergyRange(_)));
4887
4888 let err = cfg
4889 .clone()
4890 .with_fit_energy_range(Some((5.0, f64::INFINITY)))
4891 .unwrap_err();
4892 assert!(matches!(err, FitConfigError::InvalidFitEnergyRange(_)));
4893 }
4894
4895 #[test]
4900 fn test_fit_energy_range_lm_matches_subset_when_outside_negligible() {
4901 let data = u238_single_resonance();
4902 let true_density = 0.002;
4903 let energies: Vec<f64> = (0..201).map(|i| 0.5 + (i as f64) * 0.05).collect();
4908 let (t, sigma) = synthetic_transmission(&data, true_density, &energies);
4909
4910 let cfg_full = UnifiedFitConfig::new(
4911 energies.clone(),
4912 vec![data.clone()],
4913 vec!["U-238".into()],
4914 0.0,
4915 None,
4916 vec![0.001],
4917 )
4918 .unwrap()
4919 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
4920
4921 let cfg_masked = UnifiedFitConfig::new(
4922 energies.clone(),
4923 vec![data],
4924 vec!["U-238".into()],
4925 0.0,
4926 None,
4927 vec![0.001],
4928 )
4929 .unwrap()
4930 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4931 .with_fit_energy_range(Some((4.0, 8.0)))
4932 .unwrap();
4933
4934 let input = InputData::Transmission {
4935 transmission: t,
4936 uncertainty: sigma,
4937 };
4938 let r_full = fit_spectrum_typed(&input, &cfg_full).unwrap();
4939 let r_masked = fit_spectrum_typed(&input, &cfg_masked).unwrap();
4940 assert!(r_full.converged && r_masked.converged);
4941
4942 let d_full = r_full.densities[0];
4943 let d_masked = r_masked.densities[0];
4944 let rel_err = (d_full - d_masked).abs() / d_full.abs();
4945 assert!(
4946 rel_err < 0.01,
4947 "fit_energy_range LM density {d_masked} should match full-grid {d_full} \
4948 within 1% (got rel_err = {rel_err})"
4949 );
4950 }
4951
4952 #[test]
4959 fn test_fit_energy_range_rejected_on_transmission_poisson_path() {
4960 let data = u238_single_resonance();
4961 let true_density = 0.002;
4962 let energies: Vec<f64> = (0..201).map(|i| 0.5 + (i as f64) * 0.05).collect();
4963 let (t, sigma) = synthetic_transmission(&data, true_density, &energies);
4964
4965 let cfg = UnifiedFitConfig::new(
4966 energies,
4967 vec![data],
4968 vec!["U-238".into()],
4969 0.0,
4970 None,
4971 vec![0.001],
4972 )
4973 .unwrap()
4974 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4975 .with_fit_energy_range(Some((4.0, 8.0)))
4976 .unwrap();
4977
4978 let input = InputData::Transmission {
4979 transmission: t,
4980 uncertainty: sigma,
4981 };
4982 let err = fit_spectrum_typed(&input, &cfg).unwrap_err();
4983 let msg = err.to_string();
4984 assert!(
4985 msg.contains("fit_energy_range")
4986 && (msg.contains("Poisson") || msg.contains("poisson")),
4987 "expected rejection message mentioning fit_energy_range and the \
4988 Poisson-KL path; got: {msg}"
4989 );
4990 }
4991
4992 #[test]
4997 fn test_fit_energy_range_lm_rejects_too_narrow() {
4998 let data = u238_single_resonance();
4999 let energies: Vec<f64> = (0..21).map(|i| 0.5 + (i as f64) * 0.5).collect();
5003 let (t, sigma) = synthetic_transmission(&data, 0.002, &energies);
5004 let cfg = UnifiedFitConfig::new(
5005 energies,
5006 vec![data],
5007 vec!["U-238".into()],
5008 0.0,
5009 None,
5010 vec![0.001],
5011 )
5012 .unwrap()
5013 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
5014 .with_fit_energy_range(Some((4.6, 4.7)))
5015 .unwrap();
5016 let input = InputData::Transmission {
5017 transmission: t,
5018 uncertainty: sigma,
5019 };
5020 let err = fit_spectrum_typed(&input, &cfg).unwrap_err();
5021 let msg = err.to_string();
5022 assert!(
5023 msg.contains("fit_energy_range") && msg.contains("active bin"),
5024 "expected too-narrow-range rejection; got: {msg}"
5025 );
5026 }
5027
5028 #[test]
5031 fn test_fit_energy_range_jp_rejects_too_narrow() {
5032 let data = u238_single_resonance();
5033 let energies: Vec<f64> = (0..21).map(|i| 0.5 + (i as f64) * 0.5).collect();
5034 let (sample, open_beam) = synthetic_counts(&data, 0.002, &energies, 1000.0);
5035 let cfg = UnifiedFitConfig::new(
5036 energies,
5037 vec![data],
5038 vec!["U-238".into()],
5039 0.0,
5040 None,
5041 vec![0.001],
5042 )
5043 .unwrap()
5044 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
5045 .with_fit_energy_range(Some((4.6, 4.7)))
5046 .unwrap();
5047 let input = InputData::Counts {
5048 sample_counts: sample,
5049 open_beam_counts: open_beam,
5050 };
5051 let err = fit_spectrum_typed(&input, &cfg).unwrap_err();
5052 let msg = err.to_string();
5053 assert!(
5054 msg.contains("fit_energy_range") && msg.contains("active bin"),
5055 "expected too-narrow-range rejection; got: {msg}"
5056 );
5057 }
5058
5059 #[test]
5070 fn evaluate_jacobian_and_fisher_gaussian_aux_grid() {
5071 use nereids_physics::resolution::{ResolutionFunction, ResolutionParams};
5072 let data = u238_single_resonance();
5073 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
5074 let n_e = energies.len();
5075 let config = UnifiedFitConfig::new(
5076 energies,
5077 vec![data],
5078 vec!["U-238".into()],
5079 300.0,
5080 Some(ResolutionFunction::Gaussian(
5081 ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
5082 )),
5083 vec![0.001],
5084 )
5085 .unwrap();
5086 let flux = vec![5000.0; n_e];
5087 let background = vec![10.0; n_e];
5088 let result = evaluate_jacobian_and_fisher(&config, &flux, &background).unwrap();
5089 assert_eq!(result.model_prediction.len(), n_e);
5090 assert!(result.model_prediction.iter().all(|v| v.is_finite()));
5091 assert_eq!(result.param_names.len(), 1, "one free density parameter");
5092 let f00 = result.fisher.get(0, 0);
5095 assert!(f00.is_finite() && f00 > 0.0, "Fisher[0,0] = {f00}");
5096 assert!(result.jacobian.get(0, 0).is_finite());
5097 assert!(result.jacobian.get(n_e - 1, 0).is_finite());
5098 }
5099
5100 #[test]
5110 fn fit_spectrum_typed_energy_scale_lm_recovers_calibration() {
5111 let data = hf178_mlbw_two_resonances(); let energies: Vec<f64> = (0..700).map(|i| 4.0 + (i as f64) * 0.025).collect();
5113 let true_density = 0.05_f64;
5114 let (t0_true, ls_true) = (1.5_f64, 1.004_f64);
5115 let model = EnergyScaleTransmissionModel::new(
5118 Arc::new(vec![data.clone()]),
5119 Arc::new(vec![0]),
5120 Arc::new(vec![1.0]),
5121 293.6,
5122 energies.clone(),
5123 25.0,
5124 1,
5125 2,
5126 None,
5127 );
5128 let measured = model.evaluate(&[true_density, t0_true, ls_true]).unwrap();
5129 let uncertainty = vec![1e-3; energies.len()];
5130 let config = UnifiedFitConfig::new(
5131 energies,
5132 vec![data],
5133 vec!["Hf-178".into()],
5134 293.6,
5135 None,
5136 vec![0.04], )
5138 .unwrap()
5139 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
5140 .with_energy_scale(0.0, 1.0, 25.0); let input = InputData::Transmission {
5142 transmission: measured,
5143 uncertainty,
5144 };
5145 let result = fit_spectrum_typed(&input, &config).unwrap();
5146 let t0 = result
5147 .t0_us
5148 .expect("t0_us populated when fit_energy_scale=true");
5149 let ls = result
5150 .l_scale
5151 .expect("l_scale populated when fit_energy_scale=true");
5152 assert!(t0.is_finite() && ls.is_finite(), "t0={t0}, L={ls}");
5153 assert!(result.converged, "energy-scale LM fit should converge");
5154 assert!(
5155 (result.densities[0] - true_density).abs() / true_density < 0.10,
5156 "density: fitted={}, true={true_density}",
5157 result.densities[0]
5158 );
5159 }
5160
5161 #[test]
5166 fn fit_spectrum_typed_energy_scale_counts_kl_seeds_via_proxy() {
5167 let data = hf178_mlbw_two_resonances();
5168 let energies: Vec<f64> = (0..700).map(|i| 4.0 + (i as f64) * 0.025).collect();
5169 let true_density = 0.05_f64;
5170 let (t0_true, ls_true) = (1.0_f64, 1.003_f64);
5171 let model = EnergyScaleTransmissionModel::new(
5172 Arc::new(vec![data.clone()]),
5173 Arc::new(vec![0]),
5174 Arc::new(vec![1.0]),
5175 293.6,
5176 energies.clone(),
5177 25.0,
5178 1,
5179 2,
5180 None,
5181 );
5182 let t = model.evaluate(&[true_density, t0_true, ls_true]).unwrap();
5183 let flux: Vec<f64> = vec![5000.0; energies.len()];
5187 let background: Vec<f64> = vec![0.0; energies.len()];
5188 let sample: Vec<f64> = t
5189 .iter()
5190 .zip(flux.iter())
5191 .map(|(&ti, &fi)| fi * ti)
5192 .collect();
5193 let config = UnifiedFitConfig::new(
5194 energies,
5195 vec![data],
5196 vec!["Hf-178".into()],
5197 293.6,
5198 None,
5199 vec![0.04],
5200 )
5201 .unwrap()
5202 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
5203 .with_energy_scale(0.0, 1.0, 25.0);
5204 let input = InputData::CountsWithNuisance {
5205 sample_counts: sample,
5206 flux,
5207 background,
5208 };
5209 let result = fit_spectrum_typed(&input, &config).unwrap();
5210 let t0 = result
5211 .t0_us
5212 .expect("t0_us populated when fit_energy_scale=true");
5213 let ls = result
5214 .l_scale
5215 .expect("l_scale populated when fit_energy_scale=true");
5216 assert!(t0.is_finite() && ls.is_finite(), "t0={t0}, L={ls}");
5217 }
5218
5219 #[test]
5225 fn validate_precomputed_work_cross_sections_error_branches() {
5226 use nereids_physics::transmission::WorkingGridLayout;
5227 let data = u238_single_resonance();
5228 let energies: Vec<f64> = (0..11).map(|i| 1.0 + (i as f64) * 0.1).collect();
5229 let n_e = energies.len();
5230 let n_work = n_e + 2; let good_layout = || {
5232 Arc::new(WorkingGridLayout {
5233 energies: (0..n_work).map(|i| 1.0 + (i as f64) * 0.09).collect(),
5234 data_indices: (0..n_e).collect(),
5235 })
5236 };
5237 let cfg = |work_xs: Vec<Vec<f64>>, layout: Arc<WorkingGridLayout>| {
5238 UnifiedFitConfig::new(
5239 energies.clone(),
5240 vec![data.clone()],
5241 vec!["U-238".into()],
5242 0.0,
5243 None,
5244 vec![0.001],
5245 )
5246 .unwrap()
5247 .with_precomputed_cross_sections(Arc::new(vec![vec![1.0f64; n_e]])) .with_precomputed_work_cross_sections(Arc::new(work_xs), layout)
5249 };
5250 let expect =
5251 |c: &UnifiedFitConfig, needle: &str| match validate_precomputed_cross_sections(c) {
5252 Err(PipelineError::ShapeMismatch(m)) => {
5253 assert!(m.contains(needle), "expected {needle:?}, got: {m}")
5254 }
5255 other => panic!("expected ShapeMismatch({needle:?}), got {other:?}"),
5256 };
5257 expect(&cfg(vec![], good_layout()), "must not be empty");
5259 expect(
5261 &cfg(vec![vec![1.0; n_work - 1]], good_layout()),
5262 "working-grid energies",
5263 );
5264 let mut nan_row = vec![1.0; n_work];
5266 nan_row[3] = f64::NAN;
5267 expect(&cfg(vec![nan_row], good_layout()), "non-finite");
5268 expect(
5270 &cfg(vec![vec![1.0; n_work], vec![1.0; n_work]], good_layout()),
5271 "rows but",
5272 );
5273 let short = Arc::new(WorkingGridLayout {
5275 energies: (0..n_work).map(|i| 1.0 + (i as f64) * 0.09).collect(),
5276 data_indices: (0..n_e - 1).collect(),
5277 });
5278 expect(&cfg(vec![vec![1.0; n_work]], short), "layout maps");
5279 let oor = Arc::new(WorkingGridLayout {
5281 energies: (0..n_work).map(|i| 1.0 + (i as f64) * 0.09).collect(),
5282 data_indices: {
5283 let mut v: Vec<usize> = (0..n_e).collect();
5284 v[0] = n_work + 5;
5285 v
5286 },
5287 });
5288 expect(&cfg(vec![vec![1.0; n_work]], oor), "out of");
5289 }
5290
5291 #[test]
5296 fn evaluate_jacobian_and_fisher_identity_and_temperature_paths() {
5297 let data = u238_single_resonance();
5298 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
5299 let n_e = energies.len();
5300 let flux = vec![5000.0; n_e];
5301 let background = vec![10.0; n_e];
5302 let cfg_id = UnifiedFitConfig::new(
5304 energies.clone(),
5305 vec![data.clone()],
5306 vec!["U-238".into()],
5307 300.0,
5308 None,
5309 vec![0.001],
5310 )
5311 .unwrap();
5312 let r_id = evaluate_jacobian_and_fisher(&cfg_id, &flux, &background).unwrap();
5313 assert_eq!(r_id.model_prediction.len(), n_e);
5314 let f00 = r_id.fisher.get(0, 0);
5315 assert!(
5316 f00.is_finite() && f00 > 0.0,
5317 "identity-path Fisher[0,0]={f00}"
5318 );
5319 let cfg_t = UnifiedFitConfig::new(
5322 energies,
5323 vec![data],
5324 vec!["U-238".into()],
5325 300.0,
5326 None,
5327 vec![0.001],
5328 )
5329 .unwrap()
5330 .with_fit_temperature(true);
5331 let r_t = evaluate_jacobian_and_fisher(&cfg_t, &flux, &background).unwrap();
5332 assert_eq!(
5333 r_t.param_names.len(),
5334 2,
5335 "density + temperature free params"
5336 );
5337 assert!(r_t.model_prediction.iter().all(|v| v.is_finite()));
5338 }
5339
5340 #[test]
5345 fn fit_spectrum_typed_grouped_precomputed_collapses_by_groups() {
5346 use nereids_core::types::{Isotope, IsotopeGroup};
5347 let rd1 = synthetic_single_resonance(92, 235, 233.025, 5.0);
5348 let rd2 = synthetic_single_resonance(92, 238, 236.006, 7.0);
5349 let energies: Vec<f64> = (0..301).map(|i| 1.0 + (i as f64) * 0.05).collect();
5350 let true_density = 0.0005_f64;
5351 let sample = phys_transmission::SampleParams::new(
5352 0.0,
5353 vec![
5354 (rd1.clone(), true_density * 0.6),
5355 (rd2.clone(), true_density * 0.4),
5356 ],
5357 )
5358 .unwrap();
5359 let transmission = phys_transmission::forward_model(&energies, &sample, None).unwrap();
5360 let uncertainty: Vec<f64> = transmission.iter().map(|&t| 0.01 * t.max(0.01)).collect();
5361 let per_member = phys_transmission::broadened_cross_sections(
5363 &energies,
5364 &[rd1.clone(), rd2.clone()],
5365 0.0,
5366 None,
5367 None,
5368 )
5369 .unwrap();
5370 let iso1 = Isotope::new(92, 235).unwrap();
5371 let iso2 = Isotope::new(92, 238).unwrap();
5372 let group = IsotopeGroup::custom("U".into(), vec![(iso1, 0.6), (iso2, 0.4)]).unwrap();
5373 let config = UnifiedFitConfig::new(
5374 energies,
5375 vec![rd1.clone()],
5376 vec!["placeholder".into()],
5377 0.0,
5378 None,
5379 vec![0.001],
5380 )
5381 .unwrap()
5382 .with_groups(&[(&group, &[rd1, rd2])], vec![0.001])
5383 .unwrap()
5384 .with_precomputed_cross_sections(Arc::new(per_member))
5385 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
5386 let input = InputData::Transmission {
5387 transmission,
5388 uncertainty,
5389 };
5390 let result = fit_spectrum_typed(&input, &config).unwrap();
5391 assert_eq!(result.densities.len(), 1, "one group density");
5392 assert!(
5393 (result.densities[0] - true_density).abs() / true_density < 0.01,
5394 "grouped+precomputed density: fitted={}, true={true_density}",
5395 result.densities[0]
5396 );
5397 }
5398
5399 #[test]
5403 fn peak_match_energy_scale_seed_rejects_out_of_bounds() {
5404 let data = hf178_mlbw_two_resonances();
5405 let energies: Vec<f64> = (0..900).map(|i| 4.0 + (i as f64) * 0.02).collect();
5406 let model = EnergyScaleTransmissionModel::new(
5407 Arc::new(vec![data.clone()]),
5408 Arc::new(vec![0]),
5409 Arc::new(vec![1.0]),
5410 293.6,
5411 energies.clone(),
5412 25.0,
5413 1,
5414 2,
5415 None,
5416 );
5417 let t_obs = model.evaluate(&[0.05, 0.0, 1.03]).unwrap();
5419 let config = UnifiedFitConfig::new(
5420 energies.clone(),
5421 vec![data],
5422 vec!["Hf-178".into()],
5423 293.6,
5424 None,
5425 vec![0.05],
5426 )
5427 .unwrap()
5428 .with_energy_scale(0.0, 1.0, 25.0);
5429 assert!(
5430 peak_match_energy_scale_seed(
5431 &t_obs,
5432 config.energies(),
5433 &config,
5434 25.0,
5435 (-10.0, 10.0),
5436 (0.99, 1.01),
5437 )
5438 .is_none(),
5439 "an out-of-bounds calibration fit must be rejected (cold-start fallback)"
5440 );
5441 }
5442}