1use std::fmt;
27use std::sync::atomic::{AtomicBool, Ordering};
28
29use rayon::prelude::*;
30
31use nereids_endf::resonance::ResonanceData;
32
33use crate::continuous_doppler;
34use crate::doppler::DopplerParamsError;
35use crate::reich_moore;
36use crate::resolution::{self, ResolutionError, ResolutionFunction};
37
38fn build_aux_grid(
49 energies: &[f64],
50 instrument: Option<&InstrumentParams>,
51 resonance_data: &[&ResonanceData],
52) -> Option<(Vec<f64>, Vec<usize>)> {
53 instrument.and_then(|inst| {
54 let (ext_e, di) = if let ResolutionFunction::Gaussian(ref params) = inst.resolution {
55 let use_intermediates = if energies.len() >= 2 {
66 let n = energies.len();
67 let check_indices = [0, n / 4, n / 2, 3 * n / 4, n - 1];
68 let n_pw_linear = check_indices
69 .iter()
70 .filter(|&&i| {
71 let e = energies[i];
72 let wg = params.gaussian_width(e);
73 let we = params.exp_width(e);
74 we < 1e-60 || wg / (2.0 * we) > 2.5
75 })
76 .count();
77 n_pw_linear >= 3
79 } else {
80 true
81 };
82
83 let resonances = extract_resonance_widths(resonance_data);
88
89 if use_intermediates {
90 crate::auxiliary_grid::build_extended_grid(energies, Some(params), &resonances)
91 } else {
92 crate::auxiliary_grid::build_extended_grid_boundary_only(energies, Some(params))
93 }
94 } else {
95 crate::auxiliary_grid::build_extended_grid_for(energies, &inst.resolution)
98 };
99 (ext_e.len() > energies.len()).then_some((ext_e, di))
100 })
101}
102
103fn 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 {
123 for res in &lg.resonances {
124 let gd = res.gn.abs() + res.gg.abs() + res.gfa.abs() + res.gfb.abs();
125 if gd > 0.0 {
126 pairs.push((res.energy, gd));
127 }
128 }
129 }
130 }
131 }
132 pairs
133}
134
135pub fn resonance_center_energies(resonance_data: &[&ResonanceData]) -> Vec<f64> {
151 let mut e: Vec<f64> = extract_resonance_widths(resonance_data)
152 .into_iter()
153 .map(|(energy, _gd)| energy)
154 .collect();
155 e.sort_by(f64::total_cmp);
156 e.dedup();
158 e
159}
160
161fn build_extended_xs_from_base(
172 ext_energies: &[f64],
173 data_indices: &[usize],
174 is_data_point: &[bool],
175 xs_raw: &[f64],
176 rd: &ResonanceData,
177) -> Vec<f64> {
178 let mut xs_ext = vec![0.0f64; ext_energies.len()];
179 for (data_i, &ext_i) in data_indices.iter().enumerate() {
180 xs_ext[ext_i] = xs_raw[data_i];
181 }
182 for (j, &e) in ext_energies.iter().enumerate() {
183 if !is_data_point[j] {
184 xs_ext[j] = reich_moore::cross_sections_at_energy(rd, e).total;
185 }
186 }
187 xs_ext
188}
189
190fn validate_base_xs(
195 energies: &[f64],
196 base_xs: &[Vec<f64>],
197 resonance_data: &[ResonanceData],
198) -> Result<(), TransmissionError> {
199 if base_xs.len() != resonance_data.len() {
200 return Err(TransmissionError::InputMismatch(format!(
201 "base_xs has {} isotopes but resonance_data has {}",
202 base_xs.len(),
203 resonance_data.len(),
204 )));
205 }
206 for (i, row) in base_xs.iter().enumerate() {
207 if row.len() != energies.len() {
208 return Err(TransmissionError::InputMismatch(format!(
209 "base_xs[{i}] has {} energies but expected {}",
210 row.len(),
211 energies.len(),
212 )));
213 }
214 }
215 Ok(())
216}
217
218fn data_point_mask(ext_grid: Option<&(Vec<f64>, Vec<usize>)>) -> Option<Vec<bool>> {
222 ext_grid.map(|(ext_e, di)| {
223 let mut mask = vec![false; ext_e.len()];
224 for &idx in di {
225 mask[idx] = true;
226 }
227 mask
228 })
229}
230
231fn working_grid_layout<'a>(
234 energies: &'a [f64],
235 ext_grid: Option<&'a (Vec<f64>, Vec<usize>)>,
236) -> (&'a [f64], WorkingGridLayout) {
237 match ext_grid {
238 Some((ext_e, di)) => (
239 ext_e.as_slice(),
240 WorkingGridLayout {
241 energies: ext_e.clone(),
242 data_indices: di.clone(),
243 },
244 ),
245 None => (energies, WorkingGridLayout::identity(energies)),
246 }
247}
248
249pub fn resolution_working_grid(
264 energies: &[f64],
265 instrument: Option<&InstrumentParams>,
266 resonance_data: &[&ResonanceData],
267) -> Result<WorkingGridLayout, TransmissionError> {
268 if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
269 return Err(ResolutionError::UnsortedEnergies.into());
270 }
271 let ext_grid = build_aux_grid(energies, instrument, resonance_data);
272 let (_, layout) = working_grid_layout(energies, ext_grid.as_ref());
273 Ok(layout)
274}
275
276#[derive(Debug)]
278pub enum TransmissionError {
279 Resolution(ResolutionError),
281 Doppler(DopplerParamsError),
283 DopplerBroadening(crate::doppler::DopplerError),
285 Cancelled,
287 InputMismatch(String),
289}
290
291impl fmt::Display for TransmissionError {
292 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293 match self {
294 Self::Resolution(e) => write!(f, "resolution broadening error: {}", e),
295 Self::Doppler(e) => write!(f, "Doppler parameter error: {}", e),
296 Self::DopplerBroadening(e) => write!(f, "Doppler broadening error: {}", e),
297 Self::Cancelled => write!(f, "computation cancelled"),
298 Self::InputMismatch(msg) => write!(f, "input mismatch: {}", msg),
299 }
300 }
301}
302
303impl std::error::Error for TransmissionError {
304 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
305 match self {
306 Self::Resolution(e) => Some(e),
307 Self::Doppler(e) => Some(e),
308 Self::DopplerBroadening(e) => Some(e),
309 Self::Cancelled => None,
310 Self::InputMismatch(_) => None,
311 }
312 }
313}
314
315impl From<ResolutionError> for TransmissionError {
316 fn from(e: ResolutionError) -> Self {
317 Self::Resolution(e)
318 }
319}
320
321impl From<DopplerParamsError> for TransmissionError {
322 fn from(e: DopplerParamsError) -> Self {
323 Self::Doppler(e)
324 }
325}
326
327impl From<crate::doppler::DopplerError> for TransmissionError {
328 fn from(e: crate::doppler::DopplerError) -> Self {
329 Self::DopplerBroadening(e)
330 }
331}
332
333pub type BroadenedXsWithDerivative = (Vec<Vec<f64>>, Vec<Vec<f64>>);
339
340#[derive(Debug, Clone)]
348pub struct WorkingGridLayout {
349 pub energies: Vec<f64>,
352 pub data_indices: Vec<usize>,
355}
356
357impl WorkingGridLayout {
358 pub fn identity(energies: &[f64]) -> Self {
359 Self {
360 energies: energies.to_vec(),
361 data_indices: (0..energies.len()).collect(),
362 }
363 }
364
365 pub fn is_identity(&self) -> bool {
368 self.data_indices.len() == self.energies.len()
369 && self
370 .data_indices
371 .iter()
372 .enumerate()
373 .all(|(i, &idx)| i == idx)
374 }
375
376 pub fn extract(&self, working: &[f64]) -> Vec<f64> {
378 self.data_indices.iter().map(|&i| working[i]).collect()
379 }
380
381 pub fn extract_owned(&self, working: Vec<f64>) -> Vec<f64> {
382 if self.is_identity() {
383 working
384 } else {
385 self.extract(&working)
386 }
387 }
388}
389
390pub struct WorkingGridXs {
397 pub sigma: Vec<Vec<f64>>,
399 pub layout: WorkingGridLayout,
401}
402
403pub struct WorkingGridXsWithDerivative {
411 pub sigma: Vec<Vec<f64>>,
413 pub dsigma_dt: Vec<Vec<f64>>,
415 pub layout: WorkingGridLayout,
417}
418
419pub fn beer_lambert(cross_sections: &[f64], thickness: f64) -> Vec<f64> {
430 cross_sections
431 .iter()
432 .map(|&sigma| (-thickness * sigma).exp())
433 .collect()
434}
435
436pub fn beer_lambert_multi(
448 cross_sections_per_isotope: &[&[f64]],
449 thicknesses: &[f64],
450) -> Result<Vec<f64>, TransmissionError> {
451 if cross_sections_per_isotope.len() != thicknesses.len() {
452 return Err(TransmissionError::InputMismatch(format!(
453 "cross_sections_per_isotope length ({}) must match thicknesses length ({})",
454 cross_sections_per_isotope.len(),
455 thicknesses.len()
456 )));
457 }
458 if cross_sections_per_isotope.is_empty() {
459 return Err(TransmissionError::InputMismatch(
460 "cross_sections_per_isotope must not be empty".into(),
461 ));
462 }
463
464 let n_energies = cross_sections_per_isotope[0].len();
465 for (k, sigma) in cross_sections_per_isotope.iter().enumerate() {
466 if sigma.len() != n_energies {
467 return Err(TransmissionError::InputMismatch(format!(
468 "cross_sections_per_isotope[{}] length ({}) must match [0] length ({})",
469 k,
470 sigma.len(),
471 n_energies
472 )));
473 }
474 }
475
476 Ok((0..n_energies)
477 .map(|i| {
478 let total_attenuation: f64 = cross_sections_per_isotope
479 .iter()
480 .zip(thicknesses.iter())
481 .map(|(sigma, &thick)| thick * sigma[i])
482 .sum();
483 (-total_attenuation).exp()
484 })
485 .collect())
486}
487
488#[derive(Debug, PartialEq)]
490pub enum SampleParamsError {
491 NonFiniteTemperature(f64),
493 NegativeTemperature(f64),
495}
496
497impl fmt::Display for SampleParamsError {
498 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
499 match self {
500 Self::NonFiniteTemperature(v) => {
501 write!(f, "temperature must be finite, got {v}")
502 }
503 Self::NegativeTemperature(v) => {
504 write!(f, "temperature must be non-negative, got {v}")
505 }
506 }
507 }
508}
509
510impl std::error::Error for SampleParamsError {}
511
512#[derive(Debug, Clone)]
514pub struct SampleParams {
515 temperature_k: f64,
517 isotopes: Vec<(ResonanceData, f64)>,
519}
520
521impl SampleParams {
522 pub fn new(
529 temperature_k: f64,
530 isotopes: Vec<(ResonanceData, f64)>,
531 ) -> Result<Self, SampleParamsError> {
532 if !temperature_k.is_finite() {
533 return Err(SampleParamsError::NonFiniteTemperature(temperature_k));
534 }
535 if temperature_k < 0.0 {
536 return Err(SampleParamsError::NegativeTemperature(temperature_k));
537 }
538 Ok(Self {
539 temperature_k,
540 isotopes,
541 })
542 }
543
544 #[must_use]
546 pub fn temperature_k(&self) -> f64 {
547 self.temperature_k
548 }
549
550 #[must_use]
552 pub fn isotopes(&self) -> &[(ResonanceData, f64)] {
553 &self.isotopes
554 }
555}
556
557#[derive(Debug, Clone)]
559pub struct InstrumentParams {
560 pub resolution: ResolutionFunction,
562}
563
564pub fn forward_model(
588 energies: &[f64],
589 sample: &SampleParams,
590 instrument: Option<&InstrumentParams>,
591) -> Result<Vec<f64>, TransmissionError> {
592 let n = energies.len();
593 if n == 0 {
594 return Ok(vec![]);
595 }
596
597 if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
601 return Err(ResolutionError::UnsortedEnergies.into());
602 }
603
604 let active_rd: Vec<&ResonanceData> = sample
608 .isotopes()
609 .iter()
610 .filter(|(_, t)| *t > 0.0)
611 .map(|(rd, _)| rd)
612 .collect();
613 let ext_grid = build_aux_grid(energies, instrument, &active_rd);
614
615 let (work_energies, work_len): (&[f64], usize) = if let Some((ref ext_e, _)) = ext_grid {
637 (ext_e.as_slice(), ext_e.len())
638 } else {
639 (energies, n)
640 };
641
642 let doppler_xs: Result<Vec<(Vec<f64>, f64)>, TransmissionError> = sample
643 .isotopes()
644 .par_iter()
645 .filter(|(_, thickness)| *thickness > 0.0)
646 .map(|(res_data, thickness)| {
647 let after_doppler =
648 continuous_doppler::broaden(work_energies, res_data, sample.temperature_k())?;
649 Ok((after_doppler, *thickness))
650 })
651 .collect();
652 let doppler_xs = doppler_xs?;
653
654 let mut total_attenuation = vec![0.0f64; work_len];
656 for (xs, thickness) in &doppler_xs {
657 for i in 0..work_len {
658 total_attenuation[i] += thickness * xs[i];
659 }
660 }
661
662 let transmission: Vec<f64> = total_attenuation.iter().map(|&att| (-att).exp()).collect();
664
665 if let Some(inst) = instrument {
667 let t_broadened =
668 resolution::apply_resolution_presorted(work_energies, &transmission, &inst.resolution);
669 if let Some((_, ref data_indices)) = ext_grid {
670 Ok(data_indices.iter().map(|&i| t_broadened[i]).collect())
671 } else {
672 Ok(t_broadened)
673 }
674 } else {
675 Ok(transmission)
676 }
677}
678
679pub fn broadened_cross_sections(
716 energies: &[f64],
717 resonance_data: &[ResonanceData],
718 temperature_k: f64,
719 instrument: Option<&InstrumentParams>,
720 cancel: Option<&AtomicBool>,
721) -> Result<Vec<Vec<f64>>, TransmissionError> {
722 let WorkingGridXs { sigma, layout } = broadened_cross_sections_on_working_grid(
725 energies,
726 resonance_data,
727 temperature_k,
728 instrument,
729 cancel,
730 )?;
731 Ok(sigma.iter().map(|s| layout.extract(s)).collect())
732}
733
734pub fn broadened_cross_sections_on_working_grid(
744 energies: &[f64],
745 resonance_data: &[ResonanceData],
746 temperature_k: f64,
747 instrument: Option<&InstrumentParams>,
748 cancel: Option<&AtomicBool>,
749) -> Result<WorkingGridXs, TransmissionError> {
750 if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
752 return Err(ResolutionError::UnsortedEnergies.into());
753 }
754
755 let rd_refs: Vec<&ResonanceData> = resonance_data.iter().collect();
761 let ext_grid = build_aux_grid(energies, instrument, &rd_refs);
762 let (work_energies, layout) = working_grid_layout(energies, ext_grid.as_ref());
763
764 let result: Result<Vec<Vec<f64>>, TransmissionError> = resonance_data
770 .par_iter()
771 .map(|rd| {
772 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
774 return Err(TransmissionError::Cancelled);
775 }
776
777 continuous_doppler::broaden(work_energies, rd, temperature_k).map_err(Into::into)
778 })
779 .collect();
780
781 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
784 return Err(TransmissionError::Cancelled);
785 }
786
787 Ok(WorkingGridXs {
788 sigma: result?,
789 layout,
790 })
791}
792
793pub fn broadened_cross_sections_for_transmission(
822 energies: &[f64],
823 resonance_data: &[ResonanceData],
824 temperature_k: f64,
825 instrument: &InstrumentParams,
826 thickness_atoms_barn: f64,
827 cancel: Option<&AtomicBool>,
828) -> Result<Vec<Vec<f64>>, TransmissionError> {
829 if !thickness_atoms_barn.is_finite() || thickness_atoms_barn <= 0.0 {
834 return Err(TransmissionError::InputMismatch(format!(
835 "thickness_atoms_barn must be finite and > 0, got {thickness_atoms_barn}"
836 )));
837 }
838 if !energies.windows(2).all(|w| w[0] <= w[1]) {
839 return Err(ResolutionError::UnsortedEnergies.into());
840 }
841
842 let rd_refs: Vec<&ResonanceData> = resonance_data.iter().collect();
843 let ext_grid = build_aux_grid(energies, Some(instrument), &rd_refs);
844 let nd = thickness_atoms_barn;
845
846 let result: Result<Vec<Vec<f64>>, TransmissionError> = resonance_data
847 .par_iter()
848 .map(|rd| {
849 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
850 return Err(TransmissionError::Cancelled);
851 }
852
853 let sigma_eff = if let Some((ref ext_energies, ref data_indices)) = ext_grid {
854 let after_doppler = continuous_doppler::broaden(ext_energies, rd, temperature_k)?;
859
860 let transmission: Vec<f64> = after_doppler
862 .iter()
863 .map(|&sigma| (-nd * sigma).exp())
864 .collect();
865
866 let t_broadened = resolution::apply_resolution_presorted(
868 ext_energies,
869 &transmission,
870 &instrument.resolution,
871 );
872
873 data_indices
875 .iter()
876 .map(|&i| {
877 let t = t_broadened[i].clamp(1e-30, 1.0);
878 -t.ln() / nd
879 })
880 .collect()
881 } else {
882 let after_doppler = continuous_doppler::broaden(energies, rd, temperature_k)?;
885
886 let transmission: Vec<f64> = after_doppler
887 .iter()
888 .map(|&sigma| (-nd * sigma).exp())
889 .collect();
890
891 let t_broadened = resolution::apply_resolution_presorted(
892 energies,
893 &transmission,
894 &instrument.resolution,
895 );
896
897 t_broadened
898 .iter()
899 .map(|&t| {
900 let t_clamped = t.clamp(1e-30, 1.0);
901 -t_clamped.ln() / nd
902 })
903 .collect()
904 };
905
906 Ok(sigma_eff)
907 })
908 .collect();
909
910 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
911 return Err(TransmissionError::Cancelled);
912 }
913
914 result
915}
916
917pub fn unbroadened_cross_sections(
927 energies: &[f64],
928 resonance_data: &[ResonanceData],
929 cancel: Option<&AtomicBool>,
930) -> Result<Vec<Vec<f64>>, TransmissionError> {
931 let result: Result<Vec<Vec<f64>>, TransmissionError> = resonance_data
932 .par_iter()
933 .map(|rd| {
934 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
935 return Err(TransmissionError::Cancelled);
936 }
937 let xs: Vec<f64> = energies
938 .iter()
939 .map(|&e| reich_moore::cross_sections_at_energy(rd, e).total)
940 .collect();
941 Ok(xs)
942 })
943 .collect();
944
945 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
946 return Err(TransmissionError::Cancelled);
947 }
948 result
949}
950
951pub fn broadened_cross_sections_from_base(
962 energies: &[f64],
963 base_xs: &[Vec<f64>],
964 resonance_data: &[ResonanceData],
965 temperature_k: f64,
966 instrument: Option<&InstrumentParams>,
967) -> Result<Vec<Vec<f64>>, TransmissionError> {
968 let WorkingGridXs { sigma, layout } = broadened_cross_sections_from_base_on_working_grid(
973 energies,
974 base_xs,
975 resonance_data,
976 temperature_k,
977 instrument,
978 )?;
979 Ok(sigma.iter().map(|s| layout.extract(s)).collect())
980}
981
982pub fn broadened_cross_sections_from_base_on_working_grid(
991 energies: &[f64],
992 base_xs: &[Vec<f64>],
993 resonance_data: &[ResonanceData],
994 temperature_k: f64,
995 instrument: Option<&InstrumentParams>,
996) -> Result<WorkingGridXs, TransmissionError> {
997 validate_base_xs(energies, base_xs, resonance_data)?;
998 if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
999 return Err(ResolutionError::UnsortedEnergies.into());
1000 }
1001
1002 let rd_refs: Vec<&ResonanceData> = resonance_data.iter().collect();
1008 let ext_grid = build_aux_grid(energies, instrument, &rd_refs);
1009 let is_data_point = data_point_mask(ext_grid.as_ref());
1010 let (work_energies, layout) = working_grid_layout(energies, ext_grid.as_ref());
1011
1012 let sigma: Result<Vec<Vec<f64>>, TransmissionError> = base_xs
1015 .par_iter()
1016 .zip(resonance_data.par_iter())
1017 .map(|(xs_raw, rd)| {
1018 if temperature_k > 0.0 {
1019 return continuous_doppler::broaden(work_energies, rd, temperature_k)
1025 .map_err(Into::into);
1026 }
1027 let xs_work = if let Some((ref ext_energies, ref data_indices)) = ext_grid {
1028 let mask = is_data_point.as_ref().unwrap();
1029 build_extended_xs_from_base(ext_energies, data_indices, mask, xs_raw, rd)
1030 } else {
1031 xs_raw.clone()
1032 };
1033 Ok(xs_work)
1034 })
1035 .collect();
1036
1037 Ok(WorkingGridXs {
1038 sigma: sigma?,
1039 layout,
1040 })
1041}
1042
1043pub fn broadened_cross_sections_with_analytical_derivative_from_base(
1055 energies: &[f64],
1056 base_xs: &[Vec<f64>],
1057 resonance_data: &[ResonanceData],
1058 temperature_k: f64,
1059 instrument: Option<&InstrumentParams>,
1060) -> Result<BroadenedXsWithDerivative, TransmissionError> {
1061 let WorkingGridXsWithDerivative {
1064 sigma,
1065 dsigma_dt,
1066 layout,
1067 } = broadened_cross_sections_with_analytical_derivative_from_base_on_working_grid(
1068 energies,
1069 base_xs,
1070 resonance_data,
1071 temperature_k,
1072 instrument,
1073 )?;
1074 let xs_all = sigma.iter().map(|s| layout.extract(s)).collect();
1075 let dxs_all = dsigma_dt.iter().map(|d| layout.extract(d)).collect();
1076 Ok((xs_all, dxs_all))
1077}
1078
1079pub fn broadened_cross_sections_with_analytical_derivative_from_base_on_working_grid(
1088 energies: &[f64],
1089 base_xs: &[Vec<f64>],
1090 resonance_data: &[ResonanceData],
1091 temperature_k: f64,
1092 instrument: Option<&InstrumentParams>,
1093) -> Result<WorkingGridXsWithDerivative, TransmissionError> {
1094 validate_base_xs(energies, base_xs, resonance_data)?;
1095 if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
1096 return Err(ResolutionError::UnsortedEnergies.into());
1097 }
1098
1099 let rd_refs: Vec<&ResonanceData> = resonance_data.iter().collect();
1101 let ext_grid = build_aux_grid(energies, instrument, &rd_refs);
1102 let is_data_point = data_point_mask(ext_grid.as_ref());
1103 let (work_energies, layout) = working_grid_layout(energies, ext_grid.as_ref());
1104
1105 type IsotopeXsDxs = Result<(Vec<f64>, Vec<f64>), TransmissionError>;
1109 let results: Vec<IsotopeXsDxs> = base_xs
1110 .par_iter()
1111 .zip(resonance_data.par_iter())
1112 .map(|(xs_raw, rd)| {
1113 if temperature_k > 0.0 {
1114 return continuous_doppler::broaden_with_derivative(
1118 work_energies,
1119 rd,
1120 temperature_k,
1121 )
1122 .map_err(Into::into);
1123 }
1124 let xs_work = if let Some((ref ext_energies, ref data_indices)) = ext_grid {
1125 let mask = is_data_point.as_ref().unwrap();
1126 build_extended_xs_from_base(ext_energies, data_indices, mask, xs_raw, rd)
1127 } else {
1128 xs_raw.clone()
1129 };
1130 let zeros = vec![0.0; work_energies.len()];
1131 Ok((xs_work, zeros))
1132 })
1133 .collect();
1134
1135 let mut sigma = Vec::with_capacity(base_xs.len());
1137 let mut dsigma_dt = Vec::with_capacity(base_xs.len());
1138 for r in results {
1139 let (xs, dxs) = r?;
1140 sigma.push(xs);
1141 dsigma_dt.push(dxs);
1142 }
1143 Ok(WorkingGridXsWithDerivative {
1144 sigma,
1145 dsigma_dt,
1146 layout,
1147 })
1148}
1149
1150pub fn forward_model_from_base_xs(
1174 energies: &[f64],
1175 base_xs: &[Vec<f64>],
1176 resonance_data: &[ResonanceData],
1177 thicknesses: &[f64],
1178 temperature_k: f64,
1179 instrument: Option<&InstrumentParams>,
1180) -> Result<Vec<f64>, TransmissionError> {
1181 if base_xs.len() != resonance_data.len() || thicknesses.len() != resonance_data.len() {
1182 return Err(TransmissionError::InputMismatch(format!(
1183 "forward_model_from_base_xs: base_xs({})/thicknesses({})/resonance_data({}) length mismatch",
1184 base_xs.len(),
1185 thicknesses.len(),
1186 resonance_data.len(),
1187 )));
1188 }
1189 for (i, row) in base_xs.iter().enumerate() {
1190 if row.len() != energies.len() {
1191 return Err(TransmissionError::InputMismatch(format!(
1192 "base_xs[{i}] has {} energies but expected {}",
1193 row.len(),
1194 energies.len(),
1195 )));
1196 }
1197 }
1198 let n = energies.len();
1199 if n == 0 {
1200 return Ok(vec![]);
1201 }
1202
1203 if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
1206 return Err(ResolutionError::UnsortedEnergies.into());
1207 }
1208
1209 let rd_refs: Vec<&ResonanceData> = resonance_data.iter().collect();
1212 let ext_grid = build_aux_grid(energies, instrument, &rd_refs);
1213 let is_data_point: Option<Vec<bool>> = ext_grid.as_ref().map(|(ext_e, di)| {
1214 let mut mask = vec![false; ext_e.len()];
1215 for &idx in di {
1216 mask[idx] = true;
1217 }
1218 mask
1219 });
1220
1221 let (work_energies, work_len): (&[f64], usize) = if let Some((ref ext_e, _)) = ext_grid {
1223 (ext_e.as_slice(), ext_e.len())
1224 } else {
1225 (energies, n)
1226 };
1227
1228 let doppler_xs: Result<Vec<Vec<f64>>, TransmissionError> = base_xs
1231 .par_iter()
1232 .zip(resonance_data.par_iter())
1233 .map(|(xs_raw, rd)| {
1234 if temperature_k > 0.0 {
1235 return continuous_doppler::broaden(work_energies, rd, temperature_k)
1236 .map_err(Into::into);
1237 }
1238 let xs_ext = if let Some((ref ext_energies, ref data_indices)) = ext_grid {
1239 let mask = is_data_point.as_ref().unwrap();
1240 build_extended_xs_from_base(ext_energies, data_indices, mask, xs_raw, rd)
1241 } else {
1242 xs_raw.clone()
1243 };
1244 Ok(xs_ext)
1245 })
1246 .collect();
1247 let doppler_xs = doppler_xs?;
1248
1249 let mut total_attenuation = vec![0.0f64; work_len];
1251 for (xs, &thickness) in doppler_xs.iter().zip(thicknesses.iter()) {
1252 if thickness <= 0.0 {
1253 continue;
1254 }
1255 for i in 0..work_len {
1256 total_attenuation[i] += thickness * xs[i];
1257 }
1258 }
1259 let transmission: Vec<f64> = total_attenuation.iter().map(|&att| (-att).exp()).collect();
1260
1261 if let Some(inst) = instrument {
1266 let t_broadened =
1267 resolution::apply_resolution_presorted(work_energies, &transmission, &inst.resolution);
1268 if let Some((_, ref data_indices)) = ext_grid {
1269 Ok(data_indices.iter().map(|&i| t_broadened[i]).collect())
1270 } else {
1271 Ok(t_broadened)
1272 }
1273 } else {
1274 Ok(transmission)
1275 }
1276}
1277
1278#[cfg(test)]
1279mod tests {
1280 use super::*;
1281 use nereids_core::types::Isotope;
1282 use nereids_endf::resonance::test_support::u238_single_resonance;
1283 use nereids_endf::resonance::{LGroup, Resonance, ResonanceFormalism, ResonanceRange};
1284
1285 #[test]
1292 fn working_grid_layout_matches_across_separate_calls() {
1293 let data = u238_single_resonance();
1294 let energies: Vec<f64> = (0..401).map(|i| 4.0 + (i as f64) * 0.015).collect();
1295 let inst = InstrumentParams {
1296 resolution: crate::resolution::ResolutionFunction::Gaussian(
1297 crate::resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
1298 ),
1299 };
1300 let layout_a = resolution_working_grid(&energies, Some(&inst), &[&data]).unwrap();
1301 let working = broadened_cross_sections_on_working_grid(
1302 &energies,
1303 std::slice::from_ref(&data),
1304 300.0,
1305 Some(&inst),
1306 None,
1307 )
1308 .unwrap();
1309 let layout_b = working.layout;
1310 assert!(
1311 !layout_a.is_identity(),
1312 "Gaussian resolution should build a non-identity auxiliary grid"
1313 );
1314 assert_eq!(
1315 layout_a.data_indices, layout_b.data_indices,
1316 "data-index maps must match across the two builders"
1317 );
1318 assert_eq!(
1319 layout_a.energies.len(),
1320 layout_b.energies.len(),
1321 "working-grid length must match"
1322 );
1323 for (a, b) in layout_a.energies.iter().zip(layout_b.energies.iter()) {
1324 assert_eq!(
1325 a.to_bits(),
1326 b.to_bits(),
1327 "working-grid energies must be bit-identical"
1328 );
1329 }
1330 }
1331
1332 #[test]
1333 fn test_beer_lambert_zero_thickness() {
1334 let xs = vec![100.0, 200.0, 300.0];
1335 let t = beer_lambert(&xs, 0.0);
1336 assert_eq!(t, vec![1.0, 1.0, 1.0]);
1337 }
1338
1339 #[test]
1340 fn test_beer_lambert_basic() {
1341 let xs = vec![100.0];
1344 let t = beer_lambert(&xs, 0.01);
1345 assert!(
1346 (t[0] - (-1.0_f64).exp()).abs() < 1e-10,
1347 "T = {}, expected {}",
1348 t[0],
1349 (-1.0_f64).exp()
1350 );
1351 }
1352
1353 #[test]
1354 fn test_beer_lambert_opaque() {
1355 let xs = vec![1000.0];
1357 let t = beer_lambert(&xs, 1.0);
1358 assert_eq!(t[0], 0.0, "T = {}, expected 0.0", t[0]);
1359 }
1360
1361 #[test]
1362 fn test_beer_lambert_multi_additive() {
1363 let xs1 = vec![100.0];
1368 let xs2 = vec![200.0];
1369 let t = beer_lambert_multi(&[&xs1, &xs2], &[0.01, 0.005]).unwrap();
1370 assert!(
1371 (t[0] - (-2.0_f64).exp()).abs() < 1e-10,
1372 "T = {}, expected {}",
1373 t[0],
1374 (-2.0_f64).exp()
1375 );
1376 }
1377
1378 #[test]
1379 fn test_transmission_dip_at_resonance() {
1380 let data = u238_single_resonance();
1383 let thickness = 0.001; let energies = [1.0, 3.0, 6.674, 10.0, 20.0];
1387 let xs: Vec<f64> = energies
1388 .iter()
1389 .map(|&e| reich_moore::cross_sections_at_energy(&data, e).total)
1390 .collect();
1391 let trans = beer_lambert(&xs, thickness);
1392
1393 let t_on_res = trans[2];
1395 let t_off_res = trans[0]; assert!(
1398 t_on_res < t_off_res,
1399 "On-resonance T ({}) should be < off-resonance T ({})",
1400 t_on_res,
1401 t_off_res
1402 );
1403
1404 assert!(
1406 t_on_res < 0.01,
1407 "On-resonance T ({}) should be very small",
1408 t_on_res
1409 );
1410 }
1411
1412 #[test]
1413 fn test_forward_model_no_broadening() {
1414 let data = u238_single_resonance();
1417 let thickness = 0.001;
1418
1419 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
1420
1421 let xs: Vec<f64> = energies
1423 .iter()
1424 .map(|&e| reich_moore::cross_sections_at_energy(&data, e).total)
1425 .collect();
1426 let t_direct = beer_lambert(&xs, thickness);
1427
1428 let sample = SampleParams::new(0.0, vec![(data, thickness)]).unwrap();
1430 let t_forward = forward_model(&energies, &sample, None).unwrap();
1431
1432 for i in 0..energies.len() {
1433 assert!(
1434 (t_direct[i] - t_forward[i]).abs() < 1e-10,
1435 "Mismatch at E={}: direct={}, forward={}",
1436 energies[i],
1437 t_direct[i],
1438 t_forward[i]
1439 );
1440 }
1441 }
1442
1443 #[test]
1444 fn test_forward_model_with_broadening() {
1445 let data = u238_single_resonance();
1448 let thickness = 0.0001; let energies: Vec<f64> = (0..401).map(|i| 5.0 + (i as f64) * 0.01).collect();
1451
1452 let sample_cold = SampleParams::new(0.0, vec![(data.clone(), thickness)]).unwrap();
1454 let t_cold = forward_model(&energies, &sample_cold, None).unwrap();
1455
1456 let sample_hot = SampleParams::new(300.0, vec![(data, thickness)]).unwrap();
1458 let t_hot = forward_model(&energies, &sample_hot, None).unwrap();
1459
1460 let min_cold = t_cold.iter().cloned().fold(f64::MAX, f64::min);
1462 let min_hot = t_hot.iter().cloned().fold(f64::MAX, f64::min);
1463
1464 assert!(
1466 min_hot > min_cold,
1467 "Broadened min T ({}) should be > unbroadened min T ({})",
1468 min_hot,
1469 min_cold
1470 );
1471 }
1472
1473 #[test]
1474 fn test_forward_model_multi_isotope() {
1475 let u238 = u238_single_resonance();
1477
1478 let other = ResonanceData {
1480 isotope: Isotope::new(1, 10).unwrap(),
1481 za: 1010,
1482 awr: 10.0,
1483 ranges: vec![ResonanceRange {
1484 energy_low: 0.0,
1485 energy_high: 100.0,
1486 resolved: true,
1487 formalism: ResonanceFormalism::ReichMoore,
1488 target_spin: 0.0,
1489 scattering_radius: 5.0,
1490 naps: 1,
1491 l_groups: vec![LGroup {
1492 l: 0,
1493 awr: 10.0,
1494 apl: 5.0,
1495 qx: 0.0,
1496 lrx: 0,
1497 resonances: vec![Resonance {
1498 energy: 20.0,
1499 j: 0.5,
1500 gn: 0.1,
1501 gg: 0.05,
1502 gfa: 0.0,
1503 gfb: 0.0,
1504 }],
1505 }],
1506 ap_table: None,
1507 r_external: vec![],
1508 }],
1509 };
1510
1511 let energies: Vec<f64> = (0..301).map(|i| 1.0 + (i as f64) * 0.1).collect();
1512
1513 let sample = SampleParams::new(0.0, vec![(u238, 0.0001), (other, 0.0001)]).unwrap();
1514 let t = forward_model(&energies, &sample, None).unwrap();
1515
1516 let idx_u238 = energies
1518 .iter()
1519 .position(|&e| (e - 6.7).abs() < 0.05)
1520 .unwrap();
1521 let idx_other = energies
1523 .iter()
1524 .position(|&e| (e - 20.0).abs() < 0.05)
1525 .unwrap();
1526 let idx_off = energies
1528 .iter()
1529 .position(|&e| (e - 15.0).abs() < 0.05)
1530 .unwrap();
1531
1532 assert!(
1534 t[idx_u238] < t[idx_off],
1535 "U-238 dip at 6.7 eV: T={}, off-res: T={}",
1536 t[idx_u238],
1537 t[idx_off]
1538 );
1539 assert!(
1540 t[idx_other] < t[idx_off],
1541 "Other dip at 20 eV: T={}, off-res: T={}",
1542 t[idx_other],
1543 t[idx_off]
1544 );
1545 }
1546
1547 #[test]
1548 fn test_broadened_xs_analytical_derivative() {
1549 let data = u238_single_resonance();
1552 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1553 let temperature = 300.0;
1554
1555 let base_xs =
1556 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1557 let (xs, dxs_dt) = broadened_cross_sections_with_analytical_derivative_from_base(
1558 &energies,
1559 &base_xs,
1560 std::slice::from_ref(&data),
1561 temperature,
1562 None,
1563 )
1564 .unwrap();
1565
1566 assert_eq!(xs.len(), 1, "one isotope");
1568 assert_eq!(dxs_dt.len(), 1, "one isotope derivative");
1569 assert_eq!(xs[0].len(), energies.len());
1570 assert_eq!(dxs_dt[0].len(), energies.len());
1571
1572 let idx_res = energies
1575 .iter()
1576 .position(|&e| (e - 6.674).abs() < 0.05)
1577 .unwrap();
1578 assert!(
1579 dxs_dt[0][idx_res].abs() > 0.0,
1580 "dσ/dT should be non-zero near resonance, got {}",
1581 dxs_dt[0][idx_res]
1582 );
1583
1584 let big_dt = 1.0;
1587 let xs_up = broadened_cross_sections(
1588 &energies,
1589 std::slice::from_ref(&data),
1590 temperature + big_dt,
1591 None,
1592 None,
1593 )
1594 .unwrap();
1595 let xs_down =
1596 broadened_cross_sections(&energies, &[data], temperature - big_dt, None, None).unwrap();
1597
1598 let manual_deriv: Vec<f64> = xs_up[0]
1599 .iter()
1600 .zip(xs_down[0].iter())
1601 .map(|(&u, &d)| (u - d) / (2.0 * big_dt))
1602 .collect();
1603
1604 let deriv_analytical = dxs_dt[0][idx_res];
1606 let deriv_coarse = manual_deriv[idx_res];
1607 let rel_err = (deriv_analytical - deriv_coarse).abs()
1608 / deriv_analytical.abs().max(deriv_coarse.abs()).max(1e-30);
1609 assert!(
1610 rel_err < 0.05,
1611 "Analytical vs FD derivatives disagree: analytical={}, coarse={}, rel_err={}",
1612 deriv_analytical,
1613 deriv_coarse,
1614 rel_err,
1615 );
1616 }
1617
1618 #[test]
1619 fn test_broadened_xs_analytical_derivative_low_temperature() {
1620 let data = u238_single_resonance();
1622 let energies: Vec<f64> = (0..51).map(|i| 5.0 + (i as f64) * 0.1).collect();
1623
1624 let base_xs =
1625 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1626
1627 let (xs_low, dxs_low) = broadened_cross_sections_with_analytical_derivative_from_base(
1629 &energies,
1630 &base_xs,
1631 std::slice::from_ref(&data),
1632 0.05,
1633 None,
1634 )
1635 .unwrap();
1636 assert!(!xs_low.is_empty());
1637 for deriv_vec in &dxs_low {
1640 for &d in deriv_vec {
1641 assert!(d.is_finite(), "derivative must be finite at T=0.05 K");
1642 }
1643 }
1644
1645 let (xs_zero, dxs_zero) = broadened_cross_sections_with_analytical_derivative_from_base(
1647 &energies,
1648 &base_xs,
1649 std::slice::from_ref(&data),
1650 0.0,
1651 None,
1652 )
1653 .unwrap();
1654 assert!(!xs_zero.is_empty());
1655 for deriv_vec in &dxs_zero {
1656 for &d in deriv_vec {
1657 assert!(d.is_finite(), "derivative must be finite at T=0.0 K");
1658 }
1659 }
1660 }
1661
1662 #[test]
1665 fn test_sample_params_valid() {
1666 let sample = SampleParams::new(300.0, vec![]).unwrap();
1667 assert!((sample.temperature_k() - 300.0).abs() < 1e-15);
1668 assert!(sample.isotopes().is_empty());
1669 }
1670
1671 #[test]
1672 fn test_sample_params_zero_temperature() {
1673 let sample = SampleParams::new(0.0, vec![]).unwrap();
1674 assert!((sample.temperature_k()).abs() < 1e-15);
1675 }
1676
1677 #[test]
1678 fn test_sample_params_rejects_negative_temperature() {
1679 let err = SampleParams::new(-1.0, vec![]).unwrap_err();
1680 assert_eq!(err, SampleParamsError::NegativeTemperature(-1.0));
1681 }
1682
1683 #[test]
1684 fn test_sample_params_rejects_nan_temperature() {
1685 let err = SampleParams::new(f64::NAN, vec![]).unwrap_err();
1686 assert!(matches!(err, SampleParamsError::NonFiniteTemperature(_)));
1687 }
1688
1689 #[test]
1690 fn test_sample_params_rejects_infinite_temperature() {
1691 let err = SampleParams::new(f64::INFINITY, vec![]).unwrap_err();
1692 assert!(matches!(err, SampleParamsError::NonFiniteTemperature(_)));
1693 }
1694
1695 #[test]
1696 fn test_sample_params_rejects_neg_infinite_temperature() {
1697 let err = SampleParams::new(f64::NEG_INFINITY, vec![]).unwrap_err();
1698 assert!(matches!(err, SampleParamsError::NonFiniteTemperature(_)));
1699 }
1700
1701 #[test]
1704 fn test_forward_model_from_base_xs_matches_forward_model() {
1705 let data = u238_single_resonance();
1706 let thickness = 0.0005;
1707 let temperature = 300.0;
1708 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1709
1710 let sample = SampleParams::new(temperature, vec![(data.clone(), thickness)]).unwrap();
1712 let t_ref = forward_model(&energies, &sample, None).unwrap();
1713
1714 let base_xs =
1716 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1717 let t_cached = forward_model_from_base_xs(
1718 &energies,
1719 &base_xs,
1720 std::slice::from_ref(&data),
1721 &[thickness],
1722 temperature,
1723 None,
1724 )
1725 .unwrap();
1726
1727 for (i, (&r, &c)) in t_ref.iter().zip(t_cached.iter()).enumerate() {
1728 assert!(
1729 (r - c).abs() < 1e-12,
1730 "Mismatch at E[{}]={}: ref={}, cached={}",
1731 i,
1732 energies[i],
1733 r,
1734 c
1735 );
1736 }
1737 }
1738
1739 #[test]
1740 fn test_broadened_from_base_matches_broadened() {
1741 let data = u238_single_resonance();
1742 let temperature = 300.0;
1743 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1744
1745 let xs_ref = broadened_cross_sections(
1746 &energies,
1747 std::slice::from_ref(&data),
1748 temperature,
1749 None,
1750 None,
1751 )
1752 .unwrap();
1753 let base_xs =
1754 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1755 let xs_cached = broadened_cross_sections_from_base(
1756 &energies,
1757 &base_xs,
1758 std::slice::from_ref(&data),
1759 temperature,
1760 None,
1761 )
1762 .unwrap();
1763
1764 assert_eq!(xs_ref.len(), xs_cached.len());
1765 for (r, c) in xs_ref[0].iter().zip(xs_cached[0].iter()) {
1766 assert!(
1767 (r - c).abs() < 1e-12,
1768 "broadened_from_base mismatch: ref={}, cached={}",
1769 r,
1770 c
1771 );
1772 }
1773 }
1774
1775 #[test]
1776 fn test_analytical_derivative_from_base_shape_and_finiteness() {
1777 let data = u238_single_resonance();
1780 let temperature = 300.0;
1781 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1782
1783 let base_xs =
1784 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1785 let (xs, dxs_dt) = broadened_cross_sections_with_analytical_derivative_from_base(
1786 &energies,
1787 &base_xs,
1788 std::slice::from_ref(&data),
1789 temperature,
1790 None,
1791 )
1792 .unwrap();
1793
1794 assert_eq!(xs.len(), 1);
1795 assert_eq!(dxs_dt.len(), 1);
1796 assert_eq!(xs[0].len(), energies.len());
1797 assert_eq!(dxs_dt[0].len(), energies.len());
1798
1799 for &v in &xs[0] {
1801 assert!(v.is_finite(), "XS must be finite, got {v}");
1802 }
1803 for &v in &dxs_dt[0] {
1804 assert!(v.is_finite(), "dXS/dT must be finite, got {v}");
1805 }
1806
1807 let xs_full = broadened_cross_sections(
1809 &energies,
1810 std::slice::from_ref(&data),
1811 temperature,
1812 None,
1813 None,
1814 )
1815 .unwrap();
1816 for (r, c) in xs_full[0].iter().zip(xs[0].iter()) {
1817 assert!(
1818 (r - c).abs() < 1e-12,
1819 "XS mismatch: full={}, from_base={}",
1820 r,
1821 c
1822 );
1823 }
1824 }
1825
1826 #[test]
1837 fn test_forward_model_resolution_after_beer_lambert() {
1838 let data = u238_single_resonance();
1839 let thickness = 0.0005; let temperature = 300.0;
1841
1842 let energies: Vec<f64> = (0..401).map(|i| 4.0 + (i as f64) * 0.015).collect();
1844
1845 let inst = InstrumentParams {
1846 resolution: resolution::ResolutionFunction::Gaussian(
1847 resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
1848 ),
1849 };
1850
1851 let sigma_d = continuous_doppler::broaden(&energies, &data, temperature).unwrap();
1858
1859 let transmission: Vec<f64> = sigma_d.iter().map(|&s| (-thickness * s).exp()).collect();
1861
1862 let t_expected =
1864 resolution::apply_resolution(&energies, &transmission, &inst.resolution).unwrap();
1865
1866 let sigma_broadened =
1868 resolution::apply_resolution(&energies, &sigma_d, &inst.resolution).unwrap();
1869 let t_wrong: Vec<f64> = sigma_broadened
1870 .iter()
1871 .map(|&s| (-thickness * s).exp())
1872 .collect();
1873
1874 let sample = SampleParams::new(temperature, vec![(data, thickness)]).unwrap();
1876 let t_forward = forward_model(&energies, &sample, Some(&inst)).unwrap();
1877
1878 let interior = 20..energies.len() - 20; let mut max_err_correct = 0.0f64;
1883 let mut max_err_wrong = 0.0f64;
1884 for i in interior.clone() {
1885 let err_correct = (t_forward[i] - t_expected[i]).abs();
1886 let err_wrong = (t_forward[i] - t_wrong[i]).abs();
1887 max_err_correct = max_err_correct.max(err_correct);
1888 max_err_wrong = max_err_wrong.max(err_wrong);
1889 }
1890
1891 assert!(
1899 max_err_correct < max_err_wrong,
1900 "forward_model is closer to the WRONG ordering than the correct one. \
1901 Error vs correct = {max_err_correct}, error vs wrong = {max_err_wrong}"
1902 );
1903
1904 assert!(
1907 max_err_correct < max_err_wrong * 0.5,
1908 "forward_model should be clearly closer to the correct ordering. \
1909 Error vs correct = {max_err_correct}, error vs wrong = {max_err_wrong}, \
1910 ratio = {:.2}",
1911 max_err_correct / max_err_wrong
1912 );
1913
1914 let ordering_diff: f64 = interior
1917 .map(|i| (t_expected[i] - t_wrong[i]).abs())
1918 .fold(0.0f64, f64::max);
1919 assert!(
1920 ordering_diff > 1e-4,
1921 "The two orderings should differ measurably at the resonance dip, \
1922 but max diff = {ordering_diff}. Test parameters may be too weak."
1923 );
1924 }
1925
1926 #[test]
1930 fn test_broadened_xs_is_doppler_only_with_instrument() {
1931 let data = u238_single_resonance();
1932 let temperature = 300.0;
1933 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1934
1935 let inst = InstrumentParams {
1936 resolution: resolution::ResolutionFunction::Gaussian(
1937 resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
1938 ),
1939 };
1940
1941 let xs_with_inst = broadened_cross_sections(
1943 &energies,
1944 std::slice::from_ref(&data),
1945 temperature,
1946 Some(&inst),
1947 None,
1948 )
1949 .unwrap();
1950
1951 let xs_no_inst = broadened_cross_sections(
1953 &energies,
1954 std::slice::from_ref(&data),
1955 temperature,
1956 None,
1957 None,
1958 )
1959 .unwrap();
1960
1961 assert_eq!(xs_with_inst.len(), 1);
1965 assert_eq!(xs_no_inst.len(), 1);
1966 assert_eq!(xs_with_inst[0].len(), energies.len());
1967
1968 let sigma_resolved =
1970 resolution::apply_resolution(&energies, &xs_no_inst[0], &inst.resolution).unwrap();
1971
1972 let idx_dip = energies
1976 .iter()
1977 .position(|&e| (e - 6.674).abs() < 0.05)
1978 .unwrap();
1979 let diff_doppler = (xs_with_inst[0][idx_dip] - xs_no_inst[0][idx_dip]).abs();
1980 let diff_resolved = (xs_with_inst[0][idx_dip] - sigma_resolved[idx_dip]).abs();
1981
1982 assert!(
1985 diff_doppler < diff_resolved,
1986 "broadened_cross_sections with instrument should return Doppler-only σ, \
1987 not resolution-broadened σ. \
1988 diff(with_inst, no_inst) = {diff_doppler}, \
1989 diff(with_inst, resolved) = {diff_resolved}"
1990 );
1991 }
1992
1993 #[test]
1996 fn test_broadened_from_base_is_doppler_only_with_instrument() {
1997 let data = u238_single_resonance();
1998 let temperature = 300.0;
1999 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
2000
2001 let inst = InstrumentParams {
2002 resolution: resolution::ResolutionFunction::Gaussian(
2003 resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2004 ),
2005 };
2006
2007 let base_xs =
2008 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
2009
2010 let xs_with_inst = broadened_cross_sections_from_base(
2012 &energies,
2013 &base_xs,
2014 std::slice::from_ref(&data),
2015 temperature,
2016 Some(&inst),
2017 )
2018 .unwrap();
2019
2020 let xs_no_inst = broadened_cross_sections_from_base(
2022 &energies,
2023 &base_xs,
2024 std::slice::from_ref(&data),
2025 temperature,
2026 None,
2027 )
2028 .unwrap();
2029
2030 let sigma_resolved =
2033 resolution::apply_resolution(&energies, &xs_no_inst[0], &inst.resolution).unwrap();
2034
2035 let idx_dip = energies
2036 .iter()
2037 .position(|&e| (e - 6.674).abs() < 0.05)
2038 .unwrap();
2039 let diff_doppler = (xs_with_inst[0][idx_dip] - xs_no_inst[0][idx_dip]).abs();
2040 let diff_resolved = (xs_with_inst[0][idx_dip] - sigma_resolved[idx_dip]).abs();
2041
2042 assert!(
2043 diff_doppler < diff_resolved,
2044 "broadened_cross_sections_from_base with instrument should return Doppler-only σ. \
2045 diff(with_inst, no_inst) = {diff_doppler}, \
2046 diff(with_inst, resolved) = {diff_resolved}"
2047 );
2048 }
2049
2050 #[test]
2055 fn test_forward_model_from_base_xs_matches_forward_model_with_resolution() {
2056 let data = u238_single_resonance();
2057 let thickness = 0.0005;
2058 let temperature = 300.0;
2059 let energies: Vec<f64> = (0..401).map(|i| 4.0 + (i as f64) * 0.015).collect();
2060
2061 let inst = InstrumentParams {
2062 resolution: resolution::ResolutionFunction::Gaussian(
2063 resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2064 ),
2065 };
2066
2067 let sample = SampleParams::new(temperature, vec![(data.clone(), thickness)]).unwrap();
2069 let t_ref = forward_model(&energies, &sample, Some(&inst)).unwrap();
2070
2071 let base_xs =
2073 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
2074 let t_base = forward_model_from_base_xs(
2075 &energies,
2076 &base_xs,
2077 std::slice::from_ref(&data),
2078 &[thickness],
2079 temperature,
2080 Some(&inst),
2081 )
2082 .unwrap();
2083
2084 let interior = 20..energies.len() - 20;
2092 let mut max_err = 0.0f64;
2093 for i in interior.clone() {
2094 max_err = max_err.max((t_ref[i] - t_base[i]).abs());
2095 }
2096 assert!(
2097 max_err < 1e-12,
2098 "forward_model_from_base_xs with resolution must match forward_model \
2099 to round-off (identical aux-grid pipeline). Max error = {max_err}"
2100 );
2101
2102 let t_no_res = forward_model_from_base_xs(
2104 &energies,
2105 &base_xs,
2106 std::slice::from_ref(&data),
2107 &[thickness],
2108 temperature,
2109 None,
2110 )
2111 .unwrap();
2112 let res_diff: f64 = interior
2113 .map(|i| (t_base[i] - t_no_res[i]).abs())
2114 .fold(0.0f64, f64::max);
2115 assert!(
2116 res_diff > 1e-4,
2117 "Resolution should make a measurable difference, but max diff = {res_diff}"
2118 );
2119 }
2120
2121 #[test]
2124 fn test_forward_model_from_base_xs_no_resolution_unchanged() {
2125 let data = u238_single_resonance();
2126 let thickness = 0.0005;
2127 let temperature = 300.0;
2128 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
2129
2130 let sample = SampleParams::new(temperature, vec![(data.clone(), thickness)]).unwrap();
2131 let t_ref = forward_model(&energies, &sample, None).unwrap();
2132
2133 let base_xs =
2134 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
2135 let t_base = forward_model_from_base_xs(
2136 &energies,
2137 &base_xs,
2138 std::slice::from_ref(&data),
2139 &[thickness],
2140 temperature,
2141 None,
2142 )
2143 .unwrap();
2144
2145 for (i, (&r, &b)) in t_ref.iter().zip(t_base.iter()).enumerate() {
2146 assert!(
2147 (r - b).abs() < 1e-12,
2148 "No-resolution mismatch at E[{i}]={}: ref={r}, base={b}",
2149 energies[i]
2150 );
2151 }
2152 }
2153
2154 #[test]
2160 fn test_derivative_helper_is_doppler_only_with_instrument() {
2161 let data = u238_single_resonance();
2162 let temperature = 300.0;
2163 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
2164
2165 let inst = InstrumentParams {
2166 resolution: resolution::ResolutionFunction::Gaussian(
2167 resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2168 ),
2169 };
2170
2171 let base_xs =
2172 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
2173
2174 let (xs_inst, dxs_inst) = broadened_cross_sections_with_analytical_derivative_from_base(
2176 &energies,
2177 &base_xs,
2178 std::slice::from_ref(&data),
2179 temperature,
2180 Some(&inst),
2181 )
2182 .unwrap();
2183
2184 let (xs_none, dxs_none) = broadened_cross_sections_with_analytical_derivative_from_base(
2186 &energies,
2187 &base_xs,
2188 std::slice::from_ref(&data),
2189 temperature,
2190 None,
2191 )
2192 .unwrap();
2193
2194 assert_eq!(xs_inst.len(), 1);
2195 assert_eq!(dxs_inst.len(), 1);
2196
2197 let sigma_resolved =
2201 resolution::apply_resolution(&energies, &xs_none[0], &inst.resolution).unwrap();
2202
2203 let idx_dip = energies
2204 .iter()
2205 .position(|&e| (e - 6.674).abs() < 0.05)
2206 .unwrap();
2207
2208 let diff_doppler = (xs_inst[0][idx_dip] - xs_none[0][idx_dip]).abs();
2211 let diff_resolved = (xs_inst[0][idx_dip] - sigma_resolved[idx_dip]).abs();
2212 assert!(
2213 diff_doppler < diff_resolved,
2214 "derivative helper σ with instrument should be Doppler-only. \
2215 diff(inst, none) = {diff_doppler}, diff(inst, resolved) = {diff_resolved}"
2216 );
2217
2218 let dxs_resolved =
2220 resolution::apply_resolution(&energies, &dxs_none[0], &inst.resolution).unwrap();
2221 let ddiff_doppler = (dxs_inst[0][idx_dip] - dxs_none[0][idx_dip]).abs();
2222 let ddiff_resolved = (dxs_inst[0][idx_dip] - dxs_resolved[idx_dip]).abs();
2223 assert!(
2224 ddiff_doppler < ddiff_resolved,
2225 "derivative helper ∂σ/∂T with instrument should be Doppler-only. \
2226 diff(inst, none) = {ddiff_doppler}, diff(inst, resolved) = {ddiff_resolved}"
2227 );
2228 }
2229
2230 #[test]
2233 fn test_derivative_helper_no_resolution_unchanged() {
2234 let data = u238_single_resonance();
2235 let temperature = 300.0;
2236 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
2237
2238 let base_xs =
2239 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
2240
2241 let (xs, dxs) = broadened_cross_sections_with_analytical_derivative_from_base(
2242 &energies,
2243 &base_xs,
2244 std::slice::from_ref(&data),
2245 temperature,
2246 None,
2247 )
2248 .unwrap();
2249
2250 let xs_ref = broadened_cross_sections(
2252 &energies,
2253 std::slice::from_ref(&data),
2254 temperature,
2255 None,
2256 None,
2257 )
2258 .unwrap();
2259
2260 for (i, (&a, &b)) in xs[0].iter().zip(xs_ref[0].iter()).enumerate() {
2261 assert!(
2262 (a - b).abs() < 1e-12,
2263 "σ mismatch at E[{i}]: derivative_helper={a}, broadened={b}"
2264 );
2265 }
2266
2267 assert_eq!(dxs.len(), 1);
2269 assert_eq!(dxs[0].len(), energies.len());
2270 let idx_res = energies
2271 .iter()
2272 .position(|&e| (e - 6.674).abs() < 0.05)
2273 .unwrap();
2274 assert!(
2275 dxs[0][idx_res].abs() > 0.0,
2276 "∂σ/∂T should be non-zero near resonance"
2277 );
2278 for &d in &dxs[0] {
2279 assert!(d.is_finite(), "∂σ/∂T must be finite");
2280 }
2281 }
2282
2283 #[test]
2289 fn test_broadened_for_transmission_rejects_bad_thickness() {
2290 let data = u238_single_resonance();
2291 let energies: Vec<f64> = (0..51).map(|i| 5.0 + (i as f64) * 0.1).collect();
2292 let inst = InstrumentParams {
2293 resolution: resolution::ResolutionFunction::Gaussian(
2294 resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2295 ),
2296 };
2297
2298 for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
2299 let err = broadened_cross_sections_for_transmission(
2300 &energies,
2301 std::slice::from_ref(&data),
2302 300.0,
2303 &inst,
2304 bad,
2305 None,
2306 )
2307 .unwrap_err();
2308 assert!(
2309 matches!(err, TransmissionError::InputMismatch(_)),
2310 "thickness = {bad} should be rejected with InputMismatch, got {err:?}"
2311 );
2312 }
2313
2314 let ok = broadened_cross_sections_for_transmission(
2316 &energies,
2317 std::slice::from_ref(&data),
2318 300.0,
2319 &inst,
2320 0.001,
2321 None,
2322 );
2323 assert!(ok.is_ok(), "valid thickness should succeed: {ok:?}");
2324 }
2325
2326 #[test]
2331 fn from_base_working_grid_rejects_malformed_inputs() {
2332 let rd = vec![u238_single_resonance()];
2333 let energies: Vec<f64> = (0..21).map(|i| 1.0 + (i as f64) * 0.1).collect();
2334 let n_e = energies.len();
2335 let good_base = vec![vec![10.0f64; n_e]];
2336 let e1 = broadened_cross_sections_with_analytical_derivative_from_base(
2338 &energies,
2339 &[vec![10.0; n_e], vec![10.0; n_e]],
2340 &rd,
2341 300.0,
2342 None,
2343 )
2344 .unwrap_err();
2345 assert!(e1.to_string().contains("isotopes"), "got: {e1}");
2346 let e2 = broadened_cross_sections_with_analytical_derivative_from_base(
2348 &energies,
2349 &[vec![10.0; n_e - 1]],
2350 &rd,
2351 300.0,
2352 None,
2353 )
2354 .unwrap_err();
2355 assert!(e2.to_string().contains("energies"), "got: {e2}");
2356 let inst = InstrumentParams {
2358 resolution: resolution::ResolutionFunction::Gaussian(
2359 resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2360 ),
2361 };
2362 let mut unsorted = energies.clone();
2363 unsorted.swap(0, 1);
2364 let e3 = broadened_cross_sections_with_analytical_derivative_from_base(
2365 &unsorted,
2366 &good_base,
2367 &rd,
2368 300.0,
2369 Some(&inst),
2370 )
2371 .unwrap_err();
2372 assert!(e3.to_string().contains("sorted"), "got: {e3}");
2373 }
2374}