1use std::fmt;
26use std::sync::atomic::{AtomicBool, Ordering};
27
28use rayon::prelude::*;
29
30use nereids_endf::resonance::ResonanceData;
31
32use crate::doppler::{self, DopplerParams, DopplerParamsError};
33use crate::reich_moore;
34use crate::resolution::{self, ResolutionError, ResolutionFunction};
35
36fn build_aux_grid(
48 energies: &[f64],
49 instrument: Option<&InstrumentParams>,
50 resonance_data: &[&ResonanceData],
51) -> Option<(Vec<f64>, Vec<usize>)> {
52 instrument.and_then(|inst| {
53 if let ResolutionFunction::Gaussian(ref params) = inst.resolution {
54 let use_intermediates = if energies.len() >= 2 {
65 let n = energies.len();
66 let check_indices = [0, n / 4, n / 2, 3 * n / 4, n - 1];
67 let n_pw_linear = check_indices
68 .iter()
69 .filter(|&&i| {
70 let e = energies[i];
71 let wg = params.gaussian_width(e);
72 let we = params.exp_width(e);
73 we < 1e-60 || wg / (2.0 * we) > 2.5
74 })
75 .count();
76 n_pw_linear >= 3
78 } else {
79 true
80 };
81
82 let resonances = extract_resonance_widths(resonance_data);
87
88 let (ext_e, di) = if use_intermediates {
89 crate::auxiliary_grid::build_extended_grid(energies, Some(params), &resonances)
90 } else {
91 crate::auxiliary_grid::build_extended_grid_boundary_only(energies, Some(params))
92 };
93 if ext_e.len() > energies.len() {
94 Some((ext_e, di))
95 } else {
96 None
97 }
98 } else {
99 None
100 }
101 })
102}
103
104fn extract_resonance_widths(resonance_data: &[&ResonanceData]) -> Vec<(f64, f64)> {
113 let mut pairs = Vec::new();
114 for rd in resonance_data {
115 for range in &rd.ranges {
116 if !range.resolved {
117 continue;
118 }
119 for lg in &range.l_groups {
121 for res in &lg.resonances {
122 let gd = res.gn.abs() + res.gg.abs() + res.gfa.abs() + res.gfb.abs();
123 if gd > 0.0 {
124 pairs.push((res.energy, gd));
125 }
126 }
127 }
128 if let Some(ref rml) = range.rml {
130 for sg in &rml.spin_groups {
131 for res in &sg.resonances {
132 let mut gd = res.gamma_gamma.abs();
133 for &w in &res.widths {
134 gd += w * w; }
136 if gd > 0.0 {
137 pairs.push((res.energy, gd));
138 }
139 }
140 }
141 }
142 }
143 }
144 pairs
145}
146
147#[derive(Debug)]
149pub enum TransmissionError {
150 Resolution(ResolutionError),
152 Doppler(DopplerParamsError),
154 DopplerBroadening(crate::doppler::DopplerError),
156 Cancelled,
158 InputMismatch(String),
160}
161
162impl fmt::Display for TransmissionError {
163 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164 match self {
165 Self::Resolution(e) => write!(f, "resolution broadening error: {}", e),
166 Self::Doppler(e) => write!(f, "Doppler parameter error: {}", e),
167 Self::DopplerBroadening(e) => write!(f, "Doppler broadening error: {}", e),
168 Self::Cancelled => write!(f, "computation cancelled"),
169 Self::InputMismatch(msg) => write!(f, "input mismatch: {}", msg),
170 }
171 }
172}
173
174impl std::error::Error for TransmissionError {
175 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
176 match self {
177 Self::Resolution(e) => Some(e),
178 Self::Doppler(e) => Some(e),
179 Self::DopplerBroadening(e) => Some(e),
180 Self::Cancelled => None,
181 Self::InputMismatch(_) => None,
182 }
183 }
184}
185
186impl From<ResolutionError> for TransmissionError {
187 fn from(e: ResolutionError) -> Self {
188 Self::Resolution(e)
189 }
190}
191
192impl From<DopplerParamsError> for TransmissionError {
193 fn from(e: DopplerParamsError) -> Self {
194 Self::Doppler(e)
195 }
196}
197
198impl From<crate::doppler::DopplerError> for TransmissionError {
199 fn from(e: crate::doppler::DopplerError) -> Self {
200 Self::DopplerBroadening(e)
201 }
202}
203
204pub type BroadenedXsWithDerivative = (Vec<Vec<f64>>, Vec<Vec<f64>>);
210
211pub fn beer_lambert(cross_sections: &[f64], thickness: f64) -> Vec<f64> {
222 cross_sections
223 .iter()
224 .map(|&sigma| (-thickness * sigma).exp())
225 .collect()
226}
227
228pub fn beer_lambert_multi(
240 cross_sections_per_isotope: &[&[f64]],
241 thicknesses: &[f64],
242) -> Result<Vec<f64>, TransmissionError> {
243 if cross_sections_per_isotope.len() != thicknesses.len() {
244 return Err(TransmissionError::InputMismatch(format!(
245 "cross_sections_per_isotope length ({}) must match thicknesses length ({})",
246 cross_sections_per_isotope.len(),
247 thicknesses.len()
248 )));
249 }
250 if cross_sections_per_isotope.is_empty() {
251 return Err(TransmissionError::InputMismatch(
252 "cross_sections_per_isotope must not be empty".into(),
253 ));
254 }
255
256 let n_energies = cross_sections_per_isotope[0].len();
257 for (k, sigma) in cross_sections_per_isotope.iter().enumerate() {
258 if sigma.len() != n_energies {
259 return Err(TransmissionError::InputMismatch(format!(
260 "cross_sections_per_isotope[{}] length ({}) must match [0] length ({})",
261 k,
262 sigma.len(),
263 n_energies
264 )));
265 }
266 }
267
268 Ok((0..n_energies)
269 .map(|i| {
270 let total_attenuation: f64 = cross_sections_per_isotope
271 .iter()
272 .zip(thicknesses.iter())
273 .map(|(sigma, &thick)| thick * sigma[i])
274 .sum();
275 (-total_attenuation).exp()
276 })
277 .collect())
278}
279
280#[derive(Debug, PartialEq)]
282pub enum SampleParamsError {
283 NonFiniteTemperature(f64),
285 NegativeTemperature(f64),
287}
288
289impl fmt::Display for SampleParamsError {
290 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
291 match self {
292 Self::NonFiniteTemperature(v) => {
293 write!(f, "temperature must be finite, got {v}")
294 }
295 Self::NegativeTemperature(v) => {
296 write!(f, "temperature must be non-negative, got {v}")
297 }
298 }
299 }
300}
301
302impl std::error::Error for SampleParamsError {}
303
304#[derive(Debug, Clone)]
306pub struct SampleParams {
307 temperature_k: f64,
309 isotopes: Vec<(ResonanceData, f64)>,
311}
312
313impl SampleParams {
314 pub fn new(
321 temperature_k: f64,
322 isotopes: Vec<(ResonanceData, f64)>,
323 ) -> Result<Self, SampleParamsError> {
324 if !temperature_k.is_finite() {
325 return Err(SampleParamsError::NonFiniteTemperature(temperature_k));
326 }
327 if temperature_k < 0.0 {
328 return Err(SampleParamsError::NegativeTemperature(temperature_k));
329 }
330 Ok(Self {
331 temperature_k,
332 isotopes,
333 })
334 }
335
336 #[must_use]
338 pub fn temperature_k(&self) -> f64 {
339 self.temperature_k
340 }
341
342 #[must_use]
344 pub fn isotopes(&self) -> &[(ResonanceData, f64)] {
345 &self.isotopes
346 }
347}
348
349#[derive(Debug, Clone)]
351pub struct InstrumentParams {
352 pub resolution: ResolutionFunction,
354}
355
356pub fn forward_model(
380 energies: &[f64],
381 sample: &SampleParams,
382 instrument: Option<&InstrumentParams>,
383) -> Result<Vec<f64>, TransmissionError> {
384 let n = energies.len();
385 if n == 0 {
386 return Ok(vec![]);
387 }
388
389 if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
393 return Err(ResolutionError::UnsortedEnergies.into());
394 }
395
396 let active_rd: Vec<&ResonanceData> = sample
400 .isotopes()
401 .iter()
402 .filter(|(_, t)| *t > 0.0)
403 .map(|(rd, _)| rd)
404 .collect();
405 let ext_grid = build_aux_grid(energies, instrument, &active_rd);
406
407 let (work_energies, work_len): (&[f64], usize) = if let Some((ref ext_e, _)) = ext_grid {
429 (ext_e.as_slice(), ext_e.len())
430 } else {
431 (energies, n)
432 };
433
434 let doppler_xs: Result<Vec<(Vec<f64>, f64)>, TransmissionError> = sample
435 .isotopes()
436 .par_iter()
437 .filter(|(_, thickness)| *thickness > 0.0)
438 .map(|(res_data, thickness)| {
439 let unbroadened: Vec<f64> = work_energies
440 .iter()
441 .map(|&e| reich_moore::cross_sections_at_energy(res_data, e).total)
442 .collect();
443 let after_doppler = if sample.temperature_k() > 0.0 {
444 let params = DopplerParams::new(sample.temperature_k(), res_data.awr)?;
445 doppler::doppler_broaden(work_energies, &unbroadened, ¶ms)?
446 } else {
447 unbroadened
448 };
449 Ok((after_doppler, *thickness))
450 })
451 .collect();
452 let doppler_xs = doppler_xs?;
453
454 let mut total_attenuation = vec![0.0f64; work_len];
456 for (xs, thickness) in &doppler_xs {
457 for i in 0..work_len {
458 total_attenuation[i] += thickness * xs[i];
459 }
460 }
461
462 let transmission: Vec<f64> = total_attenuation.iter().map(|&att| (-att).exp()).collect();
464
465 if let Some(inst) = instrument {
467 let t_broadened =
468 resolution::apply_resolution_presorted(work_energies, &transmission, &inst.resolution);
469 if let Some((_, ref data_indices)) = ext_grid {
470 Ok(data_indices.iter().map(|&i| t_broadened[i]).collect())
471 } else {
472 Ok(t_broadened)
473 }
474 } else {
475 Ok(transmission)
476 }
477}
478
479pub fn broadened_cross_sections(
519 energies: &[f64],
520 resonance_data: &[ResonanceData],
521 temperature_k: f64,
522 instrument: Option<&InstrumentParams>,
523 cancel: Option<&AtomicBool>,
524) -> Result<Vec<Vec<f64>>, TransmissionError> {
525 if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
527 return Err(ResolutionError::UnsortedEnergies.into());
528 }
529
530 let rd_refs: Vec<&ResonanceData> = resonance_data.iter().collect();
536 let ext_grid = build_aux_grid(energies, instrument, &rd_refs);
537
538 let result: Result<Vec<Vec<f64>>, TransmissionError> = resonance_data
543 .par_iter()
544 .map(|rd| {
545 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
547 return Err(TransmissionError::Cancelled);
548 }
549
550 let xs = if let Some((ref ext_energies, ref data_indices)) = ext_grid {
555 let unbroadened: Vec<f64> = ext_energies
556 .iter()
557 .map(|&e| reich_moore::cross_sections_at_energy(rd, e).total)
558 .collect();
559 let after_doppler = if temperature_k > 0.0 {
560 let params = DopplerParams::new(temperature_k, rd.awr)?;
561 doppler::doppler_broaden(ext_energies, &unbroadened, ¶ms)?
562 } else {
563 unbroadened
564 };
565 data_indices.iter().map(|&i| after_doppler[i]).collect()
566 } else {
567 let unbroadened: Vec<f64> = energies
569 .iter()
570 .map(|&e| reich_moore::cross_sections_at_energy(rd, e).total)
571 .collect();
572 if temperature_k > 0.0 {
573 let params = DopplerParams::new(temperature_k, rd.awr)?;
574 doppler::doppler_broaden(energies, &unbroadened, ¶ms)?
575 } else {
576 unbroadened
577 }
578 };
579
580 Ok(xs)
581 })
582 .collect();
583
584 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
587 return Err(TransmissionError::Cancelled);
588 }
589
590 result
591}
592
593pub fn broadened_cross_sections_for_transmission(
622 energies: &[f64],
623 resonance_data: &[ResonanceData],
624 temperature_k: f64,
625 instrument: &InstrumentParams,
626 thickness_atoms_barn: f64,
627 cancel: Option<&AtomicBool>,
628) -> Result<Vec<Vec<f64>>, TransmissionError> {
629 if !energies.windows(2).all(|w| w[0] <= w[1]) {
630 return Err(ResolutionError::UnsortedEnergies.into());
631 }
632
633 let rd_refs: Vec<&ResonanceData> = resonance_data.iter().collect();
634 let ext_grid = build_aux_grid(energies, Some(instrument), &rd_refs);
635 let nd = thickness_atoms_barn;
636
637 let result: Result<Vec<Vec<f64>>, TransmissionError> = resonance_data
638 .par_iter()
639 .map(|rd| {
640 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
641 return Err(TransmissionError::Cancelled);
642 }
643
644 let sigma_eff = if let Some((ref ext_energies, ref data_indices)) = ext_grid {
645 let unbroadened: Vec<f64> = ext_energies
650 .iter()
651 .map(|&e| reich_moore::cross_sections_at_energy(rd, e).total)
652 .collect();
653
654 let after_doppler = if temperature_k > 0.0 {
656 let params = DopplerParams::new(temperature_k, rd.awr)?;
657 doppler::doppler_broaden(ext_energies, &unbroadened, ¶ms)?
658 } else {
659 unbroadened
660 };
661
662 let transmission: Vec<f64> = after_doppler
664 .iter()
665 .map(|&sigma| (-nd * sigma).exp())
666 .collect();
667
668 let t_broadened = resolution::apply_resolution_presorted(
670 ext_energies,
671 &transmission,
672 &instrument.resolution,
673 );
674
675 data_indices
677 .iter()
678 .map(|&i| {
679 let t = t_broadened[i].clamp(1e-30, 1.0);
680 -t.ln() / nd
681 })
682 .collect()
683 } else {
684 let unbroadened: Vec<f64> = energies
687 .iter()
688 .map(|&e| reich_moore::cross_sections_at_energy(rd, e).total)
689 .collect();
690
691 let after_doppler = if temperature_k > 0.0 {
692 let params = DopplerParams::new(temperature_k, rd.awr)?;
693 doppler::doppler_broaden(energies, &unbroadened, ¶ms)?
694 } else {
695 unbroadened
696 };
697
698 let transmission: Vec<f64> = after_doppler
699 .iter()
700 .map(|&sigma| (-nd * sigma).exp())
701 .collect();
702
703 let t_broadened = resolution::apply_resolution_presorted(
704 energies,
705 &transmission,
706 &instrument.resolution,
707 );
708
709 t_broadened
710 .iter()
711 .map(|&t| {
712 let t_clamped = t.clamp(1e-30, 1.0);
713 -t_clamped.ln() / nd
714 })
715 .collect()
716 };
717
718 Ok(sigma_eff)
719 })
720 .collect();
721
722 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
723 return Err(TransmissionError::Cancelled);
724 }
725
726 result
727}
728
729pub fn unbroadened_cross_sections(
739 energies: &[f64],
740 resonance_data: &[ResonanceData],
741 cancel: Option<&AtomicBool>,
742) -> Result<Vec<Vec<f64>>, TransmissionError> {
743 let result: Result<Vec<Vec<f64>>, TransmissionError> = resonance_data
744 .par_iter()
745 .map(|rd| {
746 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
747 return Err(TransmissionError::Cancelled);
748 }
749 let xs: Vec<f64> = energies
750 .iter()
751 .map(|&e| reich_moore::cross_sections_at_energy(rd, e).total)
752 .collect();
753 Ok(xs)
754 })
755 .collect();
756
757 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
758 return Err(TransmissionError::Cancelled);
759 }
760 result
761}
762
763pub fn broadened_cross_sections_from_base(
774 energies: &[f64],
775 base_xs: &[Vec<f64>],
776 resonance_data: &[ResonanceData],
777 temperature_k: f64,
778 instrument: Option<&InstrumentParams>,
779) -> Result<Vec<Vec<f64>>, TransmissionError> {
780 if base_xs.len() != resonance_data.len() {
781 return Err(TransmissionError::InputMismatch(format!(
782 "base_xs has {} isotopes but resonance_data has {}",
783 base_xs.len(),
784 resonance_data.len(),
785 )));
786 }
787 for (i, row) in base_xs.iter().enumerate() {
788 if row.len() != energies.len() {
789 return Err(TransmissionError::InputMismatch(format!(
790 "base_xs[{i}] has {} energies but expected {}",
791 row.len(),
792 energies.len(),
793 )));
794 }
795 }
796 if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
797 return Err(ResolutionError::UnsortedEnergies.into());
798 }
799
800 let rd_refs: Vec<&ResonanceData> = resonance_data.iter().collect();
806 let ext_grid = build_aux_grid(energies, instrument, &rd_refs);
807
808 let is_data_point: Option<Vec<bool>> = ext_grid.as_ref().map(|(ext_e, di)| {
810 let mut mask = vec![false; ext_e.len()];
811 for &idx in di {
812 mask[idx] = true;
813 }
814 mask
815 });
816
817 base_xs
819 .par_iter()
820 .zip(resonance_data.par_iter())
821 .map(|(xs_raw, rd)| {
822 if let Some((ref ext_energies, ref data_indices)) = ext_grid {
823 let mask = is_data_point.as_ref().unwrap();
824
825 let mut xs_ext = vec![0.0f64; ext_energies.len()];
827 for (data_i, &ext_i) in data_indices.iter().enumerate() {
828 xs_ext[ext_i] = xs_raw[data_i];
829 }
830 for (j, &e) in ext_energies.iter().enumerate() {
831 if !mask[j] {
832 xs_ext[j] = reich_moore::cross_sections_at_energy(rd, e).total;
833 }
834 }
835
836 let after_doppler = if temperature_k > 0.0 {
837 let params = DopplerParams::new(temperature_k, rd.awr)?;
838 doppler::doppler_broaden(ext_energies, &xs_ext, ¶ms)?
839 } else {
840 xs_ext
841 };
842 Ok(data_indices.iter().map(|&i| after_doppler[i]).collect())
843 } else {
844 let after_doppler = if temperature_k > 0.0 {
846 let params = DopplerParams::new(temperature_k, rd.awr)?;
847 doppler::doppler_broaden(energies, xs_raw, ¶ms)?
848 } else {
849 xs_raw.clone()
850 };
851 Ok(after_doppler)
852 }
853 })
854 .collect()
855}
856
857pub fn broadened_cross_sections_with_analytical_derivative_from_base(
869 energies: &[f64],
870 base_xs: &[Vec<f64>],
871 resonance_data: &[ResonanceData],
872 temperature_k: f64,
873 instrument: Option<&InstrumentParams>,
874) -> Result<BroadenedXsWithDerivative, TransmissionError> {
875 if base_xs.len() != resonance_data.len() {
876 return Err(TransmissionError::InputMismatch(format!(
877 "base_xs has {} isotopes but resonance_data has {}",
878 base_xs.len(),
879 resonance_data.len(),
880 )));
881 }
882 for (i, row) in base_xs.iter().enumerate() {
883 if row.len() != energies.len() {
884 return Err(TransmissionError::InputMismatch(format!(
885 "base_xs[{i}] has {} energies but expected {}",
886 row.len(),
887 energies.len(),
888 )));
889 }
890 }
891 if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
892 return Err(ResolutionError::UnsortedEnergies.into());
893 }
894
895 let rd_refs: Vec<&ResonanceData> = resonance_data.iter().collect();
897 let ext_grid = build_aux_grid(energies, instrument, &rd_refs);
898 let is_data_point: Option<Vec<bool>> = ext_grid.as_ref().map(|(ext_e, di)| {
899 let mut mask = vec![false; ext_e.len()];
900 for &idx in di {
901 mask[idx] = true;
902 }
903 mask
904 });
905
906 type IsotopeXsDxs = Result<(Vec<f64>, Vec<f64>), TransmissionError>;
909 let results: Vec<IsotopeXsDxs> = base_xs
910 .par_iter()
911 .zip(resonance_data.par_iter())
912 .map(|(xs_raw, rd)| {
913 if let Some((ref ext_energies, ref data_indices)) = ext_grid {
914 let mask = is_data_point.as_ref().unwrap();
915
916 let mut xs_ext = vec![0.0f64; ext_energies.len()];
918 for (data_i, &ext_i) in data_indices.iter().enumerate() {
919 xs_ext[ext_i] = xs_raw[data_i];
920 }
921 for (j, &e) in ext_energies.iter().enumerate() {
922 if !mask[j] {
923 xs_ext[j] = reich_moore::cross_sections_at_energy(rd, e).total;
924 }
925 }
926
927 let (after_doppler, dxs_dt_doppler) = if temperature_k > 0.0 {
930 let params = DopplerParams::new(temperature_k, rd.awr)?;
931 doppler::doppler_broaden_with_derivative(ext_energies, &xs_ext, ¶ms)?
932 } else {
933 (xs_ext, vec![0.0; ext_energies.len()])
934 };
935
936 let xs: Vec<f64> = data_indices.iter().map(|&i| after_doppler[i]).collect();
938 let dxs: Vec<f64> = data_indices.iter().map(|&i| dxs_dt_doppler[i]).collect();
939 Ok((xs, dxs))
940 } else {
941 let (after_doppler, dxs_dt_doppler) = if temperature_k > 0.0 {
943 let params = DopplerParams::new(temperature_k, rd.awr)?;
944 doppler::doppler_broaden_with_derivative(energies, xs_raw, ¶ms)?
945 } else {
946 (xs_raw.clone(), vec![0.0; energies.len()])
947 };
948 Ok((after_doppler, dxs_dt_doppler))
949 }
950 })
951 .collect();
952
953 let mut xs_all = Vec::with_capacity(base_xs.len());
955 let mut dxs_all = Vec::with_capacity(base_xs.len());
956 for r in results {
957 let (xs, dxs) = r?;
958 xs_all.push(xs);
959 dxs_all.push(dxs);
960 }
961 Ok((xs_all, dxs_all))
962}
963
964pub fn forward_model_from_base_xs(
978 energies: &[f64],
979 base_xs: &[Vec<f64>],
980 resonance_data: &[ResonanceData],
981 thicknesses: &[f64],
982 temperature_k: f64,
983 instrument: Option<&InstrumentParams>,
984) -> Result<Vec<f64>, TransmissionError> {
985 if base_xs.len() != resonance_data.len() || thicknesses.len() != resonance_data.len() {
986 return Err(TransmissionError::InputMismatch(format!(
987 "forward_model_from_base_xs: base_xs({})/thicknesses({})/resonance_data({}) length mismatch",
988 base_xs.len(),
989 thicknesses.len(),
990 resonance_data.len(),
991 )));
992 }
993 let n = energies.len();
994 if n == 0 {
995 return Ok(vec![]);
996 }
997
998 let doppler_xs = broadened_cross_sections_from_base(
1000 energies,
1001 base_xs,
1002 resonance_data,
1003 temperature_k,
1004 instrument,
1005 )?;
1006
1007 let mut total_attenuation = vec![0.0f64; n];
1009 for (xs, &thickness) in doppler_xs.iter().zip(thicknesses.iter()) {
1010 if thickness <= 0.0 {
1011 continue;
1012 }
1013 for i in 0..n {
1014 total_attenuation[i] += thickness * xs[i];
1015 }
1016 }
1017 let transmission: Vec<f64> = total_attenuation.iter().map(|&att| (-att).exp()).collect();
1018
1019 if let Some(inst) = instrument {
1021 resolution::apply_resolution(energies, &transmission, &inst.resolution)
1022 .map_err(TransmissionError::from)
1023 } else {
1024 Ok(transmission)
1025 }
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030 use super::*;
1031 use nereids_core::types::Isotope;
1032 use nereids_endf::resonance::{LGroup, Resonance, ResonanceFormalism, ResonanceRange};
1033
1034 fn u238_single_resonance() -> ResonanceData {
1035 ResonanceData {
1036 isotope: Isotope::new(92, 238).unwrap(),
1037 za: 92238,
1038 awr: 236.006,
1039 ranges: vec![ResonanceRange {
1040 energy_low: 1e-5,
1041 energy_high: 1e4,
1042 resolved: true,
1043 formalism: ResonanceFormalism::ReichMoore,
1044 target_spin: 0.0,
1045 scattering_radius: 9.4285,
1046 naps: 1,
1047 l_groups: vec![LGroup {
1048 l: 0,
1049 awr: 236.006,
1050 apl: 0.0,
1051 qx: 0.0,
1052 lrx: 0,
1053 resonances: vec![Resonance {
1054 energy: 6.674,
1055 j: 0.5,
1056 gn: 1.493e-3,
1057 gg: 23.0e-3,
1058 gfa: 0.0,
1059 gfb: 0.0,
1060 }],
1061 }],
1062 rml: None,
1063 urr: None,
1064 ap_table: None,
1065 r_external: vec![],
1066 }],
1067 }
1068 }
1069
1070 #[test]
1071 fn test_beer_lambert_zero_thickness() {
1072 let xs = vec![100.0, 200.0, 300.0];
1073 let t = beer_lambert(&xs, 0.0);
1074 assert_eq!(t, vec![1.0, 1.0, 1.0]);
1075 }
1076
1077 #[test]
1078 fn test_beer_lambert_basic() {
1079 let xs = vec![100.0];
1082 let t = beer_lambert(&xs, 0.01);
1083 assert!(
1084 (t[0] - (-1.0_f64).exp()).abs() < 1e-10,
1085 "T = {}, expected {}",
1086 t[0],
1087 (-1.0_f64).exp()
1088 );
1089 }
1090
1091 #[test]
1092 fn test_beer_lambert_opaque() {
1093 let xs = vec![1000.0];
1095 let t = beer_lambert(&xs, 1.0);
1096 assert_eq!(t[0], 0.0, "T = {}, expected 0.0", t[0]);
1097 }
1098
1099 #[test]
1100 fn test_beer_lambert_multi_additive() {
1101 let xs1 = vec![100.0];
1106 let xs2 = vec![200.0];
1107 let t = beer_lambert_multi(&[&xs1, &xs2], &[0.01, 0.005]).unwrap();
1108 assert!(
1109 (t[0] - (-2.0_f64).exp()).abs() < 1e-10,
1110 "T = {}, expected {}",
1111 t[0],
1112 (-2.0_f64).exp()
1113 );
1114 }
1115
1116 #[test]
1117 fn test_transmission_dip_at_resonance() {
1118 let data = u238_single_resonance();
1121 let thickness = 0.001; let energies = [1.0, 3.0, 6.674, 10.0, 20.0];
1125 let xs: Vec<f64> = energies
1126 .iter()
1127 .map(|&e| reich_moore::cross_sections_at_energy(&data, e).total)
1128 .collect();
1129 let trans = beer_lambert(&xs, thickness);
1130
1131 let t_on_res = trans[2];
1133 let t_off_res = trans[0]; assert!(
1136 t_on_res < t_off_res,
1137 "On-resonance T ({}) should be < off-resonance T ({})",
1138 t_on_res,
1139 t_off_res
1140 );
1141
1142 assert!(
1144 t_on_res < 0.01,
1145 "On-resonance T ({}) should be very small",
1146 t_on_res
1147 );
1148 }
1149
1150 #[test]
1151 fn test_forward_model_no_broadening() {
1152 let data = u238_single_resonance();
1155 let thickness = 0.001;
1156
1157 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
1158
1159 let xs: Vec<f64> = energies
1161 .iter()
1162 .map(|&e| reich_moore::cross_sections_at_energy(&data, e).total)
1163 .collect();
1164 let t_direct = beer_lambert(&xs, thickness);
1165
1166 let sample = SampleParams::new(0.0, vec![(data, thickness)]).unwrap();
1168 let t_forward = forward_model(&energies, &sample, None).unwrap();
1169
1170 for i in 0..energies.len() {
1171 assert!(
1172 (t_direct[i] - t_forward[i]).abs() < 1e-10,
1173 "Mismatch at E={}: direct={}, forward={}",
1174 energies[i],
1175 t_direct[i],
1176 t_forward[i]
1177 );
1178 }
1179 }
1180
1181 #[test]
1182 fn test_forward_model_with_broadening() {
1183 let data = u238_single_resonance();
1186 let thickness = 0.0001; let energies: Vec<f64> = (0..401).map(|i| 5.0 + (i as f64) * 0.01).collect();
1189
1190 let sample_cold = SampleParams::new(0.0, vec![(data.clone(), thickness)]).unwrap();
1192 let t_cold = forward_model(&energies, &sample_cold, None).unwrap();
1193
1194 let sample_hot = SampleParams::new(300.0, vec![(data, thickness)]).unwrap();
1196 let t_hot = forward_model(&energies, &sample_hot, None).unwrap();
1197
1198 let min_cold = t_cold.iter().cloned().fold(f64::MAX, f64::min);
1200 let min_hot = t_hot.iter().cloned().fold(f64::MAX, f64::min);
1201
1202 assert!(
1204 min_hot > min_cold,
1205 "Broadened min T ({}) should be > unbroadened min T ({})",
1206 min_hot,
1207 min_cold
1208 );
1209 }
1210
1211 #[test]
1212 fn test_forward_model_multi_isotope() {
1213 let u238 = u238_single_resonance();
1215
1216 let other = ResonanceData {
1218 isotope: Isotope::new(1, 10).unwrap(),
1219 za: 1010,
1220 awr: 10.0,
1221 ranges: vec![ResonanceRange {
1222 energy_low: 0.0,
1223 energy_high: 100.0,
1224 resolved: true,
1225 formalism: ResonanceFormalism::ReichMoore,
1226 target_spin: 0.0,
1227 scattering_radius: 5.0,
1228 naps: 1,
1229 l_groups: vec![LGroup {
1230 l: 0,
1231 awr: 10.0,
1232 apl: 5.0,
1233 qx: 0.0,
1234 lrx: 0,
1235 resonances: vec![Resonance {
1236 energy: 20.0,
1237 j: 0.5,
1238 gn: 0.1,
1239 gg: 0.05,
1240 gfa: 0.0,
1241 gfb: 0.0,
1242 }],
1243 }],
1244 rml: None,
1245 urr: None,
1246 ap_table: None,
1247 r_external: vec![],
1248 }],
1249 };
1250
1251 let energies: Vec<f64> = (0..301).map(|i| 1.0 + (i as f64) * 0.1).collect();
1252
1253 let sample = SampleParams::new(0.0, vec![(u238, 0.0001), (other, 0.0001)]).unwrap();
1254 let t = forward_model(&energies, &sample, None).unwrap();
1255
1256 let idx_u238 = energies
1258 .iter()
1259 .position(|&e| (e - 6.7).abs() < 0.05)
1260 .unwrap();
1261 let idx_other = energies
1263 .iter()
1264 .position(|&e| (e - 20.0).abs() < 0.05)
1265 .unwrap();
1266 let idx_off = energies
1268 .iter()
1269 .position(|&e| (e - 15.0).abs() < 0.05)
1270 .unwrap();
1271
1272 assert!(
1274 t[idx_u238] < t[idx_off],
1275 "U-238 dip at 6.7 eV: T={}, off-res: T={}",
1276 t[idx_u238],
1277 t[idx_off]
1278 );
1279 assert!(
1280 t[idx_other] < t[idx_off],
1281 "Other dip at 20 eV: T={}, off-res: T={}",
1282 t[idx_other],
1283 t[idx_off]
1284 );
1285 }
1286
1287 #[test]
1288 fn test_broadened_xs_analytical_derivative() {
1289 let data = u238_single_resonance();
1292 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1293 let temperature = 300.0;
1294
1295 let base_xs =
1296 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1297 let (xs, dxs_dt) = broadened_cross_sections_with_analytical_derivative_from_base(
1298 &energies,
1299 &base_xs,
1300 std::slice::from_ref(&data),
1301 temperature,
1302 None,
1303 )
1304 .unwrap();
1305
1306 assert_eq!(xs.len(), 1, "one isotope");
1308 assert_eq!(dxs_dt.len(), 1, "one isotope derivative");
1309 assert_eq!(xs[0].len(), energies.len());
1310 assert_eq!(dxs_dt[0].len(), energies.len());
1311
1312 let idx_res = energies
1315 .iter()
1316 .position(|&e| (e - 6.674).abs() < 0.05)
1317 .unwrap();
1318 assert!(
1319 dxs_dt[0][idx_res].abs() > 0.0,
1320 "dσ/dT should be non-zero near resonance, got {}",
1321 dxs_dt[0][idx_res]
1322 );
1323
1324 let big_dt = 1.0;
1327 let xs_up = broadened_cross_sections(
1328 &energies,
1329 std::slice::from_ref(&data),
1330 temperature + big_dt,
1331 None,
1332 None,
1333 )
1334 .unwrap();
1335 let xs_down =
1336 broadened_cross_sections(&energies, &[data], temperature - big_dt, None, None).unwrap();
1337
1338 let manual_deriv: Vec<f64> = xs_up[0]
1339 .iter()
1340 .zip(xs_down[0].iter())
1341 .map(|(&u, &d)| (u - d) / (2.0 * big_dt))
1342 .collect();
1343
1344 let deriv_analytical = dxs_dt[0][idx_res];
1346 let deriv_coarse = manual_deriv[idx_res];
1347 let rel_err = (deriv_analytical - deriv_coarse).abs()
1348 / deriv_analytical.abs().max(deriv_coarse.abs()).max(1e-30);
1349 assert!(
1350 rel_err < 0.05,
1351 "Analytical vs FD derivatives disagree: analytical={}, coarse={}, rel_err={}",
1352 deriv_analytical,
1353 deriv_coarse,
1354 rel_err,
1355 );
1356 }
1357
1358 #[test]
1359 fn test_broadened_xs_analytical_derivative_low_temperature() {
1360 let data = u238_single_resonance();
1362 let energies: Vec<f64> = (0..51).map(|i| 5.0 + (i as f64) * 0.1).collect();
1363
1364 let base_xs =
1365 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1366
1367 let (xs_low, dxs_low) = broadened_cross_sections_with_analytical_derivative_from_base(
1369 &energies,
1370 &base_xs,
1371 std::slice::from_ref(&data),
1372 0.05,
1373 None,
1374 )
1375 .unwrap();
1376 assert!(!xs_low.is_empty());
1377 for deriv_vec in &dxs_low {
1380 for &d in deriv_vec {
1381 assert!(d.is_finite(), "derivative must be finite at T=0.05 K");
1382 }
1383 }
1384
1385 let (xs_zero, dxs_zero) = broadened_cross_sections_with_analytical_derivative_from_base(
1387 &energies,
1388 &base_xs,
1389 std::slice::from_ref(&data),
1390 0.0,
1391 None,
1392 )
1393 .unwrap();
1394 assert!(!xs_zero.is_empty());
1395 for deriv_vec in &dxs_zero {
1396 for &d in deriv_vec {
1397 assert!(d.is_finite(), "derivative must be finite at T=0.0 K");
1398 }
1399 }
1400 }
1401
1402 #[test]
1405 fn test_sample_params_valid() {
1406 let sample = SampleParams::new(300.0, vec![]).unwrap();
1407 assert!((sample.temperature_k() - 300.0).abs() < 1e-15);
1408 assert!(sample.isotopes().is_empty());
1409 }
1410
1411 #[test]
1412 fn test_sample_params_zero_temperature() {
1413 let sample = SampleParams::new(0.0, vec![]).unwrap();
1414 assert!((sample.temperature_k()).abs() < 1e-15);
1415 }
1416
1417 #[test]
1418 fn test_sample_params_rejects_negative_temperature() {
1419 let err = SampleParams::new(-1.0, vec![]).unwrap_err();
1420 assert_eq!(err, SampleParamsError::NegativeTemperature(-1.0));
1421 }
1422
1423 #[test]
1424 fn test_sample_params_rejects_nan_temperature() {
1425 let err = SampleParams::new(f64::NAN, vec![]).unwrap_err();
1426 assert!(matches!(err, SampleParamsError::NonFiniteTemperature(_)));
1427 }
1428
1429 #[test]
1430 fn test_sample_params_rejects_infinite_temperature() {
1431 let err = SampleParams::new(f64::INFINITY, vec![]).unwrap_err();
1432 assert!(matches!(err, SampleParamsError::NonFiniteTemperature(_)));
1433 }
1434
1435 #[test]
1436 fn test_sample_params_rejects_neg_infinite_temperature() {
1437 let err = SampleParams::new(f64::NEG_INFINITY, vec![]).unwrap_err();
1438 assert!(matches!(err, SampleParamsError::NonFiniteTemperature(_)));
1439 }
1440
1441 #[test]
1444 fn test_forward_model_from_base_xs_matches_forward_model() {
1445 let data = u238_single_resonance();
1446 let thickness = 0.0005;
1447 let temperature = 300.0;
1448 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1449
1450 let sample = SampleParams::new(temperature, vec![(data.clone(), thickness)]).unwrap();
1452 let t_ref = forward_model(&energies, &sample, None).unwrap();
1453
1454 let base_xs =
1456 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1457 let t_cached = forward_model_from_base_xs(
1458 &energies,
1459 &base_xs,
1460 std::slice::from_ref(&data),
1461 &[thickness],
1462 temperature,
1463 None,
1464 )
1465 .unwrap();
1466
1467 for (i, (&r, &c)) in t_ref.iter().zip(t_cached.iter()).enumerate() {
1468 assert!(
1469 (r - c).abs() < 1e-12,
1470 "Mismatch at E[{}]={}: ref={}, cached={}",
1471 i,
1472 energies[i],
1473 r,
1474 c
1475 );
1476 }
1477 }
1478
1479 #[test]
1480 fn test_broadened_from_base_matches_broadened() {
1481 let data = u238_single_resonance();
1482 let temperature = 300.0;
1483 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1484
1485 let xs_ref = broadened_cross_sections(
1486 &energies,
1487 std::slice::from_ref(&data),
1488 temperature,
1489 None,
1490 None,
1491 )
1492 .unwrap();
1493 let base_xs =
1494 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1495 let xs_cached = broadened_cross_sections_from_base(
1496 &energies,
1497 &base_xs,
1498 std::slice::from_ref(&data),
1499 temperature,
1500 None,
1501 )
1502 .unwrap();
1503
1504 assert_eq!(xs_ref.len(), xs_cached.len());
1505 for (r, c) in xs_ref[0].iter().zip(xs_cached[0].iter()) {
1506 assert!(
1507 (r - c).abs() < 1e-12,
1508 "broadened_from_base mismatch: ref={}, cached={}",
1509 r,
1510 c
1511 );
1512 }
1513 }
1514
1515 #[test]
1516 fn test_analytical_derivative_from_base_shape_and_finiteness() {
1517 let data = u238_single_resonance();
1520 let temperature = 300.0;
1521 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1522
1523 let base_xs =
1524 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1525 let (xs, dxs_dt) = broadened_cross_sections_with_analytical_derivative_from_base(
1526 &energies,
1527 &base_xs,
1528 std::slice::from_ref(&data),
1529 temperature,
1530 None,
1531 )
1532 .unwrap();
1533
1534 assert_eq!(xs.len(), 1);
1535 assert_eq!(dxs_dt.len(), 1);
1536 assert_eq!(xs[0].len(), energies.len());
1537 assert_eq!(dxs_dt[0].len(), energies.len());
1538
1539 for &v in &xs[0] {
1541 assert!(v.is_finite(), "XS must be finite, got {v}");
1542 }
1543 for &v in &dxs_dt[0] {
1544 assert!(v.is_finite(), "dXS/dT must be finite, got {v}");
1545 }
1546
1547 let xs_full = broadened_cross_sections(
1549 &energies,
1550 std::slice::from_ref(&data),
1551 temperature,
1552 None,
1553 None,
1554 )
1555 .unwrap();
1556 for (r, c) in xs_full[0].iter().zip(xs[0].iter()) {
1557 assert!(
1558 (r - c).abs() < 1e-12,
1559 "XS mismatch: full={}, from_base={}",
1560 r,
1561 c
1562 );
1563 }
1564 }
1565
1566 #[test]
1577 fn test_forward_model_resolution_after_beer_lambert() {
1578 let data = u238_single_resonance();
1579 let thickness = 0.0005; let temperature = 300.0;
1581
1582 let energies: Vec<f64> = (0..401).map(|i| 4.0 + (i as f64) * 0.015).collect();
1584
1585 let inst = InstrumentParams {
1586 resolution: resolution::ResolutionFunction::Gaussian(
1587 resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
1588 ),
1589 };
1590
1591 let unbroadened: Vec<f64> = energies
1595 .iter()
1596 .map(|&e| reich_moore::cross_sections_at_energy(&data, e).total)
1597 .collect();
1598 let doppler_params = doppler::DopplerParams::new(temperature, data.awr).unwrap();
1599 let sigma_d = doppler::doppler_broaden(&energies, &unbroadened, &doppler_params).unwrap();
1600
1601 let transmission: Vec<f64> = sigma_d.iter().map(|&s| (-thickness * s).exp()).collect();
1603
1604 let t_expected =
1606 resolution::apply_resolution(&energies, &transmission, &inst.resolution).unwrap();
1607
1608 let sigma_broadened =
1610 resolution::apply_resolution(&energies, &sigma_d, &inst.resolution).unwrap();
1611 let t_wrong: Vec<f64> = sigma_broadened
1612 .iter()
1613 .map(|&s| (-thickness * s).exp())
1614 .collect();
1615
1616 let sample = SampleParams::new(temperature, vec![(data, thickness)]).unwrap();
1618 let t_forward = forward_model(&energies, &sample, Some(&inst)).unwrap();
1619
1620 let interior = 20..energies.len() - 20; let mut max_err_correct = 0.0f64;
1625 let mut max_err_wrong = 0.0f64;
1626 for i in interior.clone() {
1627 let err_correct = (t_forward[i] - t_expected[i]).abs();
1628 let err_wrong = (t_forward[i] - t_wrong[i]).abs();
1629 max_err_correct = max_err_correct.max(err_correct);
1630 max_err_wrong = max_err_wrong.max(err_wrong);
1631 }
1632
1633 assert!(
1641 max_err_correct < max_err_wrong,
1642 "forward_model is closer to the WRONG ordering than the correct one. \
1643 Error vs correct = {max_err_correct}, error vs wrong = {max_err_wrong}"
1644 );
1645
1646 assert!(
1649 max_err_correct < max_err_wrong * 0.5,
1650 "forward_model should be clearly closer to the correct ordering. \
1651 Error vs correct = {max_err_correct}, error vs wrong = {max_err_wrong}, \
1652 ratio = {:.2}",
1653 max_err_correct / max_err_wrong
1654 );
1655
1656 let ordering_diff: f64 = interior
1659 .map(|i| (t_expected[i] - t_wrong[i]).abs())
1660 .fold(0.0f64, f64::max);
1661 assert!(
1662 ordering_diff > 1e-4,
1663 "The two orderings should differ measurably at the resonance dip, \
1664 but max diff = {ordering_diff}. Test parameters may be too weak."
1665 );
1666 }
1667
1668 #[test]
1672 fn test_broadened_xs_is_doppler_only_with_instrument() {
1673 let data = u238_single_resonance();
1674 let temperature = 300.0;
1675 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1676
1677 let inst = InstrumentParams {
1678 resolution: resolution::ResolutionFunction::Gaussian(
1679 resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
1680 ),
1681 };
1682
1683 let xs_with_inst = broadened_cross_sections(
1685 &energies,
1686 std::slice::from_ref(&data),
1687 temperature,
1688 Some(&inst),
1689 None,
1690 )
1691 .unwrap();
1692
1693 let xs_no_inst = broadened_cross_sections(
1695 &energies,
1696 std::slice::from_ref(&data),
1697 temperature,
1698 None,
1699 None,
1700 )
1701 .unwrap();
1702
1703 assert_eq!(xs_with_inst.len(), 1);
1707 assert_eq!(xs_no_inst.len(), 1);
1708 assert_eq!(xs_with_inst[0].len(), energies.len());
1709
1710 let sigma_resolved =
1712 resolution::apply_resolution(&energies, &xs_no_inst[0], &inst.resolution).unwrap();
1713
1714 let idx_dip = energies
1718 .iter()
1719 .position(|&e| (e - 6.674).abs() < 0.05)
1720 .unwrap();
1721 let diff_doppler = (xs_with_inst[0][idx_dip] - xs_no_inst[0][idx_dip]).abs();
1722 let diff_resolved = (xs_with_inst[0][idx_dip] - sigma_resolved[idx_dip]).abs();
1723
1724 assert!(
1727 diff_doppler < diff_resolved,
1728 "broadened_cross_sections with instrument should return Doppler-only σ, \
1729 not resolution-broadened σ. \
1730 diff(with_inst, no_inst) = {diff_doppler}, \
1731 diff(with_inst, resolved) = {diff_resolved}"
1732 );
1733 }
1734
1735 #[test]
1738 fn test_broadened_from_base_is_doppler_only_with_instrument() {
1739 let data = u238_single_resonance();
1740 let temperature = 300.0;
1741 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1742
1743 let inst = InstrumentParams {
1744 resolution: resolution::ResolutionFunction::Gaussian(
1745 resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
1746 ),
1747 };
1748
1749 let base_xs =
1750 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1751
1752 let xs_with_inst = broadened_cross_sections_from_base(
1754 &energies,
1755 &base_xs,
1756 std::slice::from_ref(&data),
1757 temperature,
1758 Some(&inst),
1759 )
1760 .unwrap();
1761
1762 let xs_no_inst = broadened_cross_sections_from_base(
1764 &energies,
1765 &base_xs,
1766 std::slice::from_ref(&data),
1767 temperature,
1768 None,
1769 )
1770 .unwrap();
1771
1772 let sigma_resolved =
1775 resolution::apply_resolution(&energies, &xs_no_inst[0], &inst.resolution).unwrap();
1776
1777 let idx_dip = energies
1778 .iter()
1779 .position(|&e| (e - 6.674).abs() < 0.05)
1780 .unwrap();
1781 let diff_doppler = (xs_with_inst[0][idx_dip] - xs_no_inst[0][idx_dip]).abs();
1782 let diff_resolved = (xs_with_inst[0][idx_dip] - sigma_resolved[idx_dip]).abs();
1783
1784 assert!(
1785 diff_doppler < diff_resolved,
1786 "broadened_cross_sections_from_base with instrument should return Doppler-only σ. \
1787 diff(with_inst, no_inst) = {diff_doppler}, \
1788 diff(with_inst, resolved) = {diff_resolved}"
1789 );
1790 }
1791
1792 #[test]
1797 fn test_forward_model_from_base_xs_matches_forward_model_with_resolution() {
1798 let data = u238_single_resonance();
1799 let thickness = 0.0005;
1800 let temperature = 300.0;
1801 let energies: Vec<f64> = (0..401).map(|i| 4.0 + (i as f64) * 0.015).collect();
1802
1803 let inst = InstrumentParams {
1804 resolution: resolution::ResolutionFunction::Gaussian(
1805 resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
1806 ),
1807 };
1808
1809 let sample = SampleParams::new(temperature, vec![(data.clone(), thickness)]).unwrap();
1811 let t_ref = forward_model(&energies, &sample, Some(&inst)).unwrap();
1812
1813 let base_xs =
1815 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1816 let t_base = forward_model_from_base_xs(
1817 &energies,
1818 &base_xs,
1819 std::slice::from_ref(&data),
1820 &[thickness],
1821 temperature,
1822 Some(&inst),
1823 )
1824 .unwrap();
1825
1826 let interior = 20..energies.len() - 20;
1830 let mut max_err = 0.0f64;
1831 for i in interior.clone() {
1832 max_err = max_err.max((t_ref[i] - t_base[i]).abs());
1833 }
1834 assert!(
1835 max_err < 0.02,
1836 "forward_model_from_base_xs with resolution should match \
1837 forward_model. Max error = {max_err}"
1838 );
1839
1840 let t_no_res = forward_model_from_base_xs(
1842 &energies,
1843 &base_xs,
1844 std::slice::from_ref(&data),
1845 &[thickness],
1846 temperature,
1847 None,
1848 )
1849 .unwrap();
1850 let res_diff: f64 = interior
1851 .map(|i| (t_base[i] - t_no_res[i]).abs())
1852 .fold(0.0f64, f64::max);
1853 assert!(
1854 res_diff > 1e-4,
1855 "Resolution should make a measurable difference, but max diff = {res_diff}"
1856 );
1857 }
1858
1859 #[test]
1862 fn test_forward_model_from_base_xs_no_resolution_unchanged() {
1863 let data = u238_single_resonance();
1864 let thickness = 0.0005;
1865 let temperature = 300.0;
1866 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1867
1868 let sample = SampleParams::new(temperature, vec![(data.clone(), thickness)]).unwrap();
1869 let t_ref = forward_model(&energies, &sample, None).unwrap();
1870
1871 let base_xs =
1872 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1873 let t_base = forward_model_from_base_xs(
1874 &energies,
1875 &base_xs,
1876 std::slice::from_ref(&data),
1877 &[thickness],
1878 temperature,
1879 None,
1880 )
1881 .unwrap();
1882
1883 for (i, (&r, &b)) in t_ref.iter().zip(t_base.iter()).enumerate() {
1884 assert!(
1885 (r - b).abs() < 1e-12,
1886 "No-resolution mismatch at E[{i}]={}: ref={r}, base={b}",
1887 energies[i]
1888 );
1889 }
1890 }
1891
1892 #[test]
1898 fn test_derivative_helper_is_doppler_only_with_instrument() {
1899 let data = u238_single_resonance();
1900 let temperature = 300.0;
1901 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1902
1903 let inst = InstrumentParams {
1904 resolution: resolution::ResolutionFunction::Gaussian(
1905 resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
1906 ),
1907 };
1908
1909 let base_xs =
1910 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1911
1912 let (xs_inst, dxs_inst) = broadened_cross_sections_with_analytical_derivative_from_base(
1914 &energies,
1915 &base_xs,
1916 std::slice::from_ref(&data),
1917 temperature,
1918 Some(&inst),
1919 )
1920 .unwrap();
1921
1922 let (xs_none, dxs_none) = broadened_cross_sections_with_analytical_derivative_from_base(
1924 &energies,
1925 &base_xs,
1926 std::slice::from_ref(&data),
1927 temperature,
1928 None,
1929 )
1930 .unwrap();
1931
1932 assert_eq!(xs_inst.len(), 1);
1933 assert_eq!(dxs_inst.len(), 1);
1934
1935 let sigma_resolved =
1939 resolution::apply_resolution(&energies, &xs_none[0], &inst.resolution).unwrap();
1940
1941 let idx_dip = energies
1942 .iter()
1943 .position(|&e| (e - 6.674).abs() < 0.05)
1944 .unwrap();
1945
1946 let diff_doppler = (xs_inst[0][idx_dip] - xs_none[0][idx_dip]).abs();
1949 let diff_resolved = (xs_inst[0][idx_dip] - sigma_resolved[idx_dip]).abs();
1950 assert!(
1951 diff_doppler < diff_resolved,
1952 "derivative helper σ with instrument should be Doppler-only. \
1953 diff(inst, none) = {diff_doppler}, diff(inst, resolved) = {diff_resolved}"
1954 );
1955
1956 let dxs_resolved =
1958 resolution::apply_resolution(&energies, &dxs_none[0], &inst.resolution).unwrap();
1959 let ddiff_doppler = (dxs_inst[0][idx_dip] - dxs_none[0][idx_dip]).abs();
1960 let ddiff_resolved = (dxs_inst[0][idx_dip] - dxs_resolved[idx_dip]).abs();
1961 assert!(
1962 ddiff_doppler < ddiff_resolved,
1963 "derivative helper ∂σ/∂T with instrument should be Doppler-only. \
1964 diff(inst, none) = {ddiff_doppler}, diff(inst, resolved) = {ddiff_resolved}"
1965 );
1966 }
1967
1968 #[test]
1971 fn test_derivative_helper_no_resolution_unchanged() {
1972 let data = u238_single_resonance();
1973 let temperature = 300.0;
1974 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1975
1976 let base_xs =
1977 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1978
1979 let (xs, dxs) = broadened_cross_sections_with_analytical_derivative_from_base(
1980 &energies,
1981 &base_xs,
1982 std::slice::from_ref(&data),
1983 temperature,
1984 None,
1985 )
1986 .unwrap();
1987
1988 let xs_ref = broadened_cross_sections(
1990 &energies,
1991 std::slice::from_ref(&data),
1992 temperature,
1993 None,
1994 None,
1995 )
1996 .unwrap();
1997
1998 for (i, (&a, &b)) in xs[0].iter().zip(xs_ref[0].iter()).enumerate() {
1999 assert!(
2000 (a - b).abs() < 1e-12,
2001 "σ mismatch at E[{i}]: derivative_helper={a}, broadened={b}"
2002 );
2003 }
2004
2005 assert_eq!(dxs.len(), 1);
2007 assert_eq!(dxs[0].len(), energies.len());
2008 let idx_res = energies
2009 .iter()
2010 .position(|&e| (e - 6.674).abs() < 0.05)
2011 .unwrap();
2012 assert!(
2013 dxs[0][idx_res].abs() > 0.0,
2014 "∂σ/∂T should be non-zero near resonance"
2015 );
2016 for &d in &dxs[0] {
2017 assert!(d.is_finite(), "∂σ/∂T must be finite");
2018 }
2019 }
2020}