1use std::fmt;
11use std::sync::Arc;
12
13use nereids_endf::resonance::ResonanceData;
14use nereids_fitting::lm::{self, FitModel, LmConfig, LmResult};
15use nereids_fitting::parameters::{FitParameter, ParameterSet};
16use nereids_fitting::poisson::{self, PoissonConfig};
17use nereids_fitting::transmission_model::{
18 NormalizedTransmissionModel, PrecomputedTransmissionModel, TransmissionFitModel,
19};
20use nereids_physics::resolution::ResolutionFunction;
21use nereids_physics::transmission::InstrumentParams;
22
23use crate::error::PipelineError;
24
25#[derive(Debug, Clone)]
35pub struct BackgroundConfig {
36 pub anorm_init: f64,
38 pub back_a_init: f64,
40 pub back_b_init: f64,
42 pub back_c_init: f64,
44 pub fit_anorm: bool,
46 pub fit_back_a: bool,
48 pub fit_back_b: bool,
50 pub fit_back_c: bool,
52}
53
54impl Default for BackgroundConfig {
55 fn default() -> Self {
56 Self {
57 anorm_init: 1.0,
58 back_a_init: 0.0,
59 back_b_init: 0.0,
60 back_c_init: 0.0,
61 fit_anorm: true,
62 fit_back_a: true,
63 fit_back_b: true,
64 fit_back_c: true,
65 }
66 }
67}
68
69#[derive(Debug, Clone)]
81pub enum InputData {
82 Transmission {
86 transmission: Vec<f64>,
88 uncertainty: Vec<f64>,
90 },
91 Counts {
97 sample_counts: Vec<f64>,
99 open_beam_counts: Vec<f64>,
101 },
102 CountsWithNuisance {
106 sample_counts: Vec<f64>,
108 flux: Vec<f64>,
110 background: Vec<f64>,
112 },
113}
114
115impl InputData {
116 pub fn n_energies(&self) -> usize {
118 match self {
119 Self::Transmission { transmission, .. } => transmission.len(),
120 Self::Counts { sample_counts, .. } => sample_counts.len(),
121 Self::CountsWithNuisance { sample_counts, .. } => sample_counts.len(),
122 }
123 }
124
125 pub fn is_counts(&self) -> bool {
127 matches!(self, Self::Counts { .. } | Self::CountsWithNuisance { .. })
128 }
129}
130
131#[derive(Debug, Clone, Default)]
136pub enum SolverConfig {
137 LevenbergMarquardt(LmConfig),
139 PoissonKL(PoissonConfig),
141 #[default]
143 Auto,
144}
145
146#[derive(Debug, Clone)]
175pub struct CountsBackgroundConfig {
176 pub alpha_1_init: f64,
178 pub alpha_2_init: f64,
180 pub fit_alpha_1: bool,
182 pub fit_alpha_2: bool,
184}
185
186impl Default for CountsBackgroundConfig {
187 fn default() -> Self {
188 Self {
189 alpha_1_init: 1.0,
190 alpha_2_init: 1.0,
191 fit_alpha_1: false,
192 fit_alpha_2: false,
193 }
194 }
195}
196
197#[derive(Debug, Clone)]
204pub struct UnifiedFitConfig {
205 energies: Vec<f64>,
207 resonance_data: Vec<ResonanceData>,
208 isotope_names: Vec<String>,
209 temperature_k: f64,
210 resolution: Option<ResolutionFunction>,
211 initial_densities: Vec<f64>,
212 fit_temperature: bool,
213 compute_covariance: bool,
214
215 solver: SolverConfig,
217
218 transmission_background: Option<BackgroundConfig>,
221 counts_background: Option<CountsBackgroundConfig>,
223
224 precomputed_cross_sections: Option<Arc<Vec<Vec<f64>>>>,
226 precomputed_base_xs: Option<Arc<Vec<Vec<f64>>>>,
227
228 pub(crate) density_indices: Option<Vec<usize>>,
232 pub(crate) density_ratios: Option<Vec<f64>>,
235 n_density_params: Option<usize>,
238}
239
240impl UnifiedFitConfig {
241 pub fn new(
243 energies: Vec<f64>,
244 resonance_data: Vec<ResonanceData>,
245 isotope_names: Vec<String>,
246 temperature_k: f64,
247 resolution: Option<ResolutionFunction>,
248 initial_densities: Vec<f64>,
249 ) -> Result<Self, FitConfigError> {
250 if energies.is_empty() {
251 return Err(FitConfigError::EmptyEnergies);
252 }
253 if resonance_data.is_empty() {
254 return Err(FitConfigError::EmptyResonanceData);
255 }
256 if initial_densities.len() != resonance_data.len() {
257 return Err(FitConfigError::DensityCountMismatch {
258 densities: initial_densities.len(),
259 isotopes: resonance_data.len(),
260 });
261 }
262 if isotope_names.len() != resonance_data.len() {
263 return Err(FitConfigError::NameCountMismatch {
264 names: isotope_names.len(),
265 isotopes: resonance_data.len(),
266 });
267 }
268 if !temperature_k.is_finite() {
269 return Err(FitConfigError::NonFiniteTemperature(temperature_k));
270 }
271 if temperature_k < 0.0 {
272 return Err(FitConfigError::NegativeTemperature(temperature_k));
273 }
274 Ok(Self {
275 energies,
276 resonance_data,
277 isotope_names,
278 temperature_k,
279 resolution,
280 initial_densities,
281 fit_temperature: false,
282 compute_covariance: true,
283 solver: SolverConfig::Auto,
284 transmission_background: None,
285 counts_background: None,
286 precomputed_cross_sections: None,
287 precomputed_base_xs: None,
288 density_indices: None,
289 density_ratios: None,
290 n_density_params: None,
291 })
292 }
293
294 #[must_use]
297 pub fn with_solver(mut self, solver: SolverConfig) -> Self {
298 self.solver = solver;
299 self
300 }
301
302 #[must_use]
303 pub fn with_fit_temperature(mut self, v: bool) -> Self {
304 self.fit_temperature = v;
305 self
306 }
307
308 #[must_use]
309 pub fn with_compute_covariance(mut self, v: bool) -> Self {
310 self.compute_covariance = v;
311 self
312 }
313
314 #[must_use]
315 pub fn with_transmission_background(mut self, bg: BackgroundConfig) -> Self {
316 self.transmission_background = Some(bg);
317 self
318 }
319
320 #[must_use]
321 pub fn with_counts_background(mut self, bg: CountsBackgroundConfig) -> Self {
322 self.counts_background = Some(bg);
323 self
324 }
325
326 #[must_use]
327 pub fn with_precomputed_cross_sections(mut self, xs: Arc<Vec<Vec<f64>>>) -> Self {
328 self.precomputed_cross_sections = Some(xs);
329 self
330 }
331
332 #[must_use]
333 pub fn with_precomputed_base_xs(mut self, xs: Arc<Vec<Vec<f64>>>) -> Self {
334 self.precomputed_base_xs = Some(xs);
335 self
336 }
337
338 pub fn with_groups(
347 mut self,
348 groups: &[(&nereids_core::types::IsotopeGroup, &[ResonanceData])],
349 initial_densities: Vec<f64>,
350 ) -> Result<Self, FitConfigError> {
351 if groups.is_empty() {
352 return Err(FitConfigError::EmptyResonanceData);
353 }
354 if initial_densities.len() != groups.len() {
355 return Err(FitConfigError::DensityCountMismatch {
356 densities: initial_densities.len(),
357 isotopes: groups.len(),
358 });
359 }
360 let mut all_resonance_data = Vec::new();
361 let mut all_indices = Vec::new();
362 let mut all_ratios = Vec::new();
363 let mut names = Vec::new();
364 for (g_idx, (group, rd_list)) in groups.iter().enumerate() {
365 if rd_list.len() != group.n_members() {
366 return Err(FitConfigError::GroupMemberCountMismatch {
367 group_name: group.name().to_string(),
368 rd_count: rd_list.len(),
369 member_count: group.n_members(),
370 });
371 }
372 names.push(group.name().to_string());
373 for ((isotope, ratio), rd) in group.members().iter().zip(rd_list.iter()) {
374 if rd.isotope != *isotope {
376 return Err(FitConfigError::GroupMemberIsotopeMismatch {
377 group_name: group.name().to_string(),
378 expected_z: isotope.z(),
379 expected_a: isotope.a(),
380 got_z: rd.isotope.z(),
381 got_a: rd.isotope.a(),
382 });
383 }
384 all_resonance_data.push(rd.clone());
385 all_indices.push(g_idx);
386 all_ratios.push(*ratio);
387 }
388 }
389 self.resonance_data = all_resonance_data;
390 self.isotope_names = names;
391 self.initial_densities = initial_densities;
392 self.n_density_params = Some(groups.len());
393 self.density_indices = Some(all_indices);
394 self.density_ratios = Some(all_ratios);
395 self.precomputed_cross_sections = None;
397 self.precomputed_base_xs = None;
398 Ok(self)
399 }
400
401 pub fn energies(&self) -> &[f64] {
404 &self.energies
405 }
406 pub fn resonance_data(&self) -> &[ResonanceData] {
407 &self.resonance_data
408 }
409 pub fn isotope_names(&self) -> &[String] {
410 &self.isotope_names
411 }
412 pub fn temperature_k(&self) -> f64 {
413 self.temperature_k
414 }
415 pub fn resolution(&self) -> Option<&ResolutionFunction> {
416 self.resolution.as_ref()
417 }
418 pub fn initial_densities(&self) -> &[f64] {
419 &self.initial_densities
420 }
421 pub fn solver(&self) -> &SolverConfig {
422 &self.solver
423 }
424 pub fn fit_temperature(&self) -> bool {
425 self.fit_temperature
426 }
427 pub fn transmission_background(&self) -> Option<&BackgroundConfig> {
428 self.transmission_background.as_ref()
429 }
430 pub fn counts_background(&self) -> Option<&CountsBackgroundConfig> {
431 self.counts_background.as_ref()
432 }
433 pub fn precomputed_cross_sections(&self) -> Option<&Arc<Vec<Vec<f64>>>> {
434 self.precomputed_cross_sections.as_ref()
435 }
436 pub fn n_density_params(&self) -> usize {
438 self.n_density_params.unwrap_or(self.resonance_data.len())
439 }
440
441 pub(crate) fn effective_solver(&self, input: &InputData) -> SolverConfig {
443 match &self.solver {
444 SolverConfig::Auto => {
445 if input.is_counts() {
446 SolverConfig::PoissonKL(PoissonConfig::default())
447 } else {
448 SolverConfig::LevenbergMarquardt(LmConfig::default())
449 }
450 }
451 other => other.clone(),
452 }
453 }
454}
455
456pub fn fit_spectrum_typed(
469 input: &InputData,
470 config: &UnifiedFitConfig,
471) -> Result<SpectrumFitResult, PipelineError> {
472 let n_e = config.energies().len();
473
474 if config.fit_temperature && config.temperature_k < 1.0 {
476 return Err(PipelineError::InvalidParameter(format!(
477 "temperature must be >= 1.0 K when fit_temperature is true, got {}",
478 config.temperature_k,
479 )));
480 }
481
482 if input.n_energies() != n_e {
484 return Err(PipelineError::ShapeMismatch(format!(
485 "input data has {} energy bins but config.energies has {}",
486 input.n_energies(),
487 n_e,
488 )));
489 }
490
491 match input {
493 InputData::Transmission {
494 transmission,
495 uncertainty,
496 } => {
497 if uncertainty.len() != transmission.len() {
498 return Err(PipelineError::ShapeMismatch(format!(
499 "uncertainty length {} != transmission length {}",
500 uncertainty.len(),
501 transmission.len(),
502 )));
503 }
504 }
505 InputData::Counts {
506 sample_counts,
507 open_beam_counts,
508 } => {
509 if open_beam_counts.len() != sample_counts.len() {
510 return Err(PipelineError::ShapeMismatch(format!(
511 "open_beam_counts length {} != sample_counts length {}",
512 open_beam_counts.len(),
513 sample_counts.len(),
514 )));
515 }
516 }
517 InputData::CountsWithNuisance {
518 sample_counts,
519 flux,
520 background,
521 } => {
522 if flux.len() != sample_counts.len() {
523 return Err(PipelineError::ShapeMismatch(format!(
524 "flux length {} != sample_counts length {}",
525 flux.len(),
526 sample_counts.len(),
527 )));
528 }
529 if background.len() != sample_counts.len() {
530 return Err(PipelineError::ShapeMismatch(format!(
531 "background length {} != sample_counts length {}",
532 background.len(),
533 sample_counts.len(),
534 )));
535 }
536 }
537 }
538
539 let effective_solver = config.effective_solver(input);
540
541 match (input, &effective_solver) {
542 (
544 InputData::Transmission {
545 transmission,
546 uncertainty,
547 },
548 SolverConfig::LevenbergMarquardt(lm_cfg),
549 ) => fit_transmission_lm(transmission, uncertainty, config, lm_cfg),
550
551 (
553 InputData::Transmission {
554 transmission,
555 uncertainty,
556 },
557 SolverConfig::PoissonKL(poisson_cfg),
558 ) => fit_transmission_poisson(transmission, uncertainty, config, poisson_cfg),
559
560 (
562 InputData::Counts {
563 sample_counts,
564 open_beam_counts,
565 },
566 SolverConfig::PoissonKL(poisson_cfg),
567 ) => {
568 let flux: Vec<f64> = open_beam_counts.to_vec();
583 let background = vec![0.0f64; n_e];
584 fit_counts_poisson(sample_counts, &flux, &background, config, poisson_cfg)
585 }
586
587 (
589 InputData::CountsWithNuisance {
590 sample_counts,
591 flux,
592 background,
593 },
594 SolverConfig::PoissonKL(poisson_cfg),
595 ) => fit_counts_poisson(sample_counts, flux, background, config, poisson_cfg),
596
597 (
605 InputData::Counts {
606 sample_counts,
607 open_beam_counts,
608 },
609 SolverConfig::LevenbergMarquardt(lm_cfg),
610 ) => {
611 let (transmission, uncertainty) =
612 counts_to_transmission(sample_counts, open_beam_counts);
613 fit_transmission_lm(&transmission, &uncertainty, config, lm_cfg)
614 }
615
616 (InputData::CountsWithNuisance { .. }, SolverConfig::LevenbergMarquardt(_)) => {
618 Err(PipelineError::InvalidParameter(
619 "CountsWithNuisance requires PoissonKL solver (LM cannot use nuisance parameters)"
620 .into(),
621 ))
622 }
623
624 (_, SolverConfig::Auto) => unreachable!("Auto should be resolved before dispatch"),
626 }
627}
628
629fn counts_to_transmission(sample: &[f64], open_beam: &[f64]) -> (Vec<f64>, Vec<f64>) {
643 let transmission: Vec<f64> = sample
644 .iter()
645 .zip(open_beam.iter())
646 .map(|(&s, &ob)| if ob > 0.0 { s / ob } else { 0.0 })
647 .collect();
648 let uncertainty: Vec<f64> = sample
649 .iter()
650 .zip(open_beam.iter())
651 .map(|(&s, &ob)| {
652 if ob <= 0.0 {
653 1e30
655 } else if s <= 0.0 {
656 1e10
658 } else {
659 s.max(1.0).sqrt() / ob.max(1e-10)
660 }
661 })
662 .collect();
663 (transmission, uncertainty)
664}
665
666fn fit_transmission_lm(
668 measured_t: &[f64],
669 sigma: &[f64],
670 config: &UnifiedFitConfig,
671 lm_config: &LmConfig,
672) -> Result<SpectrumFitResult, PipelineError> {
673 let n_density_params = config.n_density_params();
674
675 let mut param_vec = build_density_params(config);
677
678 let _temperature_index = if config.fit_temperature {
680 param_vec.push(FitParameter {
681 name: "temperature_k".into(),
682 value: config.temperature_k,
683 lower: 1.0,
684 upper: 5000.0,
685 fixed: false,
686 });
687 Some(n_density_params)
688 } else {
689 None
690 };
691
692 let bg_base = param_vec.len();
694 let bg_indices = if let Some(bg) = &config.transmission_background {
695 append_background_params(&mut param_vec, bg);
696 Some((bg_base, bg_base + 1, bg_base + 2, bg_base + 3))
697 } else {
698 None
699 };
700
701 let mut params = ParameterSet::new(param_vec);
702 let mut lm_cfg = lm_config.clone();
703 lm_cfg.compute_covariance = config.compute_covariance;
704
705 let model = build_transmission_model(config, n_density_params, _temperature_index)?;
707
708 let result = if let Some((ai, bai, bbi, bci)) = bg_indices {
710 let wrapped =
711 NormalizedTransmissionModel::new(&*model, config.energies(), ai, bai, bbi, bci);
712 lm::levenberg_marquardt(&wrapped, measured_t, sigma, &mut params, &lm_cfg)?
713 } else {
714 lm::levenberg_marquardt(&*model, measured_t, sigma, &mut params, &lm_cfg)?
715 };
716
717 extract_result(config, &result, n_density_params, bg_indices)
718}
719
720fn fit_transmission_poisson(
722 measured_t: &[f64],
723 sigma: &[f64],
724 config: &UnifiedFitConfig,
725 poisson_cfg: &PoissonConfig,
726) -> Result<SpectrumFitResult, PipelineError> {
727 let mut poisson_cfg = poisson_cfg.clone();
729 poisson_cfg.compute_covariance = config.compute_covariance;
730 let poisson_cfg = &poisson_cfg;
731
732 let n_density_params = config.n_density_params();
733
734 let mut param_vec = build_density_params(config);
735
736 let temperature_index = if config.fit_temperature {
738 let idx = param_vec.len();
739 param_vec.push(FitParameter {
740 name: "temperature_k".into(),
741 value: config.temperature_k,
742 lower: 1.0,
743 upper: 5000.0,
744 fixed: false,
745 });
746 Some(idx)
747 } else {
748 None
749 };
750
751 let bg_base = param_vec.len();
753 let kl_bg = if config.transmission_background.is_some() {
754 param_vec.push(FitParameter {
755 name: "kl_b0".into(),
756 value: 0.0,
757 lower: 0.0,
758 upper: 0.5,
759 fixed: false,
760 });
761 param_vec.push(FitParameter {
762 name: "kl_b1".into(),
763 value: 0.0,
764 lower: 0.0,
765 upper: 0.5,
766 fixed: false,
767 });
768 Some((bg_base, bg_base + 1))
769 } else {
770 None
771 };
772
773 let mut params = ParameterSet::new(param_vec);
774
775 let model = build_transmission_model(config, n_density_params, temperature_index)?;
776
777 let polish_cfg = if kl_bg.is_some() && temperature_index.is_some() {
778 let mut cfg = poisson_cfg.clone();
779 cfg.max_iter = cfg.max_iter.clamp(20, 80);
780 cfg.tol_param = (cfg.tol_param * 1e-3).max(1e-12);
781 cfg.gauss_newton_lambda = (cfg.gauss_newton_lambda * 0.1).max(1e-8);
782 Some(cfg)
783 } else {
784 None
785 };
786
787 let result = if let Some((b0_idx, b1_idx)) = kl_bg {
789 let inv_sqrt_e: Vec<f64> = config
790 .energies()
791 .iter()
792 .map(|&e| 1.0 / e.max(1e-10).sqrt())
793 .collect();
794 let wrapped = poisson::TransmissionKLBackgroundModel {
795 inner: &*model,
796 inv_sqrt_energies: inv_sqrt_e,
797 b0_index: b0_idx,
798 b1_index: b1_idx,
799 n_params: params.params.len(),
800 };
801 let first_cfg = if polish_cfg.is_some() {
804 let mut cfg = poisson_cfg.clone();
805 cfg.compute_covariance = false;
806 cfg
807 } else {
808 poisson_cfg.clone()
809 };
810 let mut pr = poisson::poisson_fit(&wrapped, measured_t, &mut params, &first_cfg)?;
811 if pr.converged
812 && let Some(ref polish) = polish_cfg
813 {
814 let polish_result = poisson::poisson_fit(&wrapped, measured_t, &mut params, polish)?;
815 if polish_result.converged {
816 pr = poisson::PoissonResult {
817 iterations: pr.iterations + polish_result.iterations,
818 ..polish_result
819 };
820 }
821 }
822 poisson_to_lm_result(&wrapped, measured_t, sigma, &pr, ¶ms)
823 } else {
824 let pr = poisson::poisson_fit(&*model, measured_t, &mut params, poisson_cfg)?;
825 poisson_to_lm_result(&*model, measured_t, sigma, &pr, ¶ms)
826 }?;
827
828 let densities: Vec<f64> = (0..n_density_params).map(|i| result.params[i]).collect();
829 let uncertainties = if result.converged {
830 result.covariance.as_ref().map(|cov| {
831 (0..n_density_params)
832 .map(|i| cov.get(i, i).sqrt())
833 .collect::<Vec<_>>()
834 })
835 } else {
836 None
837 };
838
839 let fitted_temp = temperature_index.map(|idx| result.params[idx]);
841 let fitted_temp_unc = temperature_index.and_then(|idx| {
842 result
843 .uncertainties
844 .as_ref()
845 .and_then(|u| u.get(idx).copied())
846 });
847
848 if let Some((b0_idx, b1_idx)) = kl_bg {
849 let b0 = result.params[b0_idx];
850 let b1 = result.params[b1_idx];
851 Ok(SpectrumFitResult {
852 densities,
853 uncertainties,
854 reduced_chi_squared: result.reduced_chi_squared,
855 converged: result.converged,
856 iterations: result.iterations,
857 temperature_k: fitted_temp,
858 temperature_k_unc: fitted_temp_unc,
859 anorm: 1.0,
860 background: [b0, b1, 0.0],
861 })
862 } else {
863 let mut sr = extract_result(config, &result, n_density_params, None)?;
864 if let Some(t) = fitted_temp {
865 sr.temperature_k = Some(t);
866 }
867 Ok(sr)
868 }
869}
870
871fn fit_counts_poisson(
873 sample_counts: &[f64],
874 flux: &[f64],
875 background: &[f64],
876 config: &UnifiedFitConfig,
877 poisson_cfg: &PoissonConfig,
878) -> Result<SpectrumFitResult, PipelineError> {
879 let mut poisson_cfg = poisson_cfg.clone();
881 poisson_cfg.compute_covariance = config.compute_covariance;
882 let poisson_cfg = &poisson_cfg;
883
884 let n_density_params = config.n_density_params();
885
886 let mut param_vec = build_density_params(config);
887
888 let temperature_index = if config.fit_temperature {
890 let idx = param_vec.len();
891 param_vec.push(FitParameter {
892 name: "temperature_k".into(),
893 value: config.temperature_k,
894 lower: 1.0,
895 upper: 5000.0,
896 fixed: false,
897 });
898 Some(idx)
899 } else {
900 None
901 };
902
903 let bg_base = param_vec.len();
905 let kl_bg = if config.transmission_background.is_some() {
906 param_vec.push(FitParameter {
907 name: "kl_b0".into(),
908 value: 0.0,
909 lower: 0.0,
910 upper: 0.5,
911 fixed: false,
912 });
913 param_vec.push(FitParameter {
914 name: "kl_b1".into(),
915 value: 0.0,
916 lower: 0.0,
917 upper: 0.5,
918 fixed: false,
919 });
920 Some((bg_base, bg_base + 1))
921 } else {
922 None
923 };
924
925 let counts_bg = if let Some(bg) = config.counts_background() {
926 if bg.fit_alpha_1 && flux.iter().all(|&v| v.abs() <= 1e-12) {
934 return Err(PipelineError::InvalidParameter(
935 "counts background alpha_1 cannot be fitted with zero flux reference".into(),
936 ));
937 }
938 if bg.fit_alpha_2 && background.iter().all(|&v| v.abs() <= 1e-12) {
939 return Err(PipelineError::InvalidParameter(
940 "counts background alpha_2 cannot be fitted with zero detector background reference"
941 .into(),
942 ));
943 }
944
945 let alpha1_idx = param_vec.len();
946 param_vec.push(if bg.fit_alpha_1 {
947 FitParameter {
948 name: "alpha_1".into(),
949 value: bg.alpha_1_init,
950 lower: 0.0,
951 upper: 10.0,
952 fixed: false,
953 }
954 } else {
955 FitParameter::fixed("alpha_1", bg.alpha_1_init)
956 });
957
958 let alpha2_idx = param_vec.len();
959 param_vec.push(if bg.fit_alpha_2 {
960 FitParameter {
961 name: "alpha_2".into(),
962 value: bg.alpha_2_init,
963 lower: 0.0,
964 upper: 10.0,
965 fixed: false,
966 }
967 } else {
968 FitParameter::fixed("alpha_2", bg.alpha_2_init)
969 });
970
971 Some((alpha1_idx, alpha2_idx))
972 } else {
973 None
974 };
975
976 let mut params = ParameterSet::new(param_vec);
977
978 let t_model = build_transmission_model(config, n_density_params, temperature_index)?;
979 let n_free = params.n_free();
980 let dof = sample_counts.len().saturating_sub(n_free).max(1);
981
982 let (pr, y_model) = if let Some((b0_idx, b1_idx)) = kl_bg {
983 let inv_sqrt_e: Vec<f64> = config
984 .energies()
985 .iter()
986 .map(|&e| 1.0 / e.max(1e-10).sqrt())
987 .collect();
988 let wrapped = poisson::TransmissionKLBackgroundModel {
989 inner: &*t_model,
990 inv_sqrt_energies: inv_sqrt_e,
991 b0_index: b0_idx,
992 b1_index: b1_idx,
993 n_params: params.params.len(),
994 };
995 let (pr, y_model) = if let Some((alpha1_idx, alpha2_idx)) = counts_bg {
996 let counts_model = poisson::CountsBackgroundScaleModel {
997 transmission_model: &wrapped,
998 flux,
999 background,
1000 alpha1_index: alpha1_idx,
1001 alpha2_index: alpha2_idx,
1002 n_params: params.params.len(),
1003 };
1004 let pr = poisson::poisson_fit(&counts_model, sample_counts, &mut params, poisson_cfg)?;
1005 let y_model = counts_model.evaluate(&pr.params)?;
1006 (pr, y_model)
1007 } else {
1008 let counts_model = poisson::CountsModel {
1009 transmission_model: &wrapped,
1010 flux,
1011 background,
1012 n_params: params.params.len(),
1013 };
1014 let pr = poisson::poisson_fit(&counts_model, sample_counts, &mut params, poisson_cfg)?;
1015 let y_model = counts_model.evaluate(&pr.params)?;
1016 (pr, y_model)
1017 };
1018 (pr, y_model)
1019 } else {
1020 let (pr, y_model) = if let Some((alpha1_idx, alpha2_idx)) = counts_bg {
1021 let counts_model = poisson::CountsBackgroundScaleModel {
1022 transmission_model: &*t_model,
1023 flux,
1024 background,
1025 alpha1_index: alpha1_idx,
1026 alpha2_index: alpha2_idx,
1027 n_params: params.params.len(),
1028 };
1029 let pr = poisson::poisson_fit(&counts_model, sample_counts, &mut params, poisson_cfg)?;
1030 let y_model = counts_model.evaluate(&pr.params)?;
1031 (pr, y_model)
1032 } else {
1033 let counts_model = poisson::CountsModel {
1035 transmission_model: &*t_model,
1036 flux,
1037 background,
1038 n_params: params.params.len(),
1039 };
1040 let pr = poisson::poisson_fit(&counts_model, sample_counts, &mut params, poisson_cfg)?;
1041 let y_model = counts_model.evaluate(&pr.params)?;
1042 (pr, y_model)
1043 };
1044 (pr, y_model)
1045 };
1046
1047 let chi_sq: f64 = sample_counts
1049 .iter()
1050 .zip(y_model.iter())
1051 .map(|(&obs, &mdl)| {
1052 let expected = mdl.max(1e-10);
1053 (obs - expected).powi(2) / expected
1054 })
1055 .sum();
1056
1057 let densities: Vec<f64> = (0..n_density_params).map(|i| pr.params[i]).collect();
1058 let fitted_temp = temperature_index.map(|idx| pr.params[idx]);
1059 let fitted_alpha1 = counts_bg.map_or(1.0, |(alpha1_idx, _)| pr.params[alpha1_idx]);
1060 let fitted_alpha2 = counts_bg.map_or(0.0, |(_, alpha2_idx)| pr.params[alpha2_idx]);
1061 let fitted_background = if let Some((b0_idx, b1_idx)) = kl_bg {
1062 [pr.params[b0_idx], pr.params[b1_idx], fitted_alpha2]
1063 } else {
1064 [0.0, 0.0, fitted_alpha2]
1065 };
1066
1067 let (uncertainties, temperature_k_unc) = if let Some(ref unc_all) = pr.uncertainties {
1071 let dens_unc: Vec<f64> = (0..n_density_params)
1072 .map(|i| *unc_all.get(i).unwrap_or(&f64::NAN))
1073 .collect();
1074 let t_unc = temperature_index.and_then(|idx| {
1075 params
1078 .free_indices()
1079 .iter()
1080 .position(|&fi| fi == idx)
1081 .and_then(|pos| unc_all.get(pos).copied())
1082 });
1083 (Some(dens_unc), t_unc)
1084 } else {
1085 (None, None)
1086 };
1087
1088 Ok(SpectrumFitResult {
1089 densities,
1090 uncertainties,
1091 reduced_chi_squared: chi_sq / dof as f64,
1092 converged: pr.converged,
1093 iterations: pr.iterations,
1094 temperature_k: fitted_temp,
1095 temperature_k_unc,
1096 anorm: fitted_alpha1,
1097 background: fitted_background,
1098 })
1099}
1100
1101fn build_density_params(config: &UnifiedFitConfig) -> Vec<FitParameter> {
1104 config
1105 .initial_densities
1106 .iter()
1107 .enumerate()
1108 .map(|(i, &d)| {
1109 FitParameter::non_negative(
1110 config
1111 .isotope_names
1112 .get(i)
1113 .cloned()
1114 .unwrap_or_else(|| format!("isotope_{i}")),
1115 d,
1116 )
1117 })
1118 .collect()
1119}
1120
1121fn append_background_params(param_vec: &mut Vec<FitParameter>, bg: &BackgroundConfig) {
1122 param_vec.push(if bg.fit_anorm {
1127 FitParameter {
1128 name: "anorm".into(),
1129 value: bg.anorm_init,
1130 lower: 0.5,
1131 upper: 2.0,
1132 fixed: false,
1133 }
1134 } else {
1135 FitParameter::fixed("anorm", bg.anorm_init)
1136 });
1137 param_vec.push(if bg.fit_back_a {
1143 FitParameter {
1144 name: "back_a".into(),
1145 value: bg.back_a_init,
1146 lower: -0.5,
1147 upper: 0.5,
1148 fixed: false,
1149 }
1150 } else {
1151 FitParameter::fixed("back_a", bg.back_a_init)
1152 });
1153 param_vec.push(if bg.fit_back_b {
1154 FitParameter {
1155 name: "back_b".into(),
1156 value: bg.back_b_init,
1157 lower: -0.5,
1158 upper: 0.5,
1159 fixed: false,
1160 }
1161 } else {
1162 FitParameter::fixed("back_b", bg.back_b_init)
1163 });
1164 param_vec.push(if bg.fit_back_c {
1165 FitParameter {
1166 name: "back_c".into(),
1167 value: bg.back_c_init,
1168 lower: -0.5,
1169 upper: 0.5,
1170 fixed: false,
1171 }
1172 } else {
1173 FitParameter::fixed("back_c", bg.back_c_init)
1174 });
1175}
1176
1177fn build_transmission_model(
1179 config: &UnifiedFitConfig,
1180 n_density_params: usize,
1181 temperature_index: Option<usize>,
1182) -> Result<Box<dyn FitModel>, PipelineError> {
1183 let n_params = config.n_density_params();
1184 if !config.fit_temperature
1185 && let Some(xs) = &config.precomputed_cross_sections
1186 {
1187 let effective_xs =
1190 if let (Some(di), Some(dr)) = (&config.density_indices, &config.density_ratios) {
1191 if xs.len() == di.len() && di.len() == dr.len() {
1194 let n_e = xs[0].len();
1195 let mut eff = vec![vec![0.0f64; n_e]; n_params];
1196 for ((&idx, &ratio), member_xs) in di.iter().zip(dr.iter()).zip(xs.iter()) {
1197 for (j, &sigma) in member_xs.iter().enumerate() {
1198 eff[idx][j] += ratio * sigma;
1199 }
1200 }
1201 Arc::new(eff)
1202 } else {
1203 Arc::clone(xs)
1204 }
1205 } else {
1206 Arc::clone(xs)
1207 };
1208 let instrument = config
1211 .resolution
1212 .clone()
1213 .map(|r| Arc::new(InstrumentParams { resolution: r }));
1214 return Ok(Box::new(PrecomputedTransmissionModel {
1215 cross_sections: effective_xs,
1216 density_indices: Arc::new((0..n_params).collect()),
1217 energies: instrument
1218 .as_ref()
1219 .map(|_| Arc::new(config.energies.clone())),
1220 instrument,
1221 }));
1222 }
1223
1224 let instrument = config
1225 .resolution
1226 .clone()
1227 .map(|r| Arc::new(InstrumentParams { resolution: r }));
1228
1229 let base_xs = config.precomputed_base_xs.clone();
1230 let density_ratios = config
1231 .density_ratios
1232 .clone()
1233 .unwrap_or_else(|| vec![1.0; n_density_params]);
1234 let density_indices = config
1235 .density_indices
1236 .clone()
1237 .unwrap_or_else(|| (0..n_density_params).collect());
1238 Ok(Box::new(TransmissionFitModel::new(
1239 config.energies.clone(),
1240 config.resonance_data.clone(),
1241 config.temperature_k,
1242 instrument,
1243 (density_indices, density_ratios),
1244 temperature_index,
1245 base_xs,
1246 )?))
1247}
1248
1249fn poisson_to_lm_result(
1251 model: &dyn FitModel,
1252 measured_t: &[f64],
1253 sigma: &[f64],
1254 pr: &poisson::PoissonResult,
1255 params: &ParameterSet,
1256) -> Result<LmResult, PipelineError> {
1257 let n_free = params.n_free();
1258 let dof = measured_t.len().saturating_sub(n_free).max(1);
1259 let y_model = model.evaluate(&pr.params)?;
1260 let chi_sq: f64 = measured_t
1261 .iter()
1262 .zip(y_model.iter())
1263 .zip(sigma.iter())
1264 .map(|((obs, mdl), s)| {
1265 let residual = obs - mdl;
1266 (residual * residual) / (s * s).max(1e-30)
1267 })
1268 .sum();
1269 Ok(LmResult {
1270 chi_squared: chi_sq,
1271 reduced_chi_squared: chi_sq / dof as f64,
1272 iterations: pr.iterations,
1273 converged: pr.converged,
1274 params: pr.params.clone(),
1275 covariance: pr.covariance.clone(),
1276 uncertainties: pr.uncertainties.clone(),
1277 })
1278}
1279
1280fn extract_result(
1282 config: &UnifiedFitConfig,
1283 result: &LmResult,
1284 n_density_params: usize,
1285 bg_indices: Option<(usize, usize, usize, usize)>,
1286) -> Result<SpectrumFitResult, PipelineError> {
1287 let densities: Vec<f64> = (0..n_density_params).map(|i| result.params[i]).collect();
1288
1289 let (anorm, background) = if let Some((ai, bai, bbi, bci)) = bg_indices {
1290 (
1291 result.params[ai],
1292 [result.params[bai], result.params[bbi], result.params[bci]],
1293 )
1294 } else {
1295 (1.0, [0.0, 0.0, 0.0])
1296 };
1297
1298 let (uncertainties, temperature_k, temperature_k_unc) = if result.converged {
1299 match &result.uncertainties {
1300 Some(unc_all) => {
1301 let (temp_k, temp_unc) = if config.fit_temperature {
1302 (
1303 Some(result.params[n_density_params]),
1304 Some(*unc_all.get(n_density_params).unwrap_or(&f64::NAN)),
1305 )
1306 } else {
1307 (None, None)
1308 };
1309 let unc = unc_all
1310 .get(..n_density_params)
1311 .map(|s| s.to_vec())
1312 .unwrap_or_else(|| vec![f64::NAN; n_density_params]);
1313 (Some(unc), temp_k, temp_unc)
1314 }
1315 None => {
1316 let temp_k = if config.fit_temperature {
1317 Some(result.params[n_density_params])
1318 } else {
1319 None
1320 };
1321 (None, temp_k, None)
1322 }
1323 }
1324 } else {
1325 let temp_k = if config.fit_temperature {
1326 Some(result.params[n_density_params])
1327 } else {
1328 None
1329 };
1330 (None, temp_k, None)
1331 };
1332
1333 Ok(SpectrumFitResult {
1334 densities,
1335 uncertainties,
1336 reduced_chi_squared: result.reduced_chi_squared,
1337 converged: result.converged,
1338 iterations: result.iterations,
1339 temperature_k,
1340 temperature_k_unc,
1341 anorm,
1342 background,
1343 })
1344}
1345
1346#[derive(Debug, PartialEq)]
1350pub enum FitConfigError {
1351 EmptyEnergies,
1353 EmptyResonanceData,
1355 DensityCountMismatch { densities: usize, isotopes: usize },
1357 NameCountMismatch { names: usize, isotopes: usize },
1359 GroupMemberCountMismatch {
1361 group_name: String,
1362 rd_count: usize,
1363 member_count: usize,
1364 },
1365 GroupMemberIsotopeMismatch {
1367 group_name: String,
1368 expected_z: u32,
1369 expected_a: u32,
1370 got_z: u32,
1371 got_a: u32,
1372 },
1373 NonFiniteTemperature(f64),
1375 NegativeTemperature(f64),
1377}
1378
1379impl fmt::Display for FitConfigError {
1380 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1381 match self {
1382 Self::EmptyEnergies => write!(f, "energy grid must be non-empty"),
1383 Self::EmptyResonanceData => write!(f, "resonance_data must be non-empty"),
1384 Self::DensityCountMismatch {
1385 densities,
1386 isotopes,
1387 } => write!(
1388 f,
1389 "initial_densities length ({densities}) must match number of density parameters ({isotopes})"
1390 ),
1391 Self::NameCountMismatch { names, isotopes } => write!(
1392 f,
1393 "isotope_names length ({names}) must match resonance_data length ({isotopes})"
1394 ),
1395 Self::GroupMemberCountMismatch {
1396 group_name,
1397 rd_count,
1398 member_count,
1399 } => write!(
1400 f,
1401 "group '{group_name}': provided {rd_count} ResonanceData but group has {member_count} members"
1402 ),
1403 Self::GroupMemberIsotopeMismatch {
1404 group_name,
1405 expected_z,
1406 expected_a,
1407 got_z,
1408 got_a,
1409 } => write!(
1410 f,
1411 "group '{group_name}': expected Z={expected_z} A={expected_a} but got Z={got_z} A={got_a}"
1412 ),
1413 Self::NonFiniteTemperature(v) => {
1414 write!(f, "temperature must be finite, got {v}")
1415 }
1416 Self::NegativeTemperature(v) => {
1417 write!(f, "temperature must be non-negative, got {v}")
1418 }
1419 }
1420 }
1421}
1422
1423impl std::error::Error for FitConfigError {}
1424
1425#[derive(Debug, Clone)]
1427pub struct SpectrumFitResult {
1428 pub densities: Vec<f64>,
1430 pub uncertainties: Option<Vec<f64>>,
1434 pub reduced_chi_squared: f64,
1436 pub converged: bool,
1438 pub iterations: usize,
1440 pub temperature_k: Option<f64>,
1442 pub temperature_k_unc: Option<f64>,
1444 pub anorm: f64,
1447 pub background: [f64; 3],
1451}
1452
1453#[cfg(test)]
1454mod tests {
1455 use super::*;
1456 use crate::test_helpers::synthetic_single_resonance;
1457 use nereids_fitting::lm::FitModel;
1458 use nereids_physics::transmission as phys_transmission;
1459
1460 use crate::test_helpers::u238_single_resonance;
1461
1462 #[test]
1465 fn test_input_data_transmission_n_energies() {
1466 let data = InputData::Transmission {
1467 transmission: vec![0.9, 0.8, 0.7],
1468 uncertainty: vec![0.01, 0.01, 0.01],
1469 };
1470 assert_eq!(data.n_energies(), 3);
1471 assert!(!data.is_counts());
1472 }
1473
1474 #[test]
1475 fn test_input_data_counts_n_energies() {
1476 let data = InputData::Counts {
1477 sample_counts: vec![10.0, 20.0, 30.0, 40.0],
1478 open_beam_counts: vec![100.0, 100.0, 100.0, 100.0],
1479 };
1480 assert_eq!(data.n_energies(), 4);
1481 assert!(data.is_counts());
1482 }
1483
1484 #[test]
1485 fn test_input_data_counts_with_nuisance() {
1486 let data = InputData::CountsWithNuisance {
1487 sample_counts: vec![5.0, 6.0],
1488 flux: vec![100.0, 100.0],
1489 background: vec![0.5, 0.5],
1490 };
1491 assert_eq!(data.n_energies(), 2);
1492 assert!(data.is_counts());
1493 }
1494
1495 #[test]
1496 fn test_solver_config_default_is_auto() {
1497 let cfg = SolverConfig::default();
1498 assert!(matches!(cfg, SolverConfig::Auto));
1499 }
1500
1501 #[test]
1502 fn test_counts_background_config_default() {
1503 let cfg = CountsBackgroundConfig::default();
1504 assert!((cfg.alpha_1_init - 1.0).abs() < f64::EPSILON);
1505 assert!((cfg.alpha_2_init - 1.0).abs() < f64::EPSILON);
1506 assert!(!cfg.fit_alpha_1);
1507 assert!(!cfg.fit_alpha_2);
1508 }
1509
1510 fn synthetic_transmission(
1514 data: &ResonanceData,
1515 true_density: f64,
1516 energies: &[f64],
1517 ) -> (Vec<f64>, Vec<f64>) {
1518 let model = PrecomputedTransmissionModel {
1519 cross_sections: Arc::new(vec![
1520 phys_transmission::broadened_cross_sections(
1521 energies,
1522 std::slice::from_ref(data),
1523 0.0,
1524 None,
1525 None,
1526 )
1527 .unwrap()
1528 .into_iter()
1529 .next()
1530 .unwrap(),
1531 ]),
1532 density_indices: Arc::new(vec![0]),
1533 energies: None,
1534 instrument: None,
1535 };
1536 let t = model.evaluate(&[true_density]).unwrap();
1537 let sigma: Vec<f64> = t.iter().map(|&v| 0.01 * v.max(0.01)).collect();
1538 (t, sigma)
1539 }
1540
1541 fn synthetic_counts(
1543 data: &ResonanceData,
1544 true_density: f64,
1545 energies: &[f64],
1546 i0: f64,
1547 ) -> (Vec<f64>, Vec<f64>) {
1548 let (t, _) = synthetic_transmission(data, true_density, energies);
1549 let open_beam: Vec<f64> = vec![i0; energies.len()];
1550 let sample: Vec<f64> = t.iter().map(|&v| (v * i0).round().max(0.0)).collect();
1551 (sample, open_beam)
1552 }
1553
1554 #[test]
1555 fn test_typed_transmission_lm_recovers_density() {
1556 let data = u238_single_resonance();
1557 let true_density = 0.002;
1558 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
1559 let (t, sigma) = synthetic_transmission(&data, true_density, &energies);
1560
1561 let config = UnifiedFitConfig::new(
1562 energies,
1563 vec![data],
1564 vec!["U-238".into()],
1565 0.0,
1566 None,
1567 vec![0.001],
1568 )
1569 .unwrap()
1570 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
1571
1572 let input = InputData::Transmission {
1573 transmission: t,
1574 uncertainty: sigma,
1575 };
1576
1577 let result = fit_spectrum_typed(&input, &config).unwrap();
1578 assert!(result.converged, "LM should converge");
1579 let fitted = result.densities[0];
1580 assert!(
1581 (fitted - true_density).abs() / true_density < 0.05,
1582 "density: fitted={fitted}, true={true_density}"
1583 );
1584 }
1585
1586 #[test]
1587 fn test_extract_result_drops_uncertainties_when_unconverged() {
1588 let data = u238_single_resonance();
1589 let energies: Vec<f64> = (0..21).map(|i| 1.0 + (i as f64) * 0.1).collect();
1590 let config = UnifiedFitConfig::new(
1591 energies,
1592 vec![data],
1593 vec!["U-238".into()],
1594 293.6,
1595 None,
1596 vec![0.001],
1597 )
1598 .unwrap();
1599
1600 let result = LmResult {
1601 chi_squared: 1.0,
1602 reduced_chi_squared: 1.0,
1603 iterations: 5,
1604 converged: false,
1605 params: vec![0.001],
1606 covariance: Some(lm::FlatMatrix::zeros(1, 1)),
1607 uncertainties: Some(vec![0.123]),
1608 };
1609
1610 let extracted = extract_result(&config, &result, 1, None).unwrap();
1611 assert!(!extracted.converged);
1612 assert!(
1613 extracted.uncertainties.is_none(),
1614 "pipeline must not surface uncertainties from an unconverged fit"
1615 );
1616 assert!(extracted.temperature_k_unc.is_none());
1617 }
1618
1619 #[test]
1620 fn test_typed_counts_kl_recovers_density() {
1621 let data = u238_single_resonance();
1622 let true_density = 0.002;
1623 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
1624 let (sample, open_beam) = synthetic_counts(&data, true_density, &energies, 1000.0);
1625
1626 let config = UnifiedFitConfig::new(
1627 energies,
1628 vec![data],
1629 vec!["U-238".into()],
1630 0.0,
1631 None,
1632 vec![0.001],
1633 )
1634 .unwrap()
1635 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
1636
1637 let input = InputData::Counts {
1638 sample_counts: sample,
1639 open_beam_counts: open_beam,
1640 };
1641
1642 let result = fit_spectrum_typed(&input, &config).unwrap();
1643 assert!(result.converged, "KL on counts should converge");
1644 let fitted = result.densities[0];
1645 assert!(
1646 (fitted - true_density).abs() / true_density < 0.10,
1647 "density: fitted={fitted}, true={true_density}"
1648 );
1649 }
1650
1651 #[test]
1652 fn test_typed_counts_kl_low_counts_recovers_density() {
1653 let data = u238_single_resonance();
1655 let true_density = 0.0005;
1656 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
1657 let (sample, open_beam) = synthetic_counts(&data, true_density, &energies, 10.0);
1658
1659 let config = UnifiedFitConfig::new(
1660 energies,
1661 vec![data],
1662 vec!["U-238".into()],
1663 0.0,
1664 None,
1665 vec![0.001],
1666 )
1667 .unwrap()
1668 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
1669
1670 let input = InputData::Counts {
1671 sample_counts: sample,
1672 open_beam_counts: open_beam,
1673 };
1674
1675 let result = fit_spectrum_typed(&input, &config).unwrap();
1676 assert!(result.converged, "KL on low counts should converge");
1677 let fitted = result.densities[0];
1678 assert!(
1680 (fitted - true_density).abs() / true_density < 0.30,
1681 "density: fitted={fitted}, true={true_density}"
1682 );
1683 }
1684
1685 #[test]
1686 fn test_typed_transmission_kl_recovers_density() {
1687 let data = u238_single_resonance();
1688 let true_density = 0.0005;
1689 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
1690 let (t, sigma) = synthetic_transmission(&data, true_density, &energies);
1691
1692 let config = UnifiedFitConfig::new(
1693 energies,
1694 vec![data],
1695 vec!["U-238".into()],
1696 0.0,
1697 None,
1698 vec![0.001],
1699 )
1700 .unwrap()
1701 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
1702
1703 let input = InputData::Transmission {
1704 transmission: t,
1705 uncertainty: sigma,
1706 };
1707
1708 let result = fit_spectrum_typed(&input, &config).unwrap();
1709 assert!(result.converged, "KL on transmission should converge");
1710 let fitted = result.densities[0];
1711 assert!(
1712 (fitted - true_density).abs() / true_density < 0.05,
1713 "density: fitted={fitted}, true={true_density}"
1714 );
1715 }
1716
1717 #[test]
1718 fn test_typed_counts_lm_auto_converts() {
1719 let data = u238_single_resonance();
1721 let true_density = 0.0005;
1722 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
1723 let (sample, open_beam) = synthetic_counts(&data, true_density, &energies, 1000.0);
1724
1725 let config = UnifiedFitConfig::new(
1726 energies,
1727 vec![data],
1728 vec!["U-238".into()],
1729 0.0,
1730 None,
1731 vec![0.001],
1732 )
1733 .unwrap()
1734 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
1735
1736 let input = InputData::Counts {
1737 sample_counts: sample,
1738 open_beam_counts: open_beam,
1739 };
1740
1741 let result = fit_spectrum_typed(&input, &config).unwrap();
1742 assert!(
1743 result.converged,
1744 "LM on auto-converted counts should converge"
1745 );
1746 let fitted = result.densities[0];
1747 assert!(
1748 (fitted - true_density).abs() / true_density < 0.10,
1749 "density: fitted={fitted}, true={true_density}"
1750 );
1751 }
1752
1753 #[test]
1754 fn test_typed_auto_solver_selects_kl_for_counts() {
1755 let data = u238_single_resonance();
1756 let true_density = 0.0005;
1757 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
1758 let (sample, open_beam) = synthetic_counts(&data, true_density, &energies, 1000.0);
1759
1760 let config = UnifiedFitConfig::new(
1762 energies,
1763 vec![data],
1764 vec!["U-238".into()],
1765 0.0,
1766 None,
1767 vec![0.001],
1768 )
1769 .unwrap(); let input = InputData::Counts {
1772 sample_counts: sample,
1773 open_beam_counts: open_beam,
1774 };
1775
1776 let result = fit_spectrum_typed(&input, &config).unwrap();
1777 assert!(
1778 result.converged,
1779 "Auto solver on counts should use KL and converge"
1780 );
1781 }
1782
1783 #[test]
1784 fn test_typed_transmission_with_background_lm() {
1785 let data = u238_single_resonance();
1786 let true_density = 0.0005;
1787 let true_anorm = 0.95;
1788 let true_back_a = 0.02;
1789 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
1790
1791 let (t_pure, _) = synthetic_transmission(&data, true_density, &energies);
1793 let t_bg: Vec<f64> = t_pure
1794 .iter()
1795 .map(|&v| true_anorm * v + true_back_a)
1796 .collect();
1797 let sigma: Vec<f64> = t_bg.iter().map(|&v| 0.01 * v.max(0.01)).collect();
1798
1799 let config = UnifiedFitConfig::new(
1800 energies,
1801 vec![data],
1802 vec!["U-238".into()],
1803 0.0,
1804 None,
1805 vec![0.001],
1806 )
1807 .unwrap()
1808 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig {
1809 max_iter: 500,
1810 ..LmConfig::default()
1811 }))
1812 .with_transmission_background(BackgroundConfig::default());
1813
1814 let input = InputData::Transmission {
1815 transmission: t_bg,
1816 uncertainty: sigma,
1817 };
1818
1819 let result = fit_spectrum_typed(&input, &config).unwrap();
1820 assert!(
1821 result.converged,
1822 "LM+BG should converge on noiseless synthetic data (chi2r={}, iter={})",
1823 result.reduced_chi_squared, result.iterations
1824 );
1825 assert!(
1826 (result.densities[0] - true_density).abs() / true_density < 0.05,
1827 "density: fitted={}, true={true_density}",
1828 result.densities[0]
1829 );
1830 assert!(
1831 (result.anorm - true_anorm).abs() / true_anorm < 0.05,
1832 "anorm: fitted={}, true={true_anorm}",
1833 result.anorm
1834 );
1835 }
1836
1837 #[test]
1838 fn test_typed_counts_with_nuisance_rejects_lm() {
1839 let data = u238_single_resonance();
1840 let energies: Vec<f64> = (0..10).map(|i| 1.0 + (i as f64) * 0.5).collect();
1841
1842 let config = UnifiedFitConfig::new(
1843 energies,
1844 vec![data],
1845 vec!["U-238".into()],
1846 0.0,
1847 None,
1848 vec![0.001],
1849 )
1850 .unwrap()
1851 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
1852
1853 let input = InputData::CountsWithNuisance {
1854 sample_counts: vec![10.0; 10],
1855 flux: vec![100.0; 10],
1856 background: vec![0.0; 10],
1857 };
1858
1859 let result = fit_spectrum_typed(&input, &config);
1860 assert!(result.is_err(), "CountsWithNuisance + LM should error");
1861 }
1862
1863 fn synthetic_transmission_at_temp(
1865 data: &ResonanceData,
1866 true_density: f64,
1867 temperature_k: f64,
1868 energies: &[f64],
1869 ) -> (Vec<f64>, Vec<f64>) {
1870 let xs = phys_transmission::broadened_cross_sections(
1871 energies,
1872 std::slice::from_ref(data),
1873 temperature_k,
1874 None,
1875 None,
1876 )
1877 .unwrap();
1878 let model = PrecomputedTransmissionModel {
1879 cross_sections: Arc::new(xs),
1880 density_indices: Arc::new(vec![0]),
1881 energies: None,
1882 instrument: None,
1883 };
1884 let t = model.evaluate(&[true_density]).unwrap();
1885 let sigma: Vec<f64> = t.iter().map(|&v| 0.01 * v.max(0.01)).collect();
1886 (t, sigma)
1887 }
1888
1889 #[test]
1890 fn test_typed_poisson_kl_with_temperature() {
1891 let data = u238_single_resonance();
1892 let true_density = 0.0005;
1893 let true_temp = 350.0;
1894 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
1895 let (t, sigma) = synthetic_transmission_at_temp(&data, true_density, true_temp, &energies);
1896
1897 let config = UnifiedFitConfig::new(
1898 energies,
1899 vec![data],
1900 vec!["U-238".into()],
1901 300.0, None,
1903 vec![0.001],
1904 )
1905 .unwrap()
1906 .with_solver(SolverConfig::PoissonKL(PoissonConfig {
1907 max_iter: 500,
1908 ..PoissonConfig::default()
1909 }))
1910 .with_fit_temperature(true);
1911
1912 let input = InputData::Transmission {
1913 transmission: t,
1914 uncertainty: sigma,
1915 };
1916
1917 let result = fit_spectrum_typed(&input, &config).unwrap();
1918
1919 let fitted_density = result.densities[0];
1921 assert!(
1922 (fitted_density - true_density).abs() / true_density < 0.01,
1923 "density: fitted={fitted_density}, true={true_density}, ratio={}",
1924 (fitted_density - true_density).abs() / true_density,
1925 );
1926
1927 let fitted_temp = result
1929 .temperature_k
1930 .expect("temperature_k should be Some when fit_temperature=true");
1931 assert!(
1932 (fitted_temp - true_temp).abs() < 1.0,
1933 "temperature: fitted={fitted_temp}, true={true_temp}, delta={}",
1934 (fitted_temp - true_temp).abs(),
1935 );
1936 }
1937
1938 #[test]
1939 fn test_typed_poisson_kl_with_temperature_and_background() {
1940 let data = u238_single_resonance();
1941 let true_density = 0.0005;
1942 let true_temp = 350.0;
1943 let true_b0 = 0.012;
1944 let true_b1 = 0.008;
1945 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
1946 let (t, sigma) = synthetic_transmission_at_temp(&data, true_density, true_temp, &energies);
1947 let measured_t: Vec<f64> = t
1948 .iter()
1949 .zip(energies.iter())
1950 .map(|(&ti, &e)| ti + true_b0 + true_b1 / e.sqrt())
1951 .collect();
1952
1953 let config = UnifiedFitConfig::new(
1954 energies,
1955 vec![data],
1956 vec!["U-238".into()],
1957 300.0,
1958 None,
1959 vec![0.001],
1960 )
1961 .unwrap()
1962 .with_solver(SolverConfig::PoissonKL(PoissonConfig {
1963 max_iter: 120,
1964 gauss_newton_lambda: 1e-4,
1965 ..PoissonConfig::default()
1966 }))
1967 .with_fit_temperature(true)
1968 .with_transmission_background(BackgroundConfig::default());
1969
1970 let input = InputData::Transmission {
1971 transmission: measured_t,
1972 uncertainty: sigma,
1973 };
1974
1975 let result = fit_spectrum_typed(&input, &config).unwrap();
1976
1977 assert!(result.converged, "fit did not converge: {result:?}");
1978 assert!(
1979 result.iterations <= 80,
1980 "expected KL background+temperature fit to converge well before max_iter; got {}",
1981 result.iterations,
1982 );
1983
1984 let fitted_density = result.densities[0];
1985 assert!(
1986 (fitted_density - true_density).abs() / true_density < 0.02,
1987 "density: fitted={fitted_density}, true={true_density}, ratio={}",
1988 (fitted_density - true_density).abs() / true_density,
1989 );
1990
1991 let fitted_temp = result
1992 .temperature_k
1993 .expect("temperature_k should be Some when fit_temperature=true");
1994 assert!(
1995 (fitted_temp - true_temp).abs() < 3.0,
1996 "temperature: fitted={fitted_temp}, true={true_temp}, delta={}",
1997 (fitted_temp - true_temp).abs(),
1998 );
1999
2000 assert!(
2001 (result.background[0] - true_b0).abs() < 5e-3,
2002 "background b0: fitted={}, true={}",
2003 result.background[0],
2004 true_b0,
2005 );
2006 assert!(
2007 (result.background[1] - true_b1).abs() < 5e-3,
2008 "background b1: fitted={}, true={}",
2009 result.background[1],
2010 true_b1,
2011 );
2012 }
2013
2014 #[test]
2015 fn test_typed_counts_poisson_kl_with_background() {
2016 let data = u238_single_resonance();
2017 let true_density = 0.0005;
2018 let true_b0 = 0.012;
2019 let true_b1 = 0.008;
2020 let detector_bg = 2.0;
2021 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
2022 let (t, _) = synthetic_transmission(&data, true_density, &energies);
2023 let flux = vec![1000.0; energies.len()];
2024 let background = vec![detector_bg; energies.len()];
2025 let sample_counts: Vec<f64> = t
2026 .iter()
2027 .zip(energies.iter())
2028 .zip(flux.iter())
2029 .zip(background.iter())
2030 .map(|(((&ti, &e), &f), &bg)| f * (ti + true_b0 + true_b1 / e.sqrt()) + bg)
2031 .collect();
2032
2033 let config = UnifiedFitConfig::new(
2034 energies,
2035 vec![data],
2036 vec!["U-238".into()],
2037 0.0,
2038 None,
2039 vec![0.001],
2040 )
2041 .unwrap()
2042 .with_solver(SolverConfig::PoissonKL(PoissonConfig {
2043 max_iter: 120,
2044 gauss_newton_lambda: 1e-4,
2045 ..PoissonConfig::default()
2046 }))
2047 .with_transmission_background(BackgroundConfig::default());
2048
2049 let input = InputData::CountsWithNuisance {
2050 sample_counts,
2051 flux,
2052 background,
2053 };
2054
2055 let result = fit_spectrum_typed(&input, &config).unwrap();
2056
2057 assert!(result.converged, "fit did not converge: {result:?}");
2058 assert!(
2059 (result.densities[0] - true_density).abs() / true_density < 0.02,
2060 "density: fitted={}, true={true_density}",
2061 result.densities[0]
2062 );
2063 assert!(
2064 (result.background[0] - true_b0).abs() < 5e-3,
2065 "background b0: fitted={}, true={true_b0}",
2066 result.background[0]
2067 );
2068 assert!(
2069 (result.background[1] - true_b1).abs() < 5e-3,
2070 "background b1: fitted={}, true={true_b1}",
2071 result.background[1]
2072 );
2073 }
2074
2075 #[test]
2076 fn test_typed_counts_poisson_kl_with_temperature_and_background() {
2077 let data = u238_single_resonance();
2078 let true_density = 0.0005;
2079 let true_temp = 350.0;
2080 let true_b0 = 0.012;
2081 let true_b1 = 0.008;
2082 let detector_bg = 2.0;
2083 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
2084 let (t, _) = synthetic_transmission_at_temp(&data, true_density, true_temp, &energies);
2085 let flux = vec![1000.0; energies.len()];
2086 let background = vec![detector_bg; energies.len()];
2087 let sample_counts: Vec<f64> = t
2088 .iter()
2089 .zip(energies.iter())
2090 .zip(flux.iter())
2091 .zip(background.iter())
2092 .map(|(((&ti, &e), &f), &bg)| f * (ti + true_b0 + true_b1 / e.sqrt()) + bg)
2093 .collect();
2094
2095 let config = UnifiedFitConfig::new(
2096 energies,
2097 vec![data],
2098 vec!["U-238".into()],
2099 300.0,
2100 None,
2101 vec![0.001],
2102 )
2103 .unwrap()
2104 .with_solver(SolverConfig::PoissonKL(PoissonConfig {
2105 max_iter: 120,
2106 gauss_newton_lambda: 1e-4,
2107 ..PoissonConfig::default()
2108 }))
2109 .with_fit_temperature(true)
2110 .with_transmission_background(BackgroundConfig::default());
2111
2112 let input = InputData::CountsWithNuisance {
2113 sample_counts,
2114 flux,
2115 background,
2116 };
2117
2118 let result = fit_spectrum_typed(&input, &config).unwrap();
2119
2120 assert!(result.converged, "fit did not converge: {result:?}");
2121 assert!(
2122 (result.densities[0] - true_density).abs() / true_density < 0.02,
2123 "density: fitted={}, true={true_density}",
2124 result.densities[0]
2125 );
2126
2127 let fitted_temp = result
2128 .temperature_k
2129 .expect("temperature_k should be Some when fit_temperature=true");
2130 assert!(
2131 (fitted_temp - true_temp).abs() < 3.0,
2132 "temperature: fitted={fitted_temp}, true={true_temp}, delta={}",
2133 (fitted_temp - true_temp).abs(),
2134 );
2135 assert!(
2136 (result.background[0] - true_b0).abs() < 5e-3,
2137 "background b0: fitted={}, true={true_b0}",
2138 result.background[0]
2139 );
2140 assert!(
2141 (result.background[1] - true_b1).abs() < 5e-3,
2142 "background b1: fitted={}, true={true_b1}",
2143 result.background[1]
2144 );
2145 }
2146
2147 #[test]
2148 fn test_typed_counts_poisson_kl_with_counts_background_scales() {
2149 let data = u238_single_resonance();
2150 let true_density = 0.002;
2151 let true_alpha1 = 0.92;
2152 let true_alpha2 = 1.35;
2153 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
2154 let (t, _) = synthetic_transmission(&data, true_density, &energies);
2155 let flux = vec![120.0; energies.len()];
2156 let background: Vec<f64> = energies.iter().map(|&e| 30.0 + 8.0 / e.sqrt()).collect();
2157 let sample_counts: Vec<f64> = t
2158 .iter()
2159 .zip(flux.iter())
2160 .zip(background.iter())
2161 .map(|((&ti, &f), &bg)| true_alpha1 * f * ti + true_alpha2 * bg)
2162 .collect();
2163
2164 let config = UnifiedFitConfig::new(
2165 energies,
2166 vec![data],
2167 vec!["U-238".into()],
2168 0.0,
2169 None,
2170 vec![0.001],
2171 )
2172 .unwrap()
2173 .with_solver(SolverConfig::PoissonKL(PoissonConfig {
2174 max_iter: 120,
2175 gauss_newton_lambda: 1e-4,
2176 ..PoissonConfig::default()
2177 }))
2178 .with_counts_background(CountsBackgroundConfig {
2179 alpha_1_init: 1.0,
2180 alpha_2_init: 1.0,
2181 fit_alpha_1: true,
2182 fit_alpha_2: true,
2183 });
2184
2185 let input = InputData::CountsWithNuisance {
2186 sample_counts,
2187 flux,
2188 background,
2189 };
2190
2191 let result = fit_spectrum_typed(&input, &config).unwrap();
2192
2193 assert!(result.converged, "fit did not converge: {result:?}");
2194 assert!(
2195 (result.densities[0] - true_density).abs() / true_density < 0.02,
2196 "density: fitted={}, true={true_density}",
2197 result.densities[0]
2198 );
2199 assert!(
2200 (result.anorm - true_alpha1).abs() < 5e-3,
2201 "alpha_1/anorm: fitted={}, true={true_alpha1}",
2202 result.anorm
2203 );
2204 assert!(
2205 (result.background[2] - true_alpha2).abs() < 5e-3,
2206 "alpha_2/background[2]: fitted={}, true={true_alpha2}",
2207 result.background[2]
2208 );
2209 }
2210
2211 #[test]
2215 fn test_grouped_fit_spectrum_round_trip() {
2216 use nereids_core::types::IsotopeGroup;
2217
2218 let rd1 = synthetic_single_resonance(92, 235, 233.025, 5.0);
2220 let rd2 = synthetic_single_resonance(92, 238, 236.006, 7.0);
2221
2222 let iso1 = nereids_core::types::Isotope::new(92, 235).unwrap();
2224 let iso2 = nereids_core::types::Isotope::new(92, 238).unwrap();
2225 let group =
2226 IsotopeGroup::custom("U (60/40)".into(), vec![(iso1, 0.6), (iso2, 0.4)]).unwrap();
2227
2228 let energies: Vec<f64> = (0..301).map(|i| 1.0 + (i as f64) * 0.05).collect();
2229 let true_density = 0.0005;
2230
2231 let sample = nereids_physics::transmission::SampleParams::new(
2233 0.0,
2234 vec![
2235 (rd1.clone(), true_density * 0.6),
2236 (rd2.clone(), true_density * 0.4),
2237 ],
2238 )
2239 .unwrap();
2240 let transmission =
2241 nereids_physics::transmission::forward_model(&energies, &sample, None).unwrap();
2242 let uncertainty: Vec<f64> = transmission.iter().map(|&t| 0.01 * t.max(0.01)).collect();
2243
2244 let config = UnifiedFitConfig::new(
2246 energies.clone(),
2247 vec![rd1.clone()],
2248 vec!["placeholder".into()],
2249 0.0,
2250 None,
2251 vec![0.001],
2252 )
2253 .unwrap()
2254 .with_groups(&[(&group, &[rd1, rd2])], vec![0.001])
2255 .unwrap()
2256 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2257
2258 let input = InputData::Transmission {
2259 transmission,
2260 uncertainty,
2261 };
2262
2263 let result = fit_spectrum_typed(&input, &config).unwrap();
2264
2265 assert_eq!(result.densities.len(), 1, "should have 1 group density");
2267 let fitted = result.densities[0];
2268 let rel_error = (fitted - true_density).abs() / true_density;
2269 assert!(
2270 rel_error < 0.01,
2271 "group density: fitted={fitted}, true={true_density}, rel_error={rel_error}"
2272 );
2273 assert!(result.converged, "fit should converge");
2274 }
2275
2276 #[test]
2277 fn test_grouped_poisson_kl_with_temperature_and_background_noiseless() {
2278 use nereids_core::types::IsotopeGroup;
2279
2280 let rd1 = synthetic_single_resonance(72, 176, 8.5, 5.0);
2281 let rd2 = synthetic_single_resonance(72, 178, 17.0, 7.5);
2282 let rd3 = synthetic_single_resonance(72, 180, 29.0, 6.0);
2283
2284 let hf176 = nereids_core::types::Isotope::new(72, 176).unwrap();
2285 let hf178 = nereids_core::types::Isotope::new(72, 178).unwrap();
2286 let hf180 = nereids_core::types::Isotope::new(72, 180).unwrap();
2287 let group = IsotopeGroup::custom(
2288 "Hf-like (3 member)".into(),
2289 vec![(hf176, 0.2), (hf178, 0.5), (hf180, 0.3)],
2290 )
2291 .unwrap();
2292
2293 let energies: Vec<f64> = (0..300).map(|i| 1.0 + (49.0 * i as f64) / 299.0).collect();
2294 let true_density = 0.001;
2295 let true_temp = 400.0;
2296 let true_b0 = 0.012;
2297 let true_b1 = 0.008;
2298
2299 let sample = nereids_physics::transmission::SampleParams::new(
2300 true_temp,
2301 vec![
2302 (rd1.clone(), true_density * 0.2),
2303 (rd2.clone(), true_density * 0.5),
2304 (rd3.clone(), true_density * 0.3),
2305 ],
2306 )
2307 .unwrap();
2308 let pure_t =
2309 nereids_physics::transmission::forward_model(&energies, &sample, None).unwrap();
2310 let measured_t: Vec<f64> = pure_t
2311 .iter()
2312 .zip(energies.iter())
2313 .map(|(&t, &e)| t + true_b0 + true_b1 / e.sqrt())
2314 .collect();
2315 let sigma = vec![0.001; energies.len()];
2316
2317 let config = UnifiedFitConfig::new(
2318 energies.clone(),
2319 vec![rd1.clone()],
2320 vec!["placeholder".into()],
2321 293.6,
2322 None,
2323 vec![0.0008],
2324 )
2325 .unwrap()
2326 .with_groups(&[(&group, &[rd1, rd2, rd3])], vec![0.0008])
2327 .unwrap()
2328 .with_solver(SolverConfig::PoissonKL(PoissonConfig {
2329 max_iter: 200,
2330 gauss_newton_lambda: 1e-4,
2331 ..PoissonConfig::default()
2332 }))
2333 .with_fit_temperature(true)
2334 .with_transmission_background(BackgroundConfig::default());
2335
2336 let input = InputData::Transmission {
2337 transmission: measured_t,
2338 uncertainty: sigma,
2339 };
2340
2341 let result = fit_spectrum_typed(&input, &config).unwrap();
2342
2343 assert!(result.converged, "fit did not converge: {result:?}");
2344 assert!(
2345 (result.densities[0] - true_density).abs() / true_density < 0.005,
2346 "density: fitted={}, true={true_density}",
2347 result.densities[0]
2348 );
2349 let fitted_temp = result
2350 .temperature_k
2351 .expect("temperature_k should be Some when fit_temperature=true");
2352 assert!(
2353 (fitted_temp - true_temp).abs() < 8.0,
2354 "temperature: fitted={fitted_temp}, true={true_temp}",
2355 );
2356 assert!(
2357 (result.background[0] - true_b0).abs() < 5e-3,
2358 "background b0: fitted={}, true={true_b0}",
2359 result.background[0]
2360 );
2361 assert!(
2362 (result.background[1] - true_b1).abs() < 5e-3,
2363 "background b1: fitted={}, true={true_b1}",
2364 result.background[1]
2365 );
2366 }
2367
2368 #[test]
2371 fn test_kl_counts_returns_density_uncertainty() {
2372 let data = u238_single_resonance();
2373 let true_density = 0.002;
2374 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
2375 let (sample, open_beam) = synthetic_counts(&data, true_density, &energies, 1000.0);
2376
2377 let config = UnifiedFitConfig::new(
2378 energies,
2379 vec![data],
2380 vec!["U-238".into()],
2381 0.0,
2382 None,
2383 vec![0.001],
2384 )
2385 .unwrap()
2386 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
2387
2388 let input = InputData::Counts {
2389 sample_counts: sample,
2390 open_beam_counts: open_beam,
2391 };
2392 let result = fit_spectrum_typed(&input, &config).unwrap();
2393 assert!(result.converged);
2394 let unc = result
2395 .uncertainties
2396 .as_ref()
2397 .expect("KL 1D fit should return density uncertainties");
2398 assert_eq!(unc.len(), 1);
2399 assert!(
2400 unc[0].is_finite() && unc[0] > 0.0,
2401 "density unc = {}",
2402 unc[0]
2403 );
2404 assert!(
2405 unc[0] < result.densities[0],
2406 "unc ({}) should be < density ({}) for high-count data",
2407 unc[0],
2408 result.densities[0]
2409 );
2410 }
2411
2412 #[test]
2413 fn test_kl_counts_returns_temperature_uncertainty() {
2414 let data = u238_single_resonance();
2415 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.05).collect();
2416 let (sample, open_beam) = synthetic_counts(&data, 0.001, &energies, 1000.0);
2417
2418 let config = UnifiedFitConfig::new(
2419 energies,
2420 vec![data],
2421 vec!["U-238".into()],
2422 350.0,
2423 None,
2424 vec![0.0005],
2425 )
2426 .unwrap()
2427 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
2428 .with_fit_temperature(true);
2429
2430 let input = InputData::Counts {
2431 sample_counts: sample,
2432 open_beam_counts: open_beam,
2433 };
2434 let result = fit_spectrum_typed(&input, &config).unwrap();
2435 assert!(result.converged);
2436 let unc = result
2437 .uncertainties
2438 .as_ref()
2439 .expect("KL+temp fit should return density uncertainties");
2440 assert!(
2441 unc[0].is_finite() && unc[0] > 0.0,
2442 "density unc = {}",
2443 unc[0]
2444 );
2445 let t_unc = result
2446 .temperature_k_unc
2447 .expect("KL+temp fit should return temperature uncertainty");
2448 assert!(
2449 t_unc.is_finite() && t_unc > 0.0,
2450 "temperature unc = {t_unc}"
2451 );
2452 }
2453
2454 #[test]
2455 fn test_kl_counts_with_background_returns_uncertainty() {
2456 let data = u238_single_resonance();
2457 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.05).collect();
2458 let (sample, open_beam) = synthetic_counts(&data, 0.001, &energies, 1000.0);
2459
2460 let config = UnifiedFitConfig::new(
2461 energies,
2462 vec![data],
2463 vec!["U-238".into()],
2464 300.0,
2465 None,
2466 vec![0.0005],
2467 )
2468 .unwrap()
2469 .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
2470 .with_fit_temperature(true)
2471 .with_transmission_background(BackgroundConfig::default());
2472
2473 let input = InputData::Counts {
2474 sample_counts: sample,
2475 open_beam_counts: open_beam,
2476 };
2477 let result = fit_spectrum_typed(&input, &config).unwrap();
2478 assert!(result.converged);
2479 let unc = result
2480 .uncertainties
2481 .as_ref()
2482 .expect("KL+bg fit should return density uncertainties");
2483 assert!(
2484 unc[0].is_finite() && unc[0] > 0.0,
2485 "density unc = {}",
2486 unc[0]
2487 );
2488 }
2489
2490 #[test]
2491 fn test_lm_uncertainty_not_regressed() {
2492 let data = u238_single_resonance();
2493 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
2494 let (t, sigma) = synthetic_transmission(&data, 0.001, &energies);
2495
2496 let config = UnifiedFitConfig::new(
2497 energies,
2498 vec![data],
2499 vec!["U-238".into()],
2500 0.0,
2501 None,
2502 vec![0.0005],
2503 )
2504 .unwrap()
2505 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2506
2507 let input = InputData::Transmission {
2508 transmission: t,
2509 uncertainty: sigma,
2510 };
2511 let result = fit_spectrum_typed(&input, &config).unwrap();
2512 assert!(result.converged);
2513 let unc = result
2514 .uncertainties
2515 .as_ref()
2516 .expect("LM should still return uncertainties");
2517 assert!(unc[0].is_finite() && unc[0] > 0.0);
2518 }
2519}