1use std::cell::{Cell, RefCell};
8use std::rc::Rc;
9use std::sync::Arc;
10
11use nereids_endf::resonance::ResonanceData;
12use nereids_physics::resolution;
13use nereids_physics::transmission::{self, InstrumentParams, SampleParams};
14
15use crate::error::FittingError;
16use crate::lm::{FitModel, FlatMatrix};
17
18pub struct PrecomputedTransmissionModel {
34 pub cross_sections: Arc<Vec<Vec<f64>>>,
37 pub density_indices: Arc<Vec<usize>>,
45 pub energies: Option<Arc<Vec<f64>>>,
48 pub instrument: Option<Arc<InstrumentParams>>,
52}
53
54impl FitModel for PrecomputedTransmissionModel {
55 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
56 if self.cross_sections.is_empty() {
57 return Err(FittingError::InvalidConfig(
58 "PrecomputedTransmissionModel.cross_sections must not be empty".into(),
59 ));
60 }
61 let n_e = self.cross_sections[0].len();
62 let mut neg_opt = vec![0.0f64; n_e];
63 for (i, xs) in self.cross_sections.iter().enumerate() {
70 let density = params[self.density_indices[i]];
71 for (j, &sigma) in xs.iter().enumerate() {
72 neg_opt[j] -= density * sigma;
73 }
74 }
75 let transmission: Vec<f64> = neg_opt.iter().map(|&d| d.exp()).collect();
76
77 if let (Some(inst), Some(energies)) = (&self.instrument, &self.energies) {
80 let t_broadened =
81 resolution::apply_resolution(energies, &transmission, &inst.resolution).map_err(
82 |e| FittingError::EvaluationFailed(format!("resolution broadening: {e}")),
83 )?;
84 Ok(t_broadened)
85 } else {
86 Ok(transmission)
87 }
88 }
89
90 fn analytical_jacobian(
103 &self,
104 params: &[f64],
105 free_param_indices: &[usize],
106 y_current: &[f64],
107 ) -> Option<FlatMatrix> {
108 let n_e = if self.cross_sections.is_empty() {
109 return None;
110 } else {
111 self.cross_sections[0].len()
112 };
113 let n_free = free_param_indices.len();
114
115 let fp_xs_sums: Vec<Vec<f64>> = free_param_indices
119 .iter()
120 .map(|&fp_idx| {
121 let mut sum = vec![0.0f64; n_e];
122 for (iso, &di) in self.density_indices.iter().enumerate() {
123 if di == fp_idx {
124 for (j, &sigma) in self.cross_sections[iso].iter().enumerate() {
125 sum[j] += sigma;
126 }
127 }
128 }
129 sum
130 })
131 .collect();
132
133 if let (Some(inst), Some(energies)) = (&self.instrument, &self.energies) {
137 let mut neg_opt = vec![0.0f64; n_e];
139 for (i, xs) in self.cross_sections.iter().enumerate() {
140 let density = params[self.density_indices[i]];
141 for (j, &sigma) in xs.iter().enumerate() {
142 neg_opt[j] -= density * sigma;
143 }
144 }
145 let t_unresolved: Vec<f64> = neg_opt.iter().map(|&d| d.exp()).collect();
146
147 let mut jacobian = FlatMatrix::zeros(n_e, n_free);
149 for (col, xs_sum) in fp_xs_sums.iter().enumerate() {
150 let inner_deriv: Vec<f64> =
151 (0..n_e).map(|i| -xs_sum[i] * t_unresolved[i]).collect();
152 let resolved_deriv =
153 resolution::apply_resolution(energies, &inner_deriv, &inst.resolution).ok()?;
154 for (i, &val) in resolved_deriv.iter().enumerate() {
155 *jacobian.get_mut(i, col) = val;
156 }
157 }
158 Some(jacobian)
159 } else {
160 let mut jacobian = FlatMatrix::zeros(n_e, n_free);
163 for i in 0..n_e {
164 for (j, xs_sum) in fp_xs_sums.iter().enumerate() {
165 *jacobian.get_mut(i, j) = -xs_sum[i] * y_current[i];
166 }
167 }
168 Some(jacobian)
169 }
170 }
171}
172
173pub struct TransmissionFitModel {
190 energies: Vec<f64>,
192 resonance_data: Vec<ResonanceData>,
194 temperature_k: f64,
196 instrument: Option<Arc<InstrumentParams>>,
198 density_indices: Vec<usize>,
206 density_ratios: Vec<f64>,
210 temperature_index: Option<usize>,
214 base_xs: Option<Arc<Vec<Vec<f64>>>>,
220 cached_broadened_xs: RefCell<Option<Rc<Vec<Vec<f64>>>>>,
226 cached_dxs_dt: RefCell<Option<Rc<Vec<Vec<f64>>>>>,
230 cached_temperature: Cell<f64>,
233}
234
235impl TransmissionFitModel {
236 pub fn new(
246 energies: Vec<f64>,
247 resonance_data: Vec<ResonanceData>,
248 temperature_k: f64,
249 instrument: Option<Arc<InstrumentParams>>,
250 density_mapping: (Vec<usize>, Vec<f64>),
251 temperature_index: Option<usize>,
252 external_base_xs: Option<Arc<Vec<Vec<f64>>>>,
253 ) -> Result<Self, FittingError> {
254 let (density_indices, density_ratios) = density_mapping;
255 if density_indices.len() != resonance_data.len() {
256 return Err(FittingError::InvalidConfig(format!(
257 "density_indices has {} entries but resonance_data has {}",
258 density_indices.len(),
259 resonance_data.len(),
260 )));
261 }
262 if density_ratios.len() != resonance_data.len() {
263 return Err(FittingError::InvalidConfig(format!(
264 "density_ratios has {} entries but resonance_data has {}",
265 density_ratios.len(),
266 resonance_data.len(),
267 )));
268 }
269 if let Some(ti) = temperature_index
270 && density_indices.contains(&ti)
271 {
272 return Err(FittingError::InvalidConfig(
273 "temperature_index must not overlap with density_indices".into(),
274 ));
275 }
276 if let Some(ref xs) = external_base_xs {
278 if xs.len() != resonance_data.len() {
279 return Err(FittingError::InvalidConfig(format!(
280 "external_base_xs has {} isotopes but resonance_data has {}",
281 xs.len(),
282 resonance_data.len(),
283 )));
284 }
285 for (i, row) in xs.iter().enumerate() {
286 if row.len() != energies.len() {
287 return Err(FittingError::InvalidConfig(format!(
288 "external_base_xs[{i}] has {} energies but expected {}",
289 row.len(),
290 energies.len(),
291 )));
292 }
293 }
294 }
295 let base_xs = match external_base_xs {
296 Some(xs) => Some(xs),
297 None if temperature_index.is_some() => Some(Arc::new(
298 transmission::unbroadened_cross_sections(&energies, &resonance_data, None)
299 .map_err(|e| {
300 FittingError::InvalidConfig(format!(
301 "failed to compute unbroadened cross-sections: {e}"
302 ))
303 })?,
304 )),
305 None => None,
306 };
307 Ok(Self {
308 energies,
309 resonance_data,
310 temperature_k,
311 instrument,
312 density_indices,
313 density_ratios,
314 temperature_index,
315 base_xs,
316 cached_broadened_xs: RefCell::new(None),
317 cached_dxs_dt: RefCell::new(None),
318 cached_temperature: Cell::new(f64::NAN),
319 })
320 }
321}
322
323impl FitModel for TransmissionFitModel {
324 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
325 debug_assert!(
326 self.density_indices.iter().all(|&i| i < params.len()),
327 "density_indices out of bounds for params (len={})",
328 params.len(),
329 );
330 debug_assert!(
331 self.temperature_index.is_none_or(|i| i < params.len()),
332 "temperature_index out of bounds for params (len={})",
333 params.len(),
334 );
335
336 let temperature_k = match self.temperature_index {
337 Some(idx) => params[idx],
338 None => self.temperature_k,
339 };
340
341 if let Some(ref base_xs) = self.base_xs {
342 if !temperature_k.is_finite() || temperature_k < 0.0 {
346 return Err(FittingError::EvaluationFailed(format!(
347 "Invalid temperature: {temperature_k} K (must be finite and non-negative)"
348 )));
349 }
350
351 let broadened_xs = if (temperature_k - self.cached_temperature.get()).abs() < 1e-15 {
360 Rc::clone(self.cached_broadened_xs.borrow().as_ref().unwrap())
361 } else {
362 let xs = Rc::new(
363 transmission::broadened_cross_sections_from_base(
364 &self.energies,
365 base_xs,
366 &self.resonance_data,
367 temperature_k,
368 self.instrument.as_deref(),
369 )
370 .map_err(|e| FittingError::EvaluationFailed(e.to_string()))?,
371 );
372 *self.cached_broadened_xs.borrow_mut() = Some(Rc::clone(&xs));
373 *self.cached_dxs_dt.borrow_mut() = None;
375 self.cached_temperature.set(temperature_k);
376 xs
377 };
378
379 let n_e = self.energies.len();
382 let mut neg_opt = vec![0.0f64; n_e];
383 for (i, xs) in broadened_xs.iter().enumerate() {
384 let density = params[self.density_indices[i]];
385 let ratio = self.density_ratios[i];
386 for (j, &sigma) in xs.iter().enumerate() {
387 neg_opt[j] -= density * ratio * sigma;
388 }
389 }
390 let transmission: Vec<f64> = neg_opt.iter().map(|&d| d.exp()).collect();
391
392 if let Some(ref inst) = self.instrument {
395 resolution::apply_resolution(&self.energies, &transmission, &inst.resolution)
396 .map_err(|e| {
397 FittingError::EvaluationFailed(format!("resolution broadening: {e}"))
398 })
399 } else {
400 Ok(transmission)
401 }
402 } else {
403 let isotopes: Vec<(ResonanceData, f64)> = self
406 .resonance_data
407 .iter()
408 .enumerate()
409 .map(|(i, rd)| {
410 (
411 rd.clone(),
412 params[self.density_indices[i]] * self.density_ratios[i],
413 )
414 })
415 .collect();
416
417 let sample = SampleParams::new(temperature_k, isotopes)
418 .map_err(|e| FittingError::EvaluationFailed(e.to_string()))?;
419
420 transmission::forward_model(&self.energies, &sample, self.instrument.as_deref())
421 .map_err(|e| FittingError::EvaluationFailed(e.to_string()))
422 }
423 }
424
425 fn analytical_jacobian(
453 &self,
454 params: &[f64],
455 free_param_indices: &[usize],
456 y_current: &[f64],
457 ) -> Option<FlatMatrix> {
458 let _base_xs_guard = self.base_xs.as_ref()?;
461 let cached_xs = self.cached_broadened_xs.borrow();
462 let broadened_xs = cached_xs.as_ref()?;
463
464 if let Some(ti) = self.temperature_index {
466 let param_temp = params[ti];
467 if (param_temp - self.cached_temperature.get()).abs() > 1e-15 {
468 return None;
469 }
470 }
471
472 let n_e = y_current.len();
473 let n_free = free_param_indices.len();
474 let mut jacobian = FlatMatrix::zeros(n_e, n_free);
475
476 let temp_col = self
477 .temperature_index
478 .and_then(|ti| free_param_indices.iter().position(|&fp| fp == ti));
479
480 let t_unresolved: Option<Vec<f64>> = if self.instrument.is_some() {
483 let mut neg_opt = vec![0.0f64; n_e];
484 for (iso, xs) in broadened_xs.iter().enumerate() {
485 let density = params[self.density_indices[iso]];
486 let ratio = self.density_ratios[iso];
487 for (j, &sigma) in xs.iter().enumerate() {
488 neg_opt[j] -= density * ratio * sigma;
489 }
490 }
491 Some(neg_opt.iter().map(|&d| d.exp()).collect())
492 } else {
493 None
494 };
495
496 let t_for_deriv: &[f64] = t_unresolved.as_deref().unwrap_or(y_current);
499
500 for (col, &fp_idx) in free_param_indices.iter().enumerate() {
502 if temp_col == Some(col) {
503 continue;
504 }
505 let mut sigma_sum = vec![0.0f64; n_e];
506 for (iso, &di) in self.density_indices.iter().enumerate() {
507 if di == fp_idx {
508 let ratio = self.density_ratios[iso];
509 for (j, &sigma) in broadened_xs[iso].iter().enumerate() {
510 sigma_sum[j] += ratio * sigma;
511 }
512 }
513 }
514 let inner: Vec<f64> = (0..n_e).map(|i| -sigma_sum[i] * t_for_deriv[i]).collect();
516
517 if let Some(ref inst) = self.instrument {
518 let resolved =
520 resolution::apply_resolution(&self.energies, &inner, &inst.resolution).ok()?;
521 for (i, &val) in resolved.iter().enumerate() {
522 *jacobian.get_mut(i, col) = val;
523 }
524 } else {
525 for (i, &val) in inner.iter().enumerate() {
526 *jacobian.get_mut(i, col) = val;
527 }
528 }
529 }
530
531 if let Some(col) = temp_col {
533 {
535 let needs_compute = self.cached_dxs_dt.borrow().as_ref().is_none();
536 if needs_compute {
537 let base_xs = self.base_xs.as_ref()?;
538 let temperature_k = self.cached_temperature.get();
539 let (_, dxs_vec) =
540 transmission::broadened_cross_sections_with_analytical_derivative_from_base(
541 &self.energies,
542 base_xs,
543 &self.resonance_data,
544 temperature_k,
545 self.instrument.as_deref(),
546 )
547 .ok()?;
548 *self.cached_dxs_dt.borrow_mut() = Some(Rc::new(dxs_vec));
549 }
550 }
551 let cached_dxs = self.cached_dxs_dt.borrow();
552 let dxs_dt = cached_dxs.as_ref()?;
553
554 let inner: Vec<f64> = (0..n_e)
556 .map(|i| {
557 let mut sum_n_dsigma = 0.0f64;
558 for (iso, dxs) in dxs_dt.iter().enumerate() {
559 let density = params[self.density_indices[iso]];
560 let ratio = self.density_ratios[iso];
561 sum_n_dsigma += density * ratio * dxs[i];
562 }
563 -t_for_deriv[i] * sum_n_dsigma
564 })
565 .collect();
566
567 if let Some(ref inst) = self.instrument {
568 let resolved =
569 resolution::apply_resolution(&self.energies, &inner, &inst.resolution).ok()?;
570 for (i, &val) in resolved.iter().enumerate() {
571 *jacobian.get_mut(i, col) = val;
572 }
573 } else {
574 for (i, &val) in inner.iter().enumerate() {
575 *jacobian.get_mut(i, col) = val;
576 }
577 }
578 }
579
580 Some(jacobian)
581 }
582}
583
584pub struct NormalizedTransmissionModel<M: FitModel> {
603 inner: M,
605 sqrt_energies: Vec<f64>,
607 inv_sqrt_energies: Vec<f64>,
609 anorm_index: usize,
611 back_a_index: usize,
613 back_b_index: usize,
615 back_c_index: usize,
617 back_d_index: Option<usize>,
620 back_f_index: Option<usize>,
623}
624
625impl<M: FitModel> NormalizedTransmissionModel<M> {
626 pub fn new(
636 inner: M,
637 energies: &[f64],
638 anorm_index: usize,
639 back_a_index: usize,
640 back_b_index: usize,
641 back_c_index: usize,
642 ) -> Self {
643 let sqrt_energies: Vec<f64> = energies.iter().map(|&e| e.sqrt()).collect();
644 let inv_sqrt_energies: Vec<f64> = sqrt_energies
645 .iter()
646 .map(|&se| if se > 0.0 { 1.0 / se } else { 0.0 })
647 .collect();
648 Self {
649 inner,
650 sqrt_energies,
651 inv_sqrt_energies,
652 anorm_index,
653 back_a_index,
654 back_b_index,
655 back_c_index,
656 back_d_index: None,
657 back_f_index: None,
658 }
659 }
660
661 #[allow(clippy::too_many_arguments)]
669 pub fn new_with_exponential(
670 inner: M,
671 energies: &[f64],
672 anorm_index: usize,
673 back_a_index: usize,
674 back_b_index: usize,
675 back_c_index: usize,
676 back_d_index: usize,
677 back_f_index: usize,
678 ) -> Self {
679 let sqrt_energies: Vec<f64> = energies.iter().map(|&e| e.sqrt()).collect();
680 let inv_sqrt_energies: Vec<f64> = sqrt_energies
681 .iter()
682 .map(|&se| if se > 0.0 { 1.0 / se } else { 0.0 })
683 .collect();
684 Self {
685 inner,
686 sqrt_energies,
687 inv_sqrt_energies,
688 anorm_index,
689 back_a_index,
690 back_b_index,
691 back_c_index,
692 back_d_index: Some(back_d_index),
693 back_f_index: Some(back_f_index),
694 }
695 }
696}
697
698impl<M: FitModel> FitModel for NormalizedTransmissionModel<M> {
699 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
700 let t_inner = self.inner.evaluate(params)?;
701 let anorm = params[self.anorm_index];
702 let back_a = params[self.back_a_index];
703 let back_b = params[self.back_b_index];
704 let back_c = params[self.back_c_index];
705
706 let (back_d, back_f) = match (self.back_d_index, self.back_f_index) {
708 (Some(di), Some(fi)) => (params[di], params[fi]),
709 _ => (0.0, 0.0),
710 };
711 let has_exp = self.back_d_index.is_some();
712
713 let result: Vec<f64> = t_inner
714 .iter()
715 .enumerate()
716 .map(|(i, &t)| {
717 let mut val = anorm * t
718 + back_a
719 + back_b * self.inv_sqrt_energies[i]
720 + back_c * self.sqrt_energies[i];
721 if has_exp {
722 val += back_d * (-back_f * self.inv_sqrt_energies[i]).exp();
723 }
724 val
725 })
726 .collect();
727 Ok(result)
728 }
729
730 fn analytical_jacobian(
742 &self,
743 params: &[f64],
744 free_param_indices: &[usize],
745 y_current: &[f64],
746 ) -> Option<FlatMatrix> {
747 let n_e = y_current.len();
748 let n_free = free_param_indices.len();
749
750 let t_inner = self.inner.evaluate(params).ok()?;
754
755 let anorm = params[self.anorm_index];
756
757 let mut bg_indices_set = vec![
759 self.anorm_index,
760 self.back_a_index,
761 self.back_b_index,
762 self.back_c_index,
763 ];
764 if let Some(di) = self.back_d_index {
765 bg_indices_set.push(di);
766 }
767 if let Some(fi) = self.back_f_index {
768 bg_indices_set.push(fi);
769 }
770
771 let inner_free_indices: Vec<usize> = free_param_indices
773 .iter()
774 .copied()
775 .filter(|idx| !bg_indices_set.contains(idx))
776 .collect();
777
778 let inner_jac = if !inner_free_indices.is_empty() {
781 self.inner
782 .analytical_jacobian(params, &inner_free_indices, &t_inner)
783 } else {
784 None
785 };
786
787 let exp_terms: Vec<f64> =
789 if let (Some(di), Some(fi)) = (self.back_d_index, self.back_f_index) {
790 let _back_d = params[di];
791 let back_f = params[fi];
792 self.inv_sqrt_energies
793 .iter()
794 .map(|&inv_se| (-back_f * inv_se).exp())
795 .collect()
796 } else {
797 vec![]
798 };
799
800 let mut jacobian = FlatMatrix::zeros(n_e, n_free);
801
802 let mut inner_col_map = std::collections::HashMap::new();
804 for (col, &idx) in inner_free_indices.iter().enumerate() {
805 inner_col_map.insert(idx, col);
806 }
807
808 for (col, &fp_idx) in free_param_indices.iter().enumerate() {
809 if fp_idx == self.anorm_index {
810 for (i, &ti) in t_inner.iter().enumerate() {
812 *jacobian.get_mut(i, col) = ti;
813 }
814 } else if fp_idx == self.back_a_index {
815 for i in 0..n_e {
817 *jacobian.get_mut(i, col) = 1.0;
818 }
819 } else if fp_idx == self.back_b_index {
820 for (i, &inv_se) in self.inv_sqrt_energies.iter().enumerate() {
822 *jacobian.get_mut(i, col) = inv_se;
823 }
824 } else if fp_idx == self.back_c_index {
825 for (i, &se) in self.sqrt_energies.iter().enumerate() {
827 *jacobian.get_mut(i, col) = se;
828 }
829 } else if self.back_d_index == Some(fp_idx) {
830 for (i, &et) in exp_terms.iter().enumerate() {
832 *jacobian.get_mut(i, col) = et;
833 }
834 } else if self.back_f_index == Some(fp_idx) {
835 let back_d = params[self.back_d_index.unwrap()];
837 for (i, (&et, &inv_se)) in exp_terms
838 .iter()
839 .zip(self.inv_sqrt_energies.iter())
840 .enumerate()
841 {
842 *jacobian.get_mut(i, col) = -back_d * et * inv_se;
843 }
844 } else if let Some(&inner_col) = inner_col_map.get(&fp_idx) {
845 if let Some(ref jac) = inner_jac {
847 for i in 0..n_e {
848 *jacobian.get_mut(i, col) = anorm * jac.get(i, inner_col);
849 }
850 } else {
851 return None;
854 }
855 } else {
856 return None;
858 }
859 }
860
861 Some(jacobian)
862 }
863}
864
865pub struct EnergyScaleTransmissionModel {
882 cross_sections: Arc<Vec<Vec<f64>>>,
884 density_indices: Arc<Vec<usize>>,
886 nominal_energies: Vec<f64>,
888 flight_path_m: f64,
890 tof_factor: f64,
892 t0_index: usize,
894 l_scale_index: usize,
896 instrument: Option<Arc<transmission::InstrumentParams>>,
898}
899
900impl EnergyScaleTransmissionModel {
901 #[allow(clippy::too_many_arguments)]
912 pub fn new(
913 cross_sections: Arc<Vec<Vec<f64>>>,
914 density_indices: Arc<Vec<usize>>,
915 nominal_energies: Vec<f64>,
916 flight_path_m: f64,
917 t0_index: usize,
918 l_scale_index: usize,
919 instrument: Option<Arc<transmission::InstrumentParams>>,
920 ) -> Self {
921 let tof_factor = (0.5 * 1.6749e-27 / 1.602e-19_f64).sqrt() * 1.0e6;
924 Self {
925 cross_sections,
926 density_indices,
927 nominal_energies,
928 flight_path_m,
929 tof_factor,
930 t0_index,
931 l_scale_index,
932 instrument,
933 }
934 }
935
936 fn corrected_energies(&self, t0_us: f64, l_scale: f64) -> Vec<f64> {
953 if self.nominal_energies.is_empty() {
954 return Vec::new();
955 }
956 let l_eff = self.flight_path_m * l_scale;
957 let min_tof = self
959 .nominal_energies
960 .iter()
961 .copied()
962 .fold(f64::INFINITY, |acc, e| {
963 acc.min(self.tof_factor * self.flight_path_m / e.sqrt())
964 });
965 let t0_limit = min_tof * (1.0 - 1.0e-12);
966 let t0_clamped = t0_us.min(t0_limit);
967 self.nominal_energies
968 .iter()
969 .map(|&e_nom| {
970 let tof = self.tof_factor * self.flight_path_m / e_nom.sqrt();
971 let tof_corr = tof - t0_clamped;
972 (self.tof_factor * l_eff / tof_corr).powi(2)
973 })
974 .collect()
975 }
976
977 fn interpolate_xs(nominal_e: &[f64], xs: &[f64], corrected_e: &[f64]) -> Vec<f64> {
979 corrected_e
980 .iter()
981 .map(|&e_corr| {
982 let n = nominal_e.len();
984 if e_corr <= nominal_e[0] {
985 return xs[0];
986 }
987 if e_corr >= nominal_e[n - 1] {
988 return xs[n - 1];
989 }
990 let pos = nominal_e.partition_point(|&e| e < e_corr);
991 let i = if pos == 0 { 0 } else { pos - 1 };
992 let frac = (e_corr - nominal_e[i]) / (nominal_e[i + 1] - nominal_e[i]);
993 xs[i] + frac * (xs[i + 1] - xs[i])
994 })
995 .collect()
996 }
997
998 fn evaluate_at(&self, params: &[f64], e_corr: &[f64]) -> Result<Vec<f64>, FittingError> {
1000 let n_e = self.nominal_energies.len();
1001
1002 let mut neg_opt = vec![0.0f64; n_e];
1004 for (i, xs) in self.cross_sections.iter().enumerate() {
1005 let density = params[self.density_indices[i]];
1006 let xs_interp = Self::interpolate_xs(&self.nominal_energies, xs, e_corr);
1007 for (j, &sigma) in xs_interp.iter().enumerate() {
1008 neg_opt[j] -= density * sigma;
1009 }
1010 }
1011 let transmission: Vec<f64> = neg_opt.iter().map(|&d| d.exp()).collect();
1012
1013 if let Some(inst) = &self.instrument {
1015 let t_broadened = resolution::apply_resolution(e_corr, &transmission, &inst.resolution)
1016 .map_err(|e| {
1017 FittingError::EvaluationFailed(format!("resolution broadening: {e}"))
1018 })?;
1019 Ok(t_broadened)
1020 } else {
1021 Ok(transmission)
1022 }
1023 }
1024}
1025
1026impl FitModel for EnergyScaleTransmissionModel {
1027 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1028 let t0 = params[self.t0_index];
1029 let l_scale = params[self.l_scale_index];
1030 let e_corr = self.corrected_energies(t0, l_scale);
1031 self.evaluate_at(params, &e_corr)
1032 }
1033
1034 fn analytical_jacobian(
1036 &self,
1037 params: &[f64],
1038 free_param_indices: &[usize],
1039 _y_current: &[f64],
1040 ) -> Option<FlatMatrix> {
1041 let n_e = self.nominal_energies.len();
1042 let n_free = free_param_indices.len();
1043 let mut jacobian = FlatMatrix::zeros(n_e, n_free);
1044
1045 let t0 = params[self.t0_index];
1046 let l_scale = params[self.l_scale_index];
1047 let e_corr = self.corrected_energies(t0, l_scale);
1048
1049 let mut neg_opt = vec![0.0f64; n_e];
1051 let mut interp_xs_all: Vec<Vec<f64>> = Vec::new();
1052 for (i, xs) in self.cross_sections.iter().enumerate() {
1053 let density = params[self.density_indices[i]];
1054 let xs_interp = Self::interpolate_xs(&self.nominal_energies, xs, &e_corr);
1055 for (j, &sigma) in xs_interp.iter().enumerate() {
1056 neg_opt[j] -= density * sigma;
1057 }
1058 interp_xs_all.push(xs_interp);
1059 }
1060 let t_unresolved: Vec<f64> = neg_opt.iter().map(|&d| d.exp()).collect();
1061
1062 for (col, &fp_idx) in free_param_indices.iter().enumerate() {
1063 if fp_idx == self.t0_index || fp_idx == self.l_scale_index {
1064 let h = if fp_idx == self.t0_index { 1e-4 } else { 1e-7 };
1066 let mut p_plus = params.to_vec();
1067 let mut p_minus = params.to_vec();
1068 p_plus[fp_idx] += h;
1069 p_minus[fp_idx] -= h;
1070 let y_plus = match self.evaluate(&p_plus) {
1071 Ok(v) => v,
1072 Err(_) => return None,
1073 };
1074 let y_minus = match self.evaluate(&p_minus) {
1075 Ok(v) => v,
1076 Err(_) => return None,
1077 };
1078 for i in 0..n_e {
1079 *jacobian.get_mut(i, col) = (y_plus[i] - y_minus[i]) / (2.0 * h);
1080 }
1081 } else {
1082 let mut sigma_sum = vec![0.0f64; n_e];
1085 for (iso, &di) in self.density_indices.iter().enumerate() {
1086 if di == fp_idx {
1087 for (j, &sigma) in interp_xs_all[iso].iter().enumerate() {
1088 sigma_sum[j] += sigma;
1089 }
1090 }
1091 }
1092 let inner_deriv: Vec<f64> =
1093 (0..n_e).map(|i| -sigma_sum[i] * t_unresolved[i]).collect();
1094
1095 if let Some(inst) = &self.instrument {
1097 let resolved_deriv =
1098 match resolution::apply_resolution(&e_corr, &inner_deriv, &inst.resolution)
1099 {
1100 Ok(v) => v,
1101 Err(_) => return None,
1102 };
1103 for (i, &val) in resolved_deriv.iter().enumerate() {
1104 *jacobian.get_mut(i, col) = val;
1105 }
1106 } else {
1107 for (i, &val) in inner_deriv.iter().enumerate() {
1108 *jacobian.get_mut(i, col) = val;
1109 }
1110 }
1111 }
1112 }
1113
1114 Some(jacobian)
1115 }
1116}
1117
1118use crate::forward_model::ForwardModel;
1124
1125impl ForwardModel for PrecomputedTransmissionModel {
1126 fn predict(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1127 self.evaluate(params)
1128 }
1129
1130 fn jacobian(
1131 &self,
1132 params: &[f64],
1133 free_param_indices: &[usize],
1134 y_current: &[f64],
1135 ) -> Option<Vec<Vec<f64>>> {
1136 let fm = self.analytical_jacobian(params, free_param_indices, y_current)?;
1137 Some(flat_matrix_to_vecs(&fm, free_param_indices.len()))
1138 }
1139
1140 fn n_data(&self) -> usize {
1141 if self.cross_sections.is_empty() {
1142 0
1143 } else {
1144 self.cross_sections[0].len()
1145 }
1146 }
1147
1148 fn n_params(&self) -> usize {
1149 self.density_indices
1151 .iter()
1152 .copied()
1153 .max()
1154 .map_or(0, |m| m + 1)
1155 }
1156}
1157
1158impl ForwardModel for TransmissionFitModel {
1159 fn predict(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1160 self.evaluate(params)
1161 }
1162
1163 fn jacobian(
1164 &self,
1165 params: &[f64],
1166 free_param_indices: &[usize],
1167 y_current: &[f64],
1168 ) -> Option<Vec<Vec<f64>>> {
1169 let fm = self.analytical_jacobian(params, free_param_indices, y_current)?;
1170 Some(flat_matrix_to_vecs(&fm, free_param_indices.len()))
1171 }
1172
1173 fn n_data(&self) -> usize {
1174 self.energies.len()
1175 }
1176
1177 fn n_params(&self) -> usize {
1178 let max_density = self.density_indices.iter().copied().max().unwrap_or(0);
1179 let max_temp = self.temperature_index.unwrap_or(0);
1180 max_density.max(max_temp) + 1
1181 }
1182}
1183
1184impl<M: FitModel> ForwardModel for NormalizedTransmissionModel<M> {
1185 fn predict(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1186 self.evaluate(params)
1187 }
1188
1189 fn jacobian(
1190 &self,
1191 params: &[f64],
1192 free_param_indices: &[usize],
1193 y_current: &[f64],
1194 ) -> Option<Vec<Vec<f64>>> {
1195 let fm = self.analytical_jacobian(params, free_param_indices, y_current)?;
1196 Some(flat_matrix_to_vecs(&fm, free_param_indices.len()))
1197 }
1198
1199 fn n_data(&self) -> usize {
1200 self.sqrt_energies.len()
1201 }
1202
1203 fn n_params(&self) -> usize {
1204 let mut max_idx = self
1206 .anorm_index
1207 .max(self.back_a_index)
1208 .max(self.back_b_index)
1209 .max(self.back_c_index);
1210 if let Some(di) = self.back_d_index {
1211 max_idx = max_idx.max(di);
1212 }
1213 if let Some(fi) = self.back_f_index {
1214 max_idx = max_idx.max(fi);
1215 }
1216 max_idx + 1
1217 }
1218}
1219
1220impl ForwardModel for EnergyScaleTransmissionModel {
1221 fn predict(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1222 self.evaluate(params)
1223 }
1224
1225 fn jacobian(
1226 &self,
1227 params: &[f64],
1228 free_param_indices: &[usize],
1229 y_current: &[f64],
1230 ) -> Option<Vec<Vec<f64>>> {
1231 let fm = self.analytical_jacobian(params, free_param_indices, y_current)?;
1232 Some(flat_matrix_to_vecs(&fm, free_param_indices.len()))
1233 }
1234
1235 fn n_data(&self) -> usize {
1236 self.nominal_energies.len()
1237 }
1238
1239 fn n_params(&self) -> usize {
1240 self.t0_index.max(self.l_scale_index) + 1
1241 }
1242}
1243
1244fn flat_matrix_to_vecs(fm: &FlatMatrix, cols: usize) -> Vec<Vec<f64>> {
1248 let nrows = fm.nrows;
1249 (0..cols)
1250 .map(|j| (0..nrows).map(|i| fm.get(i, j)).collect())
1251 .collect()
1252}
1253
1254#[cfg(test)]
1255mod tests {
1256 use super::*;
1257 use crate::lm::{self, FitModel, LmConfig};
1258 use crate::parameters::{FitParameter, ParameterSet};
1259 use nereids_core::types::Isotope;
1260 use nereids_endf::resonance::{LGroup, Resonance, ResonanceFormalism, ResonanceRange};
1261
1262 #[test]
1266 fn precomputed_evaluate_matches_beer_lambert() {
1267 let model = make_precomputed(
1268 vec![
1269 vec![1.0, 2.0, 3.0], vec![0.5, 0.5, 0.5], ],
1272 vec![0, 1],
1273 );
1274
1275 let params = [0.2f64, 0.4f64];
1276 let y = model.evaluate(¶ms).unwrap();
1277
1278 let expected: Vec<f64> = (0..3)
1279 .map(|i| {
1280 let s0 = [1.0, 2.0, 3.0][i];
1281 let s1 = [0.5, 0.5, 0.5][i];
1282 (-params[0] * s0 - params[1] * s1).exp()
1283 })
1284 .collect();
1285
1286 assert_eq!(y.len(), 3);
1287 for (yi, ei) in y.iter().zip(expected.iter()) {
1288 assert!(
1289 (yi - ei).abs() < 1e-12,
1290 "evaluate mismatch: got {yi}, expected {ei}"
1291 );
1292 }
1293 }
1294
1295 #[test]
1297 fn precomputed_analytical_jacobian_matches_finite_difference() {
1298 let model = make_precomputed(
1299 vec![
1300 vec![1.0, 2.0, 3.0], vec![0.5, 0.5, 0.5], ],
1303 vec![0, 1],
1304 );
1305
1306 let params = [0.2f64, 0.4f64];
1307 let y = model.evaluate(¶ms).unwrap();
1308 let free = vec![0usize, 1usize];
1309
1310 let jac = model
1311 .analytical_jacobian(¶ms, &free, &y)
1312 .expect("analytical_jacobian should return Some(_)");
1313
1314 assert_eq!(jac.nrows, 3); assert_eq!(jac.ncols, 2); let h = 1e-6f64;
1319 for (col, &p_idx) in free.iter().enumerate() {
1320 let mut p_plus = params;
1321 let mut p_minus = params;
1322 p_plus[p_idx] += h;
1323 p_minus[p_idx] -= h;
1324
1325 let y_plus = model.evaluate(&p_plus).unwrap();
1326 let y_minus = model.evaluate(&p_minus).unwrap();
1327
1328 for i in 0..3 {
1329 let fd = (y_plus[i] - y_minus[i]) / (2.0 * h);
1330 let ana = jac.get(i, col);
1331 assert!(
1332 (fd - ana).abs() < 1e-6,
1333 "Jacobian mismatch (row {i}, col {col}): FD={fd:.8}, analytical={ana:.8}"
1334 );
1335 }
1336 }
1337 }
1338
1339 #[test]
1342 fn precomputed_jacobian_tied_parameters_sums_both_isotopes() {
1343 let model = make_precomputed(
1345 vec![
1346 vec![1.0, 2.0, 3.0], vec![0.5, 1.0, 1.5], ],
1349 vec![0, 0], );
1351
1352 let params = [0.1f64];
1353 let y = model.evaluate(¶ms).unwrap();
1354 let free = vec![0usize];
1355
1356 let jac = model
1357 .analytical_jacobian(¶ms, &free, &y)
1358 .expect("analytical_jacobian should return Some(_)");
1359
1360 for i in 0..3 {
1362 let sigma_sum = [1.0, 2.0, 3.0][i] + [0.5, 1.0, 1.5][i];
1363 let expected = -y[i] * sigma_sum;
1364 assert!(
1365 (jac.get(i, 0) - expected).abs() < 1e-12,
1366 "Tied Jacobian mismatch at E[{i}]: got {}, expected {expected}",
1367 jac.get(i, 0)
1368 );
1369 }
1370 }
1371
1372 fn u238_single_resonance() -> ResonanceData {
1375 ResonanceData {
1376 isotope: Isotope::new(92, 238).unwrap(),
1377 za: 92238,
1378 awr: 236.006,
1379 ranges: vec![ResonanceRange {
1380 energy_low: 1e-5,
1381 energy_high: 1e4,
1382 resolved: true,
1383 formalism: ResonanceFormalism::ReichMoore,
1384 target_spin: 0.0,
1385 scattering_radius: 9.4285,
1386 naps: 1,
1387 l_groups: vec![LGroup {
1388 l: 0,
1389 awr: 236.006,
1390 apl: 0.0,
1391 qx: 0.0,
1392 lrx: 0,
1393 resonances: vec![Resonance {
1394 energy: 6.674,
1395 j: 0.5,
1396 gn: 1.493e-3,
1397 gg: 23.0e-3,
1398 gfa: 0.0,
1399 gfb: 0.0,
1400 }],
1401 }],
1402 rml: None,
1403 urr: None,
1404 ap_table: None,
1405 r_external: vec![],
1406 }],
1407 }
1408 }
1409
1410 #[test]
1411 fn test_recover_single_isotope_thickness() {
1412 let data = u238_single_resonance();
1413 let true_thickness = 0.0005;
1414
1415 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
1417
1418 let model = TransmissionFitModel::new(
1419 energies.clone(),
1420 vec![data],
1421 0.0,
1422 None,
1423 (vec![0], vec![1.0]),
1424 None,
1425 None,
1426 )
1427 .unwrap();
1428
1429 let y_obs = model.evaluate(&[true_thickness]).unwrap();
1430 let sigma = vec![0.01; y_obs.len()]; let mut params = ParameterSet::new(vec![
1433 FitParameter::non_negative("thickness", 0.001), ]);
1435
1436 let result =
1437 lm::levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default())
1438 .unwrap();
1439
1440 assert!(result.converged, "Fit did not converge");
1441 let fitted = result.params[0];
1442 assert!(
1443 (fitted - true_thickness).abs() / true_thickness < 0.01,
1444 "Fitted thickness = {}, true = {}, error = {:.1}%",
1445 fitted,
1446 true_thickness,
1447 (fitted - true_thickness).abs() / true_thickness * 100.0,
1448 );
1449 }
1450
1451 #[test]
1452 fn test_recover_two_isotope_thicknesses() {
1453 let u238 = u238_single_resonance();
1454
1455 let other = ResonanceData {
1457 isotope: Isotope::new(1, 10).unwrap(),
1458 za: 1010,
1459 awr: 10.0,
1460 ranges: vec![ResonanceRange {
1461 energy_low: 0.0,
1462 energy_high: 100.0,
1463 resolved: true,
1464 formalism: ResonanceFormalism::ReichMoore,
1465 target_spin: 0.0,
1466 scattering_radius: 5.0,
1467 naps: 1,
1468 l_groups: vec![LGroup {
1469 l: 0,
1470 awr: 10.0,
1471 apl: 5.0,
1472 qx: 0.0,
1473 lrx: 0,
1474 resonances: vec![Resonance {
1475 energy: 20.0,
1476 j: 0.5,
1477 gn: 0.1,
1478 gg: 0.05,
1479 gfa: 0.0,
1480 gfb: 0.0,
1481 }],
1482 }],
1483 rml: None,
1484 urr: None,
1485 ap_table: None,
1486 r_external: vec![],
1487 }],
1488 };
1489
1490 let true_t1 = 0.0003;
1491 let true_t2 = 0.0001;
1492
1493 let energies: Vec<f64> = (0..301).map(|i| 1.0 + (i as f64) * 0.1).collect();
1494
1495 let model = TransmissionFitModel::new(
1496 energies.clone(),
1497 vec![u238, other],
1498 0.0,
1499 None,
1500 (vec![0, 1], vec![1.0, 1.0]),
1501 None,
1502 None,
1503 )
1504 .unwrap();
1505
1506 let y_obs = model.evaluate(&[true_t1, true_t2]).unwrap();
1507 let sigma = vec![0.01; y_obs.len()];
1508
1509 let mut params = ParameterSet::new(vec![
1510 FitParameter::non_negative("U-238 thickness", 0.001),
1511 FitParameter::non_negative("Other thickness", 0.001),
1512 ]);
1513
1514 let result =
1515 lm::levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default())
1516 .unwrap();
1517
1518 assert!(
1519 result.converged,
1520 "Fit did not converge after {} iterations",
1521 result.iterations
1522 );
1523
1524 let (fit_t1, fit_t2) = (result.params[0], result.params[1]);
1525 assert!(
1526 (fit_t1 - true_t1).abs() / true_t1 < 0.05,
1527 "U-238: fitted={}, true={}, error={:.1}%",
1528 fit_t1,
1529 true_t1,
1530 (fit_t1 - true_t1).abs() / true_t1 * 100.0,
1531 );
1532 assert!(
1533 (fit_t2 - true_t2).abs() / true_t2 < 0.05,
1534 "Other: fitted={}, true={}, error={:.1}%",
1535 fit_t2,
1536 true_t2,
1537 (fit_t2 - true_t2).abs() / true_t2 * 100.0,
1538 );
1539 }
1540
1541 #[test]
1546 fn temperature_index_overrides_fixed_temperature() {
1547 let data = u238_single_resonance();
1548 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
1549
1550 let model = TransmissionFitModel::new(
1553 energies.clone(),
1554 vec![data.clone()],
1555 0.0,
1556 None,
1557 (vec![0], vec![1.0]),
1558 Some(1),
1559 None,
1560 )
1561 .unwrap();
1562
1563 let model_fixed = TransmissionFitModel::new(
1565 energies.clone(),
1566 vec![data],
1567 300.0,
1568 None,
1569 (vec![0], vec![1.0]),
1570 None,
1571 None,
1572 )
1573 .unwrap();
1574
1575 let density = 0.0005;
1576 let y_via_index = model.evaluate(&[density, 300.0]).unwrap();
1577 let y_via_fixed = model_fixed.evaluate(&[density]).unwrap();
1578
1579 for (a, b) in y_via_index.iter().zip(y_via_fixed.iter()) {
1580 assert!(
1581 (a - b).abs() < 1e-12,
1582 "temperature_index path disagrees with fixed path: {} vs {}",
1583 a,
1584 b
1585 );
1586 }
1587 }
1588
1589 #[test]
1594 fn test_recover_temperature() {
1595 let data = u238_single_resonance();
1596 let true_density = 0.0005;
1597 let true_temp = 300.0; let energies: Vec<f64> = (0..401).map(|i| 4.0 + (i as f64) * 0.025).collect();
1601
1602 let model = TransmissionFitModel::new(
1604 energies.clone(),
1605 vec![data],
1606 0.0, None,
1608 (vec![0], vec![1.0]),
1609 Some(1), None,
1611 )
1612 .unwrap();
1613
1614 let mut y_obs = model.evaluate(&[true_density, true_temp]).unwrap();
1615 for (i, y) in y_obs.iter_mut().enumerate() {
1619 *y *= 1.0 + 1e-5 * ((i % 7) as f64 - 3.0);
1620 }
1621 let sigma = vec![0.005; y_obs.len()];
1622
1623 let mut params = ParameterSet::new(vec![
1625 FitParameter::non_negative("density", 0.001),
1626 FitParameter {
1627 name: "temperature_k".into(),
1628 value: 200.0, lower: 1.0,
1630 upper: 2000.0,
1631 fixed: false,
1632 },
1633 ]);
1634
1635 let config = LmConfig {
1636 max_iter: 200,
1637 ..LmConfig::default()
1638 };
1639
1640 let result = lm::levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &config).unwrap();
1641
1642 assert!(
1643 result.converged,
1644 "Temperature fit did not converge after {} iterations",
1645 result.iterations
1646 );
1647
1648 let fit_density = result.params[0];
1649 let fit_temp = result.params[1];
1650
1651 assert!(
1653 (fit_density - true_density).abs() / true_density < 0.001,
1654 "Density: fitted={}, true={}, error={:.1}%",
1655 fit_density,
1656 true_density,
1657 (fit_density - true_density).abs() / true_density * 100.0,
1658 );
1659 assert!(
1660 (fit_temp - true_temp).abs() / true_temp < 0.001,
1661 "Temperature: fitted={:.1} K, true={:.1} K, error={:.1}%",
1662 fit_temp,
1663 true_temp,
1664 (fit_temp - true_temp).abs() / true_temp * 100.0,
1665 );
1666
1667 let unc = result
1669 .uncertainties
1670 .expect("uncertainties should be available");
1671 assert!(
1672 unc.len() == 2,
1673 "expected 2 uncertainties, got {}",
1674 unc.len()
1675 );
1676 assert!(
1677 unc[1] > 0.0 && unc[1].is_finite(),
1678 "temperature uncertainty should be positive and finite, got {}",
1679 unc[1]
1680 );
1681 }
1682
1683 #[test]
1689 fn transmission_fit_model_analytical_jacobian_matches_fd() {
1690 let data = u238_single_resonance();
1691 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
1692
1693 let model = TransmissionFitModel::new(
1694 energies,
1695 vec![data],
1696 0.0,
1697 None,
1698 (vec![0], vec![1.0]),
1699 Some(1), None,
1701 )
1702 .unwrap();
1703
1704 let params = [0.0005f64, 300.0f64]; let y = model.evaluate(¶ms).unwrap();
1706 let free = vec![0usize, 1usize];
1707
1708 let jac = model
1709 .analytical_jacobian(¶ms, &free, &y)
1710 .expect("analytical_jacobian should return Some(_)");
1711
1712 assert_eq!(jac.nrows, y.len());
1713 assert_eq!(jac.ncols, 2);
1714
1715 let h = 1e-6f64;
1717 for (col, &p_idx) in free.iter().enumerate() {
1718 let mut p_plus = params;
1719 let mut p_minus = params;
1720 p_plus[p_idx] += h * (1.0 + params[p_idx].abs());
1721 p_minus[p_idx] -= h * (1.0 + params[p_idx].abs());
1722
1723 let y_plus = model.evaluate(&p_plus).unwrap();
1724 let y_minus = model.evaluate(&p_minus).unwrap();
1725
1726 let actual_2h = p_plus[p_idx] - p_minus[p_idx];
1727 for i in 0..y.len() {
1728 let fd = (y_plus[i] - y_minus[i]) / actual_2h;
1729 let ana = jac.get(i, col);
1730 let err = (fd - ana).abs();
1731 let scale = fd.abs().max(ana.abs()).max(1e-10);
1741 assert!(
1742 err / scale < 0.01,
1743 "Jacobian mismatch (row {i}, col {col}): FD={fd:.8}, analytical={ana:.8}, \
1744 rel_err={:.4}",
1745 err / scale,
1746 );
1747 }
1748 }
1749 }
1750
1751 #[test]
1755 fn transmission_fit_model_cache_reuse() {
1756 let data = u238_single_resonance();
1757 let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
1758
1759 let model = TransmissionFitModel::new(
1760 energies,
1761 vec![data],
1762 0.0,
1763 None,
1764 (vec![0], vec![1.0]),
1765 Some(1),
1766 None,
1767 )
1768 .unwrap();
1769
1770 let y1 = model.evaluate(&[0.0005, 300.0]).unwrap();
1772 assert!(model.cached_broadened_xs.borrow().is_some());
1773 assert!((model.cached_temperature.get() - 300.0).abs() < 1e-15);
1774
1775 let y2 = model.evaluate(&[0.001, 300.0]).unwrap();
1778 assert!((model.cached_temperature.get() - 300.0).abs() < 1e-15);
1779
1780 assert!(
1782 (y1[100] - y2[100]).abs() > 1e-10,
1783 "different densities should produce different transmission"
1784 );
1785
1786 let _y3 = model.evaluate(&[0.0005, 600.0]).unwrap();
1788 assert!((model.cached_temperature.get() - 600.0).abs() < 1e-15);
1789 }
1790
1791 fn make_precomputed(
1796 xs: Vec<Vec<f64>>,
1797 density_indices: Vec<usize>,
1798 ) -> PrecomputedTransmissionModel {
1799 PrecomputedTransmissionModel {
1800 cross_sections: Arc::new(xs),
1801 density_indices: Arc::new(density_indices),
1802 energies: None,
1803 instrument: None,
1804 }
1805 }
1806
1807 #[test]
1810 fn normalized_identity_matches_inner() {
1811 let xs = vec![
1812 vec![1.0, 2.0, 3.0], vec![0.5, 0.5, 0.5], ];
1815 let inner_ref = make_precomputed(xs.clone(), vec![0, 1]);
1816 let inner_wrap = make_precomputed(xs, vec![0, 1]);
1817
1818 let energies = [4.0, 9.0, 16.0];
1819 let model = NormalizedTransmissionModel::new(inner_wrap, &energies, 2, 3, 4, 5);
1821
1822 let params = [0.2, 0.4, 1.0, 0.0, 0.0, 0.0];
1823 let y_norm = model.evaluate(¶ms).unwrap();
1824 let y_inner = inner_ref.evaluate(¶ms).unwrap();
1825
1826 for (a, b) in y_norm.iter().zip(y_inner.iter()) {
1827 assert!(
1828 (a - b).abs() < 1e-12,
1829 "identity normalization should match inner: {} vs {}",
1830 a,
1831 b
1832 );
1833 }
1834 }
1835
1836 #[test]
1839 fn normalized_formula_correct() {
1840 let xs = vec![vec![1.0, 2.0, 3.0]];
1841 let inner_ref = make_precomputed(xs.clone(), vec![0]);
1842 let inner_wrap = make_precomputed(xs, vec![0]);
1843
1844 let energies = [4.0, 9.0, 16.0]; let model = NormalizedTransmissionModel::new(inner_wrap, &energies, 1, 2, 3, 4);
1846
1847 let anorm = 0.95;
1849 let back_a = 0.01;
1850 let back_b = 0.02;
1851 let back_c = 0.005;
1852 let density = 0.3;
1853 let params = [density, anorm, back_a, back_b, back_c];
1854
1855 let y = model.evaluate(¶ms).unwrap();
1856 let t_inner = inner_ref.evaluate(¶ms).unwrap();
1857
1858 for (i, (&yi, &ti)) in y.iter().zip(t_inner.iter()).enumerate() {
1859 let sqrt_e = energies[i].sqrt();
1860 let expected = anorm * ti + back_a + back_b / sqrt_e + back_c * sqrt_e;
1861 assert!(
1862 (yi - expected).abs() < 1e-12,
1863 "E[{i}]: got {yi}, expected {expected}"
1864 );
1865 }
1866 }
1867
1868 #[test]
1871 fn normalized_analytical_jacobian_matches_fd() {
1872 let xs = vec![
1873 vec![1.0, 2.0, 3.0], vec![0.5, 0.5, 0.5], ];
1876 let inner = make_precomputed(xs, vec![0, 1]);
1877
1878 let energies = [4.0, 9.0, 16.0];
1879 let model = NormalizedTransmissionModel::new(inner, &energies, 2, 3, 4, 5);
1881
1882 let params = [0.2, 0.4, 0.95, 0.01, 0.02, 0.005];
1883 let y = model.evaluate(¶ms).unwrap();
1884 let free: Vec<usize> = (0..6).collect();
1885
1886 let jac = model
1887 .analytical_jacobian(¶ms, &free, &y)
1888 .expect("analytical_jacobian should return Some");
1889
1890 assert_eq!(jac.nrows, 3);
1891 assert_eq!(jac.ncols, 6);
1892
1893 let h = 1e-7;
1895 for (col, &p_idx) in free.iter().enumerate() {
1896 let mut p_plus = params;
1897 let mut p_minus = params;
1898 p_plus[p_idx] += h;
1899 p_minus[p_idx] -= h;
1900
1901 let y_plus = model.evaluate(&p_plus).unwrap();
1902 let y_minus = model.evaluate(&p_minus).unwrap();
1903
1904 for i in 0..3 {
1905 let fd = (y_plus[i] - y_minus[i]) / (2.0 * h);
1906 let ana = jac.get(i, col);
1907 let err = (fd - ana).abs();
1908 let scale = fd.abs().max(ana.abs()).max(1e-10);
1909 assert!(
1910 err / scale < 1e-4,
1911 "Jacobian mismatch (row {i}, col {col}): FD={fd:.8}, analytical={ana:.8}, \
1912 rel_err={:.6}",
1913 err / scale,
1914 );
1915 }
1916 }
1917 }
1918
1919 #[test]
1922 fn normalized_jacobian_partial_free() {
1923 let xs = vec![vec![1.0, 2.0, 3.0]];
1924 let inner = make_precomputed(xs, vec![0]);
1925
1926 let energies = [4.0, 9.0, 16.0];
1927 let model = NormalizedTransmissionModel::new(inner, &energies, 1, 2, 3, 4);
1928
1929 let params = [0.3, 0.95, 0.01, 0.0, 0.0];
1931 let y = model.evaluate(¶ms).unwrap();
1932 let free = vec![0usize, 1usize];
1934
1935 let jac = model
1936 .analytical_jacobian(¶ms, &free, &y)
1937 .expect("should return Some for partial free");
1938
1939 assert_eq!(jac.nrows, 3);
1940 assert_eq!(jac.ncols, 2);
1941
1942 let h = 1e-7;
1944 for (col, &p_idx) in free.iter().enumerate() {
1945 let mut p_plus = params;
1946 let mut p_minus = params;
1947 p_plus[p_idx] += h;
1948 p_minus[p_idx] -= h;
1949
1950 let y_plus = model.evaluate(&p_plus).unwrap();
1951 let y_minus = model.evaluate(&p_minus).unwrap();
1952
1953 for i in 0..3 {
1954 let fd = (y_plus[i] - y_minus[i]) / (2.0 * h);
1955 let ana = jac.get(i, col);
1956 let err = (fd - ana).abs();
1957 let scale = fd.abs().max(ana.abs()).max(1e-10);
1958 assert!(
1959 err / scale < 1e-4,
1960 "Jacobian mismatch (row {i}, col {col}): FD={fd:.8}, analytical={ana:.8}"
1961 );
1962 }
1963 }
1964 }
1965
1966 #[test]
1968 fn normalized_fit_recovers_anorm_and_backa() {
1969 let xs = vec![vec![1.0, 2.0, 3.0, 2.0, 1.5]];
1970 let inner = make_precomputed(xs, vec![0]);
1971
1972 let energies = [4.0, 9.0, 16.0, 25.0, 36.0];
1973 let model = NormalizedTransmissionModel::new(inner, &energies, 1, 2, 3, 4);
1974
1975 let true_density = 0.2;
1977 let true_anorm = 0.95;
1978 let true_back_a = 0.02;
1979 let true_params = [true_density, true_anorm, true_back_a, 0.0, 0.0];
1980
1981 let y_obs = model.evaluate(&true_params).unwrap();
1982 let sigma = vec![0.001; y_obs.len()];
1983
1984 let mut params = ParameterSet::new(vec![
1986 FitParameter::non_negative("density", 0.1),
1987 FitParameter {
1988 name: "anorm".into(),
1989 value: 1.0,
1990 lower: 0.5,
1991 upper: 1.5,
1992 fixed: false,
1993 },
1994 FitParameter::unbounded("back_a", 0.0),
1995 FitParameter::fixed("back_b", 0.0),
1996 FitParameter::fixed("back_c", 0.0),
1997 ]);
1998
1999 let config = LmConfig {
2000 max_iter: 200,
2001 ..LmConfig::default()
2002 };
2003
2004 let result = lm::levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &config).unwrap();
2005
2006 assert!(result.converged, "Fit should converge");
2007
2008 let fit_density = result.params[0];
2009 let fit_anorm = result.params[1];
2010 let fit_back_a = result.params[2];
2011
2012 assert!(
2013 (fit_density - true_density).abs() / true_density < 0.01,
2014 "density: fitted={fit_density}, true={true_density}"
2015 );
2016 assert!(
2017 (fit_anorm - true_anorm).abs() / true_anorm < 0.01,
2018 "anorm: fitted={fit_anorm}, true={true_anorm}"
2019 );
2020 assert!(
2021 (fit_back_a - true_back_a).abs() < 0.001,
2022 "back_a: fitted={fit_back_a}, true={true_back_a}"
2023 );
2024 }
2025
2026 #[test]
2029 fn forward_model_predict_equals_fit_model_evaluate_precomputed() {
2030 use crate::forward_model::ForwardModel;
2031 let xs = vec![vec![1.0, 2.0, 3.0, 2.0, 1.5]];
2032 let model = make_precomputed(xs, vec![0]);
2033 let params = [0.001];
2034 let fm_result = model.evaluate(¶ms).unwrap();
2035 let fwd_result = model.predict(¶ms).unwrap();
2036 assert_eq!(fm_result, fwd_result);
2037 assert_eq!(model.n_data(), 5);
2038 assert_eq!(model.n_params(), 1);
2039 }
2040
2041 #[test]
2042 fn forward_model_predict_equals_fit_model_evaluate_normalized() {
2043 use crate::forward_model::ForwardModel;
2044 let xs = vec![vec![1.0, 2.0, 3.0, 2.0, 1.5]];
2045 let inner = make_precomputed(xs, vec![0]);
2046 let energies = [4.0, 9.0, 16.0, 25.0, 36.0];
2047 let model = NormalizedTransmissionModel::new(inner, &energies, 1, 2, 3, 4);
2048 let params = [0.001, 0.95, 0.01, 0.0, 0.0];
2049 let fm_result = model.evaluate(¶ms).unwrap();
2050 let fwd_result = model.predict(¶ms).unwrap();
2051 assert_eq!(fm_result, fwd_result);
2052 assert_eq!(model.n_data(), 5);
2053 assert_eq!(model.n_params(), 5);
2054 }
2055
2056 #[test]
2057 fn forward_model_jacobian_columns_match_precomputed() {
2058 use crate::forward_model::ForwardModel;
2059 let xs = vec![vec![1.0, 2.0, 3.0], vec![0.5, 1.5, 2.5]];
2060 let model = make_precomputed(xs, vec![0, 1]);
2061 let params = [0.001, 0.002];
2062 let y = model.predict(¶ms).unwrap();
2063 let free_indices = vec![0, 1];
2064 let jac = model
2065 .jacobian(¶ms, &free_indices, &y)
2066 .expect("analytical jacobian should be available");
2067 assert_eq!(jac.len(), 2); assert_eq!(jac[0].len(), 3); }
2070
2071 #[test]
2076 fn precomputed_with_resolution_matches_forward_model() {
2077 use nereids_physics::resolution::ResolutionFunction;
2078
2079 let data = u238_single_resonance();
2080 let thickness = 0.0005;
2081 let temperature = 300.0;
2082 let energies: Vec<f64> = (0..401).map(|i| 4.0 + (i as f64) * 0.015).collect();
2083
2084 let inst = Arc::new(InstrumentParams {
2085 resolution: ResolutionFunction::Gaussian(
2086 nereids_physics::resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2087 ),
2088 });
2089
2090 let sample = SampleParams::new(temperature, vec![(data.clone(), thickness)]).unwrap();
2092 let t_forward = transmission::forward_model(&energies, &sample, Some(&inst)).unwrap();
2093
2094 let xs = transmission::broadened_cross_sections(
2096 &energies,
2097 std::slice::from_ref(&data),
2098 temperature,
2099 Some(&inst), None,
2101 )
2102 .unwrap();
2103 let model = PrecomputedTransmissionModel {
2104 cross_sections: Arc::new(xs),
2105 density_indices: Arc::new(vec![0]),
2106 energies: Some(Arc::new(energies.clone())),
2107 instrument: Some(Arc::clone(&inst)),
2108 };
2109 let t_precomputed = model.evaluate(&[thickness]).unwrap();
2110
2111 let interior = 20..energies.len() - 20;
2115 let mut max_err = 0.0f64;
2116 for i in interior {
2117 let err = (t_forward[i] - t_precomputed[i]).abs();
2118 max_err = max_err.max(err);
2119 }
2120 assert!(
2121 max_err < 0.02,
2122 "PrecomputedTransmissionModel with resolution should match \
2123 forward_model. Max error = {max_err}"
2124 );
2125 }
2126
2127 #[test]
2130 fn precomputed_without_resolution_unchanged() {
2131 let model_no_res = make_precomputed(
2132 vec![vec![100.0, 200.0, 50.0]], vec![0],
2134 );
2135 let params = [0.001f64]; let t = model_no_res.evaluate(¶ms).unwrap();
2137
2138 let expected: Vec<f64> = [100.0, 200.0, 50.0]
2140 .iter()
2141 .map(|&sigma| (-params[0] * sigma).exp())
2142 .collect();
2143
2144 for (i, (&ti, &ei)) in t.iter().zip(expected.iter()).enumerate() {
2145 assert!(
2146 (ti - ei).abs() < 1e-14,
2147 "No-resolution mismatch at bin {i}: got {ti}, expected {ei}"
2148 );
2149 }
2150
2151 let y = model_no_res.evaluate(¶ms).unwrap();
2153 assert!(
2154 model_no_res
2155 .analytical_jacobian(¶ms, &[0], &y)
2156 .is_some(),
2157 "Analytical Jacobian must be available when instrument is None"
2158 );
2159 }
2160
2161 #[test]
2164 fn precomputed_jacobian_with_resolution_matches_fd() {
2165 use nereids_physics::resolution::ResolutionFunction;
2166
2167 let data = u238_single_resonance();
2168 let temperature = 300.0;
2169 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
2170 let inst = Arc::new(InstrumentParams {
2171 resolution: ResolutionFunction::Gaussian(
2172 nereids_physics::resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2173 ),
2174 });
2175
2176 let xs = transmission::broadened_cross_sections(
2177 &energies,
2178 std::slice::from_ref(&data),
2179 temperature,
2180 Some(&inst),
2181 None,
2182 )
2183 .unwrap();
2184 let model = PrecomputedTransmissionModel {
2185 cross_sections: Arc::new(xs),
2186 density_indices: Arc::new(vec![0]),
2187 energies: Some(Arc::new(energies.clone())),
2188 instrument: Some(Arc::clone(&inst)),
2189 };
2190
2191 let params = [0.0005f64];
2192 let y = model.evaluate(¶ms).unwrap();
2193
2194 let jac = model
2195 .analytical_jacobian(¶ms, &[0], &y)
2196 .expect("analytical Jacobian must be available with resolution");
2197
2198 let h = 1e-7;
2200 let y_plus = model.evaluate(&[params[0] + h]).unwrap();
2201 let y_minus = model.evaluate(&[params[0] - h]).unwrap();
2202
2203 let interior = 20..energies.len() - 20;
2204 let mut max_rel_err = 0.0f64;
2205 for i in interior {
2206 let fd = (y_plus[i] - y_minus[i]) / (2.0 * h);
2207 let ana = jac.get(i, 0);
2208 let denom = fd.abs().max(ana.abs()).max(1e-30);
2209 max_rel_err = max_rel_err.max((ana - fd).abs() / denom);
2210 }
2211 assert!(
2212 max_rel_err < 0.01,
2213 "PrecomputedTM analytical Jacobian with resolution vs FD: \
2214 max relative error = {max_rel_err}"
2215 );
2216 }
2217
2218 #[test]
2221 fn precomputed_jacobian_grouped_with_resolution_matches_fd() {
2222 use nereids_physics::resolution::ResolutionFunction;
2223
2224 let energies: Vec<f64> = (0..100).map(|i| 1.0 + i as f64 * 0.1).collect();
2225 let inst = Arc::new(InstrumentParams {
2226 resolution: ResolutionFunction::Gaussian(
2227 nereids_physics::resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2228 ),
2229 });
2230 let xs = vec![vec![10.0; 100], vec![5.0; 100]];
2232 let model = PrecomputedTransmissionModel {
2233 cross_sections: Arc::new(xs),
2234 density_indices: Arc::new(vec![0, 0]), energies: Some(Arc::new(energies.clone())),
2236 instrument: Some(Arc::clone(&inst)),
2237 };
2238
2239 let params = [0.001f64];
2240 let y = model.evaluate(¶ms).unwrap();
2241 let jac = model
2242 .analytical_jacobian(¶ms, &[0], &y)
2243 .expect("analytical Jacobian must be available");
2244
2245 let h = 1e-7;
2246 let y_plus = model.evaluate(&[params[0] + h]).unwrap();
2247 let y_minus = model.evaluate(&[params[0] - h]).unwrap();
2248
2249 let mut max_rel_err = 0.0f64;
2250 for i in 10..energies.len() - 10 {
2251 let fd = (y_plus[i] - y_minus[i]) / (2.0 * h);
2252 let ana = jac.get(i, 0);
2253 let denom = fd.abs().max(ana.abs()).max(1e-30);
2254 max_rel_err = max_rel_err.max((ana - fd).abs() / denom);
2255 }
2256 assert!(
2257 max_rel_err < 0.01,
2258 "Grouped PrecomputedTM analytical Jacobian with resolution vs FD: \
2259 max relative error = {max_rel_err}"
2260 );
2261 }
2262
2263 #[test]
2268 fn transmission_fit_model_jacobian_with_resolution_matches_fd() {
2269 use nereids_physics::resolution::ResolutionFunction;
2270
2271 let data = u238_single_resonance();
2272 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
2273 let inst = Arc::new(InstrumentParams {
2274 resolution: ResolutionFunction::Gaussian(
2275 nereids_physics::resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2276 ),
2277 });
2278
2279 let model = TransmissionFitModel::new(
2280 energies.clone(),
2281 vec![data],
2282 300.0,
2283 Some(inst),
2284 (vec![0], vec![1.0]),
2285 Some(1), None,
2287 )
2288 .unwrap();
2289
2290 let params = [0.0005f64, 300.0];
2291 let y = model.evaluate(¶ms).unwrap();
2292 let free = vec![0usize, 1usize];
2293
2294 let jac = model
2295 .analytical_jacobian(¶ms, &free, &y)
2296 .expect("analytical Jacobian must be available with resolution");
2297
2298 let h_density = 1e-7;
2300 let h_temp = 0.01; for (col, (&fp_idx, &h)) in free.iter().zip([h_density, h_temp].iter()).enumerate() {
2303 let mut p_plus = params;
2304 let mut p_minus = params;
2305 p_plus[fp_idx] += h;
2306 p_minus[fp_idx] -= h;
2307 let y_plus = model.evaluate(&p_plus).unwrap();
2308 let y_minus = model.evaluate(&p_minus).unwrap();
2309
2310 let interior = 20..energies.len() - 20;
2311 let mut max_rel_err = 0.0f64;
2312 for i in interior {
2313 let fd = (y_plus[i] - y_minus[i]) / (2.0 * h);
2314 let ana = jac.get(i, col);
2315 let denom = fd.abs().max(ana.abs()).max(1e-30);
2316 max_rel_err = max_rel_err.max((ana - fd).abs() / denom);
2317 }
2318 let label = if col == 0 { "density" } else { "temperature" };
2319 assert!(
2320 max_rel_err < 0.05,
2321 "TransmissionFitModel {label} column with resolution vs FD: \
2322 max relative error = {max_rel_err}"
2323 );
2324 }
2325 }
2326
2327 #[test]
2330 fn transmission_fit_model_jacobian_available_without_resolution() {
2331 let data = u238_single_resonance();
2332 let energies: Vec<f64> = (0..101).map(|i| 4.0 + (i as f64) * 0.05).collect();
2333
2334 let model = TransmissionFitModel::new(
2335 energies,
2336 vec![data],
2337 300.0,
2338 None,
2339 (vec![0], vec![1.0]),
2340 Some(1),
2341 None,
2342 )
2343 .unwrap();
2344
2345 let params = [0.0005, 300.0];
2346 let y = model.evaluate(¶ms).unwrap();
2347
2348 assert!(
2349 model.analytical_jacobian(¶ms, &[0, 1], &y).is_some(),
2350 "TransmissionFitModel analytical Jacobian must be available \
2351 when resolution is disabled"
2352 );
2353 }
2354
2355 #[test]
2360 fn transmission_fit_model_temp_path_with_resolution_matches_forward_model() {
2361 use nereids_physics::resolution::ResolutionFunction;
2362
2363 let data = u238_single_resonance();
2364 let thickness = 0.0005;
2365 let temperature = 300.0;
2366 let energies: Vec<f64> = (0..401).map(|i| 4.0 + (i as f64) * 0.015).collect();
2367
2368 let inst = Arc::new(InstrumentParams {
2369 resolution: ResolutionFunction::Gaussian(
2370 nereids_physics::resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2371 ),
2372 });
2373
2374 let sample = SampleParams::new(temperature, vec![(data.clone(), thickness)]).unwrap();
2376 let t_ref = transmission::forward_model(&energies, &sample, Some(&inst)).unwrap();
2377
2378 let model = TransmissionFitModel::new(
2380 energies.clone(),
2381 vec![data],
2382 temperature,
2383 Some(Arc::clone(&inst)),
2384 (vec![0], vec![1.0]),
2385 Some(1), None,
2387 )
2388 .unwrap();
2389
2390 let t_model = model.evaluate(&[thickness, temperature]).unwrap();
2392
2393 let interior = 20..energies.len() - 20;
2396 let mut max_err = 0.0f64;
2397 for i in interior {
2398 max_err = max_err.max((t_ref[i] - t_model[i]).abs());
2399 }
2400 assert!(
2401 max_err < 0.02,
2402 "TransmissionFitModel temperature path with resolution should match \
2403 forward_model. Max error = {max_err}"
2404 );
2405 }
2406
2407 #[test]
2410 fn transmission_fit_model_temp_path_no_resolution_unchanged() {
2411 let data = u238_single_resonance();
2412 let thickness = 0.0005;
2413 let temperature = 300.0;
2414 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
2415
2416 let sample = SampleParams::new(temperature, vec![(data.clone(), thickness)]).unwrap();
2418 let t_ref = transmission::forward_model(&energies, &sample, None).unwrap();
2419
2420 let model = TransmissionFitModel::new(
2422 energies.clone(),
2423 vec![data],
2424 temperature,
2425 None,
2426 (vec![0], vec![1.0]),
2427 Some(1),
2428 None,
2429 )
2430 .unwrap();
2431
2432 let t_model = model.evaluate(&[thickness, temperature]).unwrap();
2433
2434 for (i, (&r, &m)) in t_ref.iter().zip(t_model.iter()).enumerate() {
2435 assert!(
2436 (r - m).abs() < 1e-12,
2437 "No-resolution mismatch at E[{i}]={}: ref={r}, model={m}",
2438 energies[i]
2439 );
2440 }
2441 }
2442
2443 #[test]
2446 fn transmission_fit_model_temp_path_resolution_makes_difference() {
2447 use nereids_physics::resolution::ResolutionFunction;
2448
2449 let data = u238_single_resonance();
2450 let thickness = 0.0005;
2451 let temperature = 300.0;
2452 let energies: Vec<f64> = (0..401).map(|i| 4.0 + (i as f64) * 0.015).collect();
2453
2454 let inst = Arc::new(InstrumentParams {
2455 resolution: ResolutionFunction::Gaussian(
2456 nereids_physics::resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2457 ),
2458 });
2459
2460 let model_res = TransmissionFitModel::new(
2462 energies.clone(),
2463 vec![data.clone()],
2464 temperature,
2465 Some(inst),
2466 (vec![0], vec![1.0]),
2467 Some(1),
2468 None,
2469 )
2470 .unwrap();
2471 let t_res = model_res.evaluate(&[thickness, temperature]).unwrap();
2472
2473 let model_no = TransmissionFitModel::new(
2475 energies.clone(),
2476 vec![data],
2477 temperature,
2478 None,
2479 (vec![0], vec![1.0]),
2480 Some(1),
2481 None,
2482 )
2483 .unwrap();
2484 let t_no = model_no.evaluate(&[thickness, temperature]).unwrap();
2485
2486 let interior = 20..energies.len() - 20;
2487 let max_diff: f64 = interior
2488 .map(|i| (t_res[i] - t_no[i]).abs())
2489 .fold(0.0f64, f64::max);
2490 assert!(
2491 max_diff > 1e-4,
2492 "Resolution should make a measurable difference in the temperature \
2493 path, but max diff = {max_diff}"
2494 );
2495 }
2496
2497 #[test]
2502 fn exponential_evaluate_formula_correct() {
2503 let xs = vec![vec![1.0, 2.0, 3.0]];
2504 let inner = make_precomputed(xs, vec![0]);
2505 let energies = [4.0, 9.0, 25.0]; let model =
2508 NormalizedTransmissionModel::new_with_exponential(inner, &energies, 1, 2, 3, 4, 5, 6);
2509
2510 let density = 0.1;
2512 let anorm = 1.02;
2513 let back_a = 0.01;
2514 let back_b = 0.005;
2515 let back_c = 0.002;
2516 let back_d = 0.05;
2517 let back_f = 3.0;
2518 let params = [density, anorm, back_a, back_b, back_c, back_d, back_f];
2519
2520 let y = model.evaluate(¶ms).unwrap();
2521
2522 let xs_vals = [1.0, 2.0, 3.0];
2524 let sqrt_e = [2.0, 3.0, 5.0];
2525 for i in 0..3 {
2526 let t_inner = (-density * xs_vals[i]).exp();
2527 let expected = anorm * t_inner
2528 + back_a
2529 + back_b / sqrt_e[i]
2530 + back_c * sqrt_e[i]
2531 + back_d * (-back_f / sqrt_e[i]).exp();
2532 assert!(
2533 (y[i] - expected).abs() < 1e-12,
2534 "bin {i}: got {}, expected {expected}",
2535 y[i]
2536 );
2537 }
2538 }
2539
2540 #[test]
2542 fn exponential_jacobian_matches_finite_difference() {
2543 let xs = vec![vec![1.0, 2.0, 3.0, 0.5, 1.5]];
2544 let inner = make_precomputed(xs, vec![0]);
2545 let energies = [0.1, 1.0, 4.0, 25.0, 100.0]; let model =
2548 NormalizedTransmissionModel::new_with_exponential(inner, &energies, 1, 2, 3, 4, 5, 6);
2549
2550 let params = [0.1, 1.02, 0.01, 0.005, 0.002, 0.05, 3.0];
2552 let y = model.evaluate(¶ms).unwrap();
2553 let free_indices: Vec<usize> = (0..7).collect();
2554
2555 let jac = model
2556 .analytical_jacobian(¶ms, &free_indices, &y)
2557 .expect("analytical Jacobian should be available");
2558
2559 let h = 1e-7;
2561 for (col, &pidx) in free_indices.iter().enumerate() {
2562 let mut p_plus = params.to_vec();
2563 let mut p_minus = params.to_vec();
2564 p_plus[pidx] += h;
2565 p_minus[pidx] -= h;
2566 let y_plus = model.evaluate(&p_plus).unwrap();
2567 let y_minus = model.evaluate(&p_minus).unwrap();
2568
2569 for row in 0..energies.len() {
2570 let fd = (y_plus[row] - y_minus[row]) / (2.0 * h);
2571 let anal = jac.get(row, col);
2572 let abs_err = (anal - fd).abs();
2573 let rel_err = abs_err / fd.abs().max(1e-15);
2574 assert!(
2575 rel_err < 1e-5 || abs_err < 1e-10,
2576 "param {pidx} (col {col}), bin {row}: analytical={anal:.10e}, \
2577 fd={fd:.10e}, rel_err={rel_err:.2e}"
2578 );
2579 }
2580 }
2581 }
2582
2583 #[test]
2585 fn exponential_fit_recovers_all_params() {
2586 let xs = vec![vec![1.0, 2.0, 3.0, 2.0, 1.5, 0.8, 1.2, 2.5]];
2587 let inner = make_precomputed(xs, vec![0]);
2588 let energies = [0.5, 1.0, 4.0, 9.0, 16.0, 25.0, 36.0, 64.0];
2589
2590 let model =
2591 NormalizedTransmissionModel::new_with_exponential(inner, &energies, 1, 2, 3, 4, 5, 6);
2592
2593 let true_density = 0.15;
2595 let true_anorm = 1.02;
2596 let true_back_a = 0.01;
2597 let true_back_b = 0.005;
2598 let true_back_c = 0.002;
2599 let true_back_d = 0.03;
2600 let true_back_f = 2.0;
2601 let true_params = [
2602 true_density,
2603 true_anorm,
2604 true_back_a,
2605 true_back_b,
2606 true_back_c,
2607 true_back_d,
2608 true_back_f,
2609 ];
2610
2611 let y_obs = model.evaluate(&true_params).unwrap();
2612 let sigma = vec![0.001; y_obs.len()];
2613
2614 let mut params = ParameterSet::new(vec![
2615 FitParameter::non_negative("density", 0.1),
2616 FitParameter {
2617 name: "anorm".into(),
2618 value: 1.0,
2619 lower: 0.5,
2620 upper: 1.5,
2621 fixed: false,
2622 },
2623 FitParameter {
2624 name: "back_a".into(),
2625 value: 0.0,
2626 lower: -0.5,
2627 upper: 0.5,
2628 fixed: false,
2629 },
2630 FitParameter {
2631 name: "back_b".into(),
2632 value: 0.0,
2633 lower: -0.5,
2634 upper: 0.5,
2635 fixed: false,
2636 },
2637 FitParameter {
2638 name: "back_c".into(),
2639 value: 0.0,
2640 lower: -0.5,
2641 upper: 0.5,
2642 fixed: false,
2643 },
2644 FitParameter {
2645 name: "back_d".into(),
2646 value: 0.01,
2647 lower: 0.0,
2648 upper: 1.0,
2649 fixed: false,
2650 },
2651 FitParameter {
2652 name: "back_f".into(),
2653 value: 1.0,
2654 lower: 0.0,
2655 upper: 100.0,
2656 fixed: false,
2657 },
2658 ]);
2659
2660 let config = LmConfig {
2661 max_iter: 500,
2662 ..LmConfig::default()
2663 };
2664
2665 let result = lm::levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &config).unwrap();
2666
2667 assert!(result.converged, "Fit should converge");
2668
2669 let fitted = &result.params;
2670 let check = |name, fitted_val: f64, true_val: f64, tol: f64| {
2671 let err = (fitted_val - true_val).abs();
2672 let rel = err / true_val.abs().max(1e-10);
2673 assert!(
2674 rel < tol || err < 1e-6,
2675 "{name}: fitted={fitted_val:.6}, true={true_val:.6}, rel_err={rel:.4}"
2676 );
2677 };
2678
2679 check("density", fitted[0], true_density, 0.10);
2680 check("anorm", fitted[1], true_anorm, 0.10);
2681 check("back_a", fitted[2], true_back_a, 0.10);
2682 check("back_b", fitted[3], true_back_b, 0.10);
2683 check("back_c", fitted[4], true_back_c, 0.10);
2684 check("back_d", fitted[5], true_back_d, 0.10);
2685 check("back_f", fitted[6], true_back_f, 0.10);
2686 }
2687
2688 #[test]
2692 fn energy_scale_corrected_energies() {
2693 let xs = vec![vec![1.0; 5]];
2694 let energies = vec![10.0, 20.0, 50.0, 100.0, 200.0];
2695 let model = EnergyScaleTransmissionModel::new(
2696 std::sync::Arc::new(xs),
2697 std::sync::Arc::new(vec![0]),
2698 energies.clone(),
2699 25.0,
2700 1, 2, None,
2703 );
2704
2705 let e_corr = model.corrected_energies(0.0, 1.0);
2707 for (i, (&nom, &corr)) in energies.iter().zip(e_corr.iter()).enumerate() {
2708 assert!(
2709 (nom - corr).abs() / nom < 1e-10,
2710 "bin {i}: nominal={nom}, corrected={corr}"
2711 );
2712 }
2713
2714 let e_corr_ls = model.corrected_energies(0.0, 1.005);
2716 for (i, (&nom, &corr)) in energies.iter().zip(e_corr_ls.iter()).enumerate() {
2717 assert!(
2718 corr > nom,
2719 "bin {i}: l_scale=1.005 should increase energy, got nom={nom}, corr={corr}"
2720 );
2721 }
2722
2723 let e_corr_t0 = model.corrected_energies(1.0, 1.0);
2725 for (i, (&nom, &corr)) in energies.iter().zip(e_corr_t0.iter()).enumerate() {
2726 assert!(
2727 corr > nom,
2728 "bin {i}: t0=1.0 should increase energy, got nom={nom}, corr={corr}"
2729 );
2730 }
2731 }
2732
2733 #[test]
2735 fn energy_scale_interpolate_xs() {
2736 let nominal_e = vec![1.0, 2.0, 3.0, 4.0, 5.0];
2737 let xs = vec![10.0, 20.0, 30.0, 40.0, 50.0];
2738
2739 let result =
2741 EnergyScaleTransmissionModel::interpolate_xs(&nominal_e, &xs, &[1.0, 3.0, 5.0]);
2742 assert!((result[0] - 10.0).abs() < 1e-10);
2743 assert!((result[1] - 30.0).abs() < 1e-10);
2744 assert!((result[2] - 50.0).abs() < 1e-10);
2745
2746 let result = EnergyScaleTransmissionModel::interpolate_xs(&nominal_e, &xs, &[1.5, 2.5]);
2748 assert!((result[0] - 15.0).abs() < 1e-10);
2749 assert!((result[1] - 25.0).abs() < 1e-10);
2750
2751 let result = EnergyScaleTransmissionModel::interpolate_xs(&nominal_e, &xs, &[0.5, 6.0]);
2753 assert!((result[0] - 10.0).abs() < 1e-10); assert!((result[1] - 50.0).abs() < 1e-10); }
2756
2757 #[test]
2759 fn energy_scale_evaluate_identity() {
2760 let xs = vec![vec![1.0, 2.0, 3.0, 2.0, 1.5]];
2761 let energies = vec![4.0, 9.0, 16.0, 25.0, 36.0];
2762 let model_es = EnergyScaleTransmissionModel::new(
2763 std::sync::Arc::new(xs.clone()),
2764 std::sync::Arc::new(vec![0]),
2765 energies.clone(),
2766 25.0,
2767 1, 2, None,
2770 );
2771
2772 let model_pre = make_precomputed(xs, vec![0]);
2773
2774 let density = 0.1;
2775 let params_es = [density, 0.0, 1.0]; let params_pre = [density];
2777
2778 let y_es = model_es.evaluate(¶ms_es).unwrap();
2779 let y_pre = model_pre.evaluate(¶ms_pre).unwrap();
2780
2781 for (i, (&a, &b)) in y_es.iter().zip(y_pre.iter()).enumerate() {
2782 assert!(
2783 (a - b).abs() < 1e-10,
2784 "bin {i}: energy_scale={a}, precomputed={b}"
2785 );
2786 }
2787 }
2788
2789 #[test]
2791 fn energy_scale_jacobian_density_matches_fd() {
2792 let xs = vec![vec![1.0, 2.0, 3.0, 2.0, 1.5]];
2793 let energies = vec![4.0, 9.0, 16.0, 25.0, 36.0];
2794 let model = EnergyScaleTransmissionModel::new(
2795 std::sync::Arc::new(xs),
2796 std::sync::Arc::new(vec![0]),
2797 energies.clone(),
2798 25.0,
2799 1,
2800 2,
2801 None,
2802 );
2803
2804 let params = [0.1, 0.5, 1.002]; let y = model.evaluate(¶ms).unwrap();
2806 let free = vec![0, 1, 2];
2807 let jac = model
2808 .analytical_jacobian(¶ms, &free, &y)
2809 .expect("Jacobian should be available");
2810
2811 let h = 1e-7;
2812 for (col, &pidx) in free.iter().enumerate() {
2813 let mut pp = params.to_vec();
2814 let mut pm = params.to_vec();
2815 pp[pidx] += h;
2816 pm[pidx] -= h;
2817 let yp = model.evaluate(&pp).unwrap();
2818 let ym = model.evaluate(&pm).unwrap();
2819 for row in 0..energies.len() {
2820 let fd = (yp[row] - ym[row]) / (2.0 * h);
2821 let anal = jac.get(row, col);
2822 let abs_err = (anal - fd).abs();
2823 let rel_err = abs_err / fd.abs().max(1e-15);
2824 assert!(
2825 rel_err < 1e-3 || abs_err < 1e-8,
2826 "param {pidx} col {col} bin {row}: anal={anal:.6e} fd={fd:.6e} rel={rel_err:.2e}"
2827 );
2828 }
2829 }
2830 }
2831
2832 #[test]
2838 fn energy_scale_fit_recovers_l_scale() {
2839 let n = 200;
2840 let energies: Vec<f64> = (0..n).map(|i| 10.0 + (i as f64) * 0.5).collect();
2841 let xs: Vec<f64> = energies
2843 .iter()
2844 .map(|&e| 100.0 / ((e - 50.0).powi(2) + 1.0))
2845 .collect();
2846
2847 let true_density = 0.001;
2848 let true_ls = 1.003;
2849
2850 let model = EnergyScaleTransmissionModel::new(
2851 std::sync::Arc::new(vec![xs]),
2852 std::sync::Arc::new(vec![0]),
2853 energies,
2854 25.0,
2855 1, 2, None,
2858 );
2859 let true_params = [true_density, 0.0, true_ls];
2860 let y_obs = model.evaluate(&true_params).unwrap();
2861 let sigma = vec![0.001; y_obs.len()];
2862
2863 let mut params = ParameterSet::new(vec![
2864 FitParameter::non_negative("density", 0.0005),
2865 FitParameter::fixed("t0", 0.0),
2866 FitParameter {
2867 name: "l_scale".into(),
2868 value: 1.0,
2869 lower: 0.99,
2870 upper: 1.01,
2871 fixed: false,
2872 },
2873 ]);
2874
2875 let config = LmConfig {
2876 max_iter: 200,
2877 ..LmConfig::default()
2878 };
2879
2880 let result = lm::levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &config).unwrap();
2881
2882 assert!(result.converged, "Fit should converge");
2883 let f = &result.params;
2884 assert!(
2885 (f[0] - true_density).abs() / true_density < 0.05,
2886 "density: fitted={}, true={true_density}",
2887 f[0]
2888 );
2889 assert!(
2890 (f[2] - true_ls).abs() < 0.001,
2891 "l_scale: fitted={}, true={true_ls}",
2892 f[2]
2893 );
2894 }
2895}