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 {
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 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 } else {
94 crate::auxiliary_grid::build_extended_grid_for(energies, &inst.resolution)
97 };
98 (ext_e.len() > energies.len()).then_some((ext_e, di))
99 })
100}
101
102fn extract_resonance_widths(resonance_data: &[&ResonanceData]) -> Vec<(f64, f64)> {
112 let mut pairs = Vec::new();
113 for rd in resonance_data {
114 for range in &rd.ranges {
115 if !range.is_evaluable() {
116 continue;
117 }
118 pairs.extend(range.levels().filter(|&(_, gd)| gd > 0.0));
119 }
120 }
121 pairs
122}
123
124pub fn resonance_center_energies(resonance_data: &[&ResonanceData]) -> Vec<f64> {
140 let mut e: Vec<f64> = extract_resonance_widths(resonance_data)
141 .into_iter()
142 .map(|(energy, _gd)| energy)
143 .collect();
144 e.sort_by(f64::total_cmp);
145 e.dedup();
147 e
148}
149
150fn build_extended_xs_from_base(
161 ext_energies: &[f64],
162 data_indices: &[usize],
163 is_data_point: &[bool],
164 xs_raw: &[f64],
165 rd: &ResonanceData,
166) -> Vec<f64> {
167 let mut xs_ext = vec![0.0f64; ext_energies.len()];
168 for (data_i, &ext_i) in data_indices.iter().enumerate() {
169 xs_ext[ext_i] = xs_raw[data_i];
170 }
171 for (j, &e) in ext_energies.iter().enumerate() {
172 if !is_data_point[j] {
173 xs_ext[j] = reich_moore::cross_sections_at_energy(rd, e).total;
174 }
175 }
176 xs_ext
177}
178
179fn validate_base_xs(
184 energies: &[f64],
185 base_xs: &[Vec<f64>],
186 resonance_data: &[ResonanceData],
187) -> Result<(), TransmissionError> {
188 if base_xs.len() != resonance_data.len() {
189 return Err(TransmissionError::InputMismatch(format!(
190 "base_xs has {} isotopes but resonance_data has {}",
191 base_xs.len(),
192 resonance_data.len(),
193 )));
194 }
195 for (i, row) in base_xs.iter().enumerate() {
196 if row.len() != energies.len() {
197 return Err(TransmissionError::InputMismatch(format!(
198 "base_xs[{i}] has {} energies but expected {}",
199 row.len(),
200 energies.len(),
201 )));
202 }
203 }
204 Ok(())
205}
206
207fn data_point_mask(ext_grid: Option<&(Vec<f64>, Vec<usize>)>) -> Option<Vec<bool>> {
211 ext_grid.map(|(ext_e, di)| {
212 let mut mask = vec![false; ext_e.len()];
213 for &idx in di {
214 mask[idx] = true;
215 }
216 mask
217 })
218}
219
220fn working_grid_layout<'a>(
223 energies: &'a [f64],
224 ext_grid: Option<&'a (Vec<f64>, Vec<usize>)>,
225) -> (&'a [f64], WorkingGridLayout) {
226 match ext_grid {
227 Some((ext_e, di)) => (
228 ext_e.as_slice(),
229 WorkingGridLayout {
230 energies: ext_e.clone(),
231 data_indices: di.clone(),
232 },
233 ),
234 None => (energies, WorkingGridLayout::identity(energies)),
235 }
236}
237
238pub fn resolution_working_grid(
253 energies: &[f64],
254 instrument: Option<&InstrumentParams>,
255 resonance_data: &[&ResonanceData],
256) -> Result<WorkingGridLayout, TransmissionError> {
257 if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
258 return Err(ResolutionError::UnsortedEnergies.into());
259 }
260 let ext_grid = build_aux_grid(energies, instrument, resonance_data);
261 let (_, layout) = working_grid_layout(energies, ext_grid.as_ref());
262 Ok(layout)
263}
264
265#[derive(Debug)]
267pub enum TransmissionError {
268 Resolution(ResolutionError),
270 Doppler(DopplerParamsError),
272 DopplerBroadening(crate::doppler::DopplerError),
274 Cancelled,
276 InputMismatch(String),
278}
279
280impl fmt::Display for TransmissionError {
281 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
282 match self {
283 Self::Resolution(e) => write!(f, "resolution broadening error: {}", e),
284 Self::Doppler(e) => write!(f, "Doppler parameter error: {}", e),
285 Self::DopplerBroadening(e) => write!(f, "Doppler broadening error: {}", e),
286 Self::Cancelled => write!(f, "computation cancelled"),
287 Self::InputMismatch(msg) => write!(f, "input mismatch: {}", msg),
288 }
289 }
290}
291
292impl std::error::Error for TransmissionError {
293 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
294 match self {
295 Self::Resolution(e) => Some(e),
296 Self::Doppler(e) => Some(e),
297 Self::DopplerBroadening(e) => Some(e),
298 Self::Cancelled => None,
299 Self::InputMismatch(_) => None,
300 }
301 }
302}
303
304impl From<ResolutionError> for TransmissionError {
305 fn from(e: ResolutionError) -> Self {
306 Self::Resolution(e)
307 }
308}
309
310impl From<DopplerParamsError> for TransmissionError {
311 fn from(e: DopplerParamsError) -> Self {
312 Self::Doppler(e)
313 }
314}
315
316impl From<crate::doppler::DopplerError> for TransmissionError {
317 fn from(e: crate::doppler::DopplerError) -> Self {
318 Self::DopplerBroadening(e)
319 }
320}
321
322pub type BroadenedXsWithDerivative = (Vec<Vec<f64>>, Vec<Vec<f64>>);
328
329#[derive(Debug, Clone)]
337pub struct WorkingGridLayout {
338 pub energies: Vec<f64>,
341 pub data_indices: Vec<usize>,
344}
345
346impl WorkingGridLayout {
347 pub fn identity(energies: &[f64]) -> Self {
348 Self {
349 energies: energies.to_vec(),
350 data_indices: (0..energies.len()).collect(),
351 }
352 }
353
354 pub fn is_identity(&self) -> bool {
357 self.data_indices.len() == self.energies.len()
358 && self
359 .data_indices
360 .iter()
361 .enumerate()
362 .all(|(i, &idx)| i == idx)
363 }
364
365 pub fn extract(&self, working: &[f64]) -> Vec<f64> {
367 self.data_indices.iter().map(|&i| working[i]).collect()
368 }
369
370 pub fn extract_owned(&self, working: Vec<f64>) -> Vec<f64> {
371 if self.is_identity() {
372 working
373 } else {
374 self.extract(&working)
375 }
376 }
377}
378
379pub struct WorkingGridXs {
386 pub sigma: Vec<Vec<f64>>,
388 pub layout: WorkingGridLayout,
390}
391
392pub struct WorkingGridXsWithDerivative {
400 pub sigma: Vec<Vec<f64>>,
402 pub dsigma_dt: Vec<Vec<f64>>,
404 pub layout: WorkingGridLayout,
406}
407
408pub fn beer_lambert(cross_sections: &[f64], thickness: f64) -> Vec<f64> {
419 cross_sections
420 .iter()
421 .map(|&sigma| (-thickness * sigma).exp())
422 .collect()
423}
424
425pub fn beer_lambert_multi(
437 cross_sections_per_isotope: &[&[f64]],
438 thicknesses: &[f64],
439) -> Result<Vec<f64>, TransmissionError> {
440 if cross_sections_per_isotope.len() != thicknesses.len() {
441 return Err(TransmissionError::InputMismatch(format!(
442 "cross_sections_per_isotope length ({}) must match thicknesses length ({})",
443 cross_sections_per_isotope.len(),
444 thicknesses.len()
445 )));
446 }
447 if cross_sections_per_isotope.is_empty() {
448 return Err(TransmissionError::InputMismatch(
449 "cross_sections_per_isotope must not be empty".into(),
450 ));
451 }
452
453 let n_energies = cross_sections_per_isotope[0].len();
454 for (k, sigma) in cross_sections_per_isotope.iter().enumerate() {
455 if sigma.len() != n_energies {
456 return Err(TransmissionError::InputMismatch(format!(
457 "cross_sections_per_isotope[{}] length ({}) must match [0] length ({})",
458 k,
459 sigma.len(),
460 n_energies
461 )));
462 }
463 }
464
465 Ok((0..n_energies)
466 .map(|i| {
467 let total_attenuation: f64 = cross_sections_per_isotope
468 .iter()
469 .zip(thicknesses.iter())
470 .map(|(sigma, &thick)| thick * sigma[i])
471 .sum();
472 (-total_attenuation).exp()
473 })
474 .collect())
475}
476
477#[derive(Debug, PartialEq)]
479pub enum SampleParamsError {
480 NonFiniteTemperature(f64),
482 NegativeTemperature(f64),
484}
485
486impl fmt::Display for SampleParamsError {
487 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
488 match self {
489 Self::NonFiniteTemperature(v) => {
490 write!(f, "temperature must be finite, got {v}")
491 }
492 Self::NegativeTemperature(v) => {
493 write!(f, "temperature must be non-negative, got {v}")
494 }
495 }
496 }
497}
498
499impl std::error::Error for SampleParamsError {}
500
501#[derive(Debug, Clone)]
503pub struct SampleParams {
504 temperature_k: f64,
506 isotopes: Vec<(ResonanceData, f64)>,
508}
509
510impl SampleParams {
511 pub fn new(
518 temperature_k: f64,
519 isotopes: Vec<(ResonanceData, f64)>,
520 ) -> Result<Self, SampleParamsError> {
521 if !temperature_k.is_finite() {
522 return Err(SampleParamsError::NonFiniteTemperature(temperature_k));
523 }
524 if temperature_k < 0.0 {
525 return Err(SampleParamsError::NegativeTemperature(temperature_k));
526 }
527 Ok(Self {
528 temperature_k,
529 isotopes,
530 })
531 }
532
533 #[must_use]
535 pub fn temperature_k(&self) -> f64 {
536 self.temperature_k
537 }
538
539 #[must_use]
541 pub fn isotopes(&self) -> &[(ResonanceData, f64)] {
542 &self.isotopes
543 }
544}
545
546#[derive(Debug, Clone)]
548pub struct InstrumentParams {
549 pub resolution: ResolutionFunction,
551}
552
553pub fn forward_model(
577 energies: &[f64],
578 sample: &SampleParams,
579 instrument: Option<&InstrumentParams>,
580) -> Result<Vec<f64>, TransmissionError> {
581 let n = energies.len();
582 if n == 0 {
583 return Ok(vec![]);
584 }
585
586 if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
590 return Err(ResolutionError::UnsortedEnergies.into());
591 }
592
593 let active_rd: Vec<&ResonanceData> = sample
597 .isotopes()
598 .iter()
599 .filter(|(_, t)| *t > 0.0)
600 .map(|(rd, _)| rd)
601 .collect();
602 let ext_grid = build_aux_grid(energies, instrument, &active_rd);
603
604 let (work_energies, work_len): (&[f64], usize) = if let Some((ref ext_e, _)) = ext_grid {
626 (ext_e.as_slice(), ext_e.len())
627 } else {
628 (energies, n)
629 };
630
631 let doppler_xs: Result<Vec<(Vec<f64>, f64)>, TransmissionError> = sample
632 .isotopes()
633 .par_iter()
634 .filter(|(_, thickness)| *thickness > 0.0)
635 .map(|(res_data, thickness)| {
636 let after_doppler =
637 continuous_doppler::broaden(work_energies, res_data, sample.temperature_k())?;
638 Ok((after_doppler, *thickness))
639 })
640 .collect();
641 let doppler_xs = doppler_xs?;
642
643 let mut total_attenuation = vec![0.0f64; work_len];
645 for (xs, thickness) in &doppler_xs {
646 for i in 0..work_len {
647 total_attenuation[i] += thickness * xs[i];
648 }
649 }
650
651 let transmission: Vec<f64> = total_attenuation.iter().map(|&att| (-att).exp()).collect();
653
654 if let Some(inst) = instrument {
656 let t_broadened =
657 resolution::apply_resolution_presorted(work_energies, &transmission, &inst.resolution);
658 if let Some((_, ref data_indices)) = ext_grid {
659 Ok(data_indices.iter().map(|&i| t_broadened[i]).collect())
660 } else {
661 Ok(t_broadened)
662 }
663 } else {
664 Ok(transmission)
665 }
666}
667
668pub fn broadened_cross_sections(
705 energies: &[f64],
706 resonance_data: &[ResonanceData],
707 temperature_k: f64,
708 instrument: Option<&InstrumentParams>,
709 cancel: Option<&AtomicBool>,
710) -> Result<Vec<Vec<f64>>, TransmissionError> {
711 let WorkingGridXs { sigma, layout } = broadened_cross_sections_on_working_grid(
714 energies,
715 resonance_data,
716 temperature_k,
717 instrument,
718 cancel,
719 )?;
720 Ok(sigma.iter().map(|s| layout.extract(s)).collect())
721}
722
723pub fn broadened_cross_sections_on_working_grid(
730 energies: &[f64],
731 resonance_data: &[ResonanceData],
732 temperature_k: f64,
733 instrument: Option<&InstrumentParams>,
734 cancel: Option<&AtomicBool>,
735) -> Result<WorkingGridXs, TransmissionError> {
736 if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
738 return Err(ResolutionError::UnsortedEnergies.into());
739 }
740
741 let rd_refs: Vec<&ResonanceData> = resonance_data.iter().collect();
747 let ext_grid = build_aux_grid(energies, instrument, &rd_refs);
748 let (work_energies, layout) = working_grid_layout(energies, ext_grid.as_ref());
749
750 let result: Result<Vec<Vec<f64>>, TransmissionError> = resonance_data
756 .par_iter()
757 .map(|rd| {
758 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
760 return Err(TransmissionError::Cancelled);
761 }
762
763 continuous_doppler::broaden(work_energies, rd, temperature_k).map_err(Into::into)
764 })
765 .collect();
766
767 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
770 return Err(TransmissionError::Cancelled);
771 }
772
773 Ok(WorkingGridXs {
774 sigma: result?,
775 layout,
776 })
777}
778
779pub fn broadened_cross_sections_for_transmission(
808 energies: &[f64],
809 resonance_data: &[ResonanceData],
810 temperature_k: f64,
811 instrument: &InstrumentParams,
812 thickness_atoms_barn: f64,
813 cancel: Option<&AtomicBool>,
814) -> Result<Vec<Vec<f64>>, TransmissionError> {
815 if !thickness_atoms_barn.is_finite() || thickness_atoms_barn <= 0.0 {
820 return Err(TransmissionError::InputMismatch(format!(
821 "thickness_atoms_barn must be finite and > 0, got {thickness_atoms_barn}"
822 )));
823 }
824 if !energies.windows(2).all(|w| w[0] <= w[1]) {
825 return Err(ResolutionError::UnsortedEnergies.into());
826 }
827
828 let rd_refs: Vec<&ResonanceData> = resonance_data.iter().collect();
829 let ext_grid = build_aux_grid(energies, Some(instrument), &rd_refs);
830 let nd = thickness_atoms_barn;
831
832 let result: Result<Vec<Vec<f64>>, TransmissionError> = resonance_data
833 .par_iter()
834 .map(|rd| {
835 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
836 return Err(TransmissionError::Cancelled);
837 }
838
839 let sigma_eff = if let Some((ref ext_energies, ref data_indices)) = ext_grid {
840 let after_doppler = continuous_doppler::broaden(ext_energies, rd, temperature_k)?;
845
846 let transmission: Vec<f64> = after_doppler
848 .iter()
849 .map(|&sigma| (-nd * sigma).exp())
850 .collect();
851
852 let t_broadened = resolution::apply_resolution_presorted(
854 ext_energies,
855 &transmission,
856 &instrument.resolution,
857 );
858
859 data_indices
861 .iter()
862 .map(|&i| {
863 let t = t_broadened[i].clamp(1e-30, 1.0);
864 -t.ln() / nd
865 })
866 .collect()
867 } else {
868 let after_doppler = continuous_doppler::broaden(energies, rd, temperature_k)?;
871
872 let transmission: Vec<f64> = after_doppler
873 .iter()
874 .map(|&sigma| (-nd * sigma).exp())
875 .collect();
876
877 let t_broadened = resolution::apply_resolution_presorted(
878 energies,
879 &transmission,
880 &instrument.resolution,
881 );
882
883 t_broadened
884 .iter()
885 .map(|&t| {
886 let t_clamped = t.clamp(1e-30, 1.0);
887 -t_clamped.ln() / nd
888 })
889 .collect()
890 };
891
892 Ok(sigma_eff)
893 })
894 .collect();
895
896 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
897 return Err(TransmissionError::Cancelled);
898 }
899
900 result
901}
902
903pub fn unbroadened_cross_sections(
913 energies: &[f64],
914 resonance_data: &[ResonanceData],
915 cancel: Option<&AtomicBool>,
916) -> Result<Vec<Vec<f64>>, TransmissionError> {
917 let result: Result<Vec<Vec<f64>>, TransmissionError> = resonance_data
918 .par_iter()
919 .map(|rd| {
920 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
921 return Err(TransmissionError::Cancelled);
922 }
923 let xs: Vec<f64> = energies
924 .iter()
925 .map(|&e| reich_moore::cross_sections_at_energy(rd, e).total)
926 .collect();
927 Ok(xs)
928 })
929 .collect();
930
931 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
932 return Err(TransmissionError::Cancelled);
933 }
934 result
935}
936
937pub fn broadened_cross_sections_from_base(
948 energies: &[f64],
949 base_xs: &[Vec<f64>],
950 resonance_data: &[ResonanceData],
951 temperature_k: f64,
952 instrument: Option<&InstrumentParams>,
953) -> Result<Vec<Vec<f64>>, TransmissionError> {
954 let WorkingGridXs { sigma, layout } = broadened_cross_sections_from_base_on_working_grid(
959 energies,
960 base_xs,
961 resonance_data,
962 temperature_k,
963 instrument,
964 )?;
965 Ok(sigma.iter().map(|s| layout.extract(s)).collect())
966}
967
968pub fn broadened_cross_sections_from_base_on_working_grid(
977 energies: &[f64],
978 base_xs: &[Vec<f64>],
979 resonance_data: &[ResonanceData],
980 temperature_k: f64,
981 instrument: Option<&InstrumentParams>,
982) -> Result<WorkingGridXs, TransmissionError> {
983 validate_base_xs(energies, base_xs, resonance_data)?;
984 if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
985 return Err(ResolutionError::UnsortedEnergies.into());
986 }
987
988 let rd_refs: Vec<&ResonanceData> = resonance_data.iter().collect();
994 let ext_grid = build_aux_grid(energies, instrument, &rd_refs);
995 let is_data_point = data_point_mask(ext_grid.as_ref());
996 let (work_energies, layout) = working_grid_layout(energies, ext_grid.as_ref());
997
998 let sigma: Result<Vec<Vec<f64>>, TransmissionError> = base_xs
1001 .par_iter()
1002 .zip(resonance_data.par_iter())
1003 .map(|(xs_raw, rd)| {
1004 if temperature_k > 0.0 {
1005 return continuous_doppler::broaden(work_energies, rd, temperature_k)
1011 .map_err(Into::into);
1012 }
1013 let xs_work = if let Some((ref ext_energies, ref data_indices)) = ext_grid {
1014 let mask = is_data_point.as_ref().unwrap();
1015 build_extended_xs_from_base(ext_energies, data_indices, mask, xs_raw, rd)
1016 } else {
1017 xs_raw.clone()
1018 };
1019 Ok(xs_work)
1020 })
1021 .collect();
1022
1023 Ok(WorkingGridXs {
1024 sigma: sigma?,
1025 layout,
1026 })
1027}
1028
1029pub fn broadened_cross_sections_with_analytical_derivative_from_base(
1041 energies: &[f64],
1042 base_xs: &[Vec<f64>],
1043 resonance_data: &[ResonanceData],
1044 temperature_k: f64,
1045 instrument: Option<&InstrumentParams>,
1046) -> Result<BroadenedXsWithDerivative, TransmissionError> {
1047 let WorkingGridXsWithDerivative {
1050 sigma,
1051 dsigma_dt,
1052 layout,
1053 } = broadened_cross_sections_with_analytical_derivative_from_base_on_working_grid(
1054 energies,
1055 base_xs,
1056 resonance_data,
1057 temperature_k,
1058 instrument,
1059 )?;
1060 let xs_all = sigma.iter().map(|s| layout.extract(s)).collect();
1061 let dxs_all = dsigma_dt.iter().map(|d| layout.extract(d)).collect();
1062 Ok((xs_all, dxs_all))
1063}
1064
1065pub fn broadened_cross_sections_with_analytical_derivative_from_base_on_working_grid(
1074 energies: &[f64],
1075 base_xs: &[Vec<f64>],
1076 resonance_data: &[ResonanceData],
1077 temperature_k: f64,
1078 instrument: Option<&InstrumentParams>,
1079) -> Result<WorkingGridXsWithDerivative, TransmissionError> {
1080 validate_base_xs(energies, base_xs, resonance_data)?;
1081 if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
1082 return Err(ResolutionError::UnsortedEnergies.into());
1083 }
1084
1085 let rd_refs: Vec<&ResonanceData> = resonance_data.iter().collect();
1087 let ext_grid = build_aux_grid(energies, instrument, &rd_refs);
1088 let is_data_point = data_point_mask(ext_grid.as_ref());
1089 let (work_energies, layout) = working_grid_layout(energies, ext_grid.as_ref());
1090
1091 type IsotopeXsDxs = Result<(Vec<f64>, Vec<f64>), TransmissionError>;
1095 let results: Vec<IsotopeXsDxs> = base_xs
1096 .par_iter()
1097 .zip(resonance_data.par_iter())
1098 .map(|(xs_raw, rd)| {
1099 if temperature_k > 0.0 {
1100 return continuous_doppler::broaden_with_derivative(
1104 work_energies,
1105 rd,
1106 temperature_k,
1107 )
1108 .map_err(Into::into);
1109 }
1110 let xs_work = if let Some((ref ext_energies, ref data_indices)) = ext_grid {
1111 let mask = is_data_point.as_ref().unwrap();
1112 build_extended_xs_from_base(ext_energies, data_indices, mask, xs_raw, rd)
1113 } else {
1114 xs_raw.clone()
1115 };
1116 let zeros = vec![0.0; work_energies.len()];
1117 Ok((xs_work, zeros))
1118 })
1119 .collect();
1120
1121 let mut sigma = Vec::with_capacity(base_xs.len());
1123 let mut dsigma_dt = Vec::with_capacity(base_xs.len());
1124 for r in results {
1125 let (xs, dxs) = r?;
1126 sigma.push(xs);
1127 dsigma_dt.push(dxs);
1128 }
1129 Ok(WorkingGridXsWithDerivative {
1130 sigma,
1131 dsigma_dt,
1132 layout,
1133 })
1134}
1135
1136pub fn forward_model_from_base_xs(
1158 energies: &[f64],
1159 base_xs: &[Vec<f64>],
1160 resonance_data: &[ResonanceData],
1161 thicknesses: &[f64],
1162 temperature_k: f64,
1163 instrument: Option<&InstrumentParams>,
1164) -> Result<Vec<f64>, TransmissionError> {
1165 if base_xs.len() != resonance_data.len() || thicknesses.len() != resonance_data.len() {
1166 return Err(TransmissionError::InputMismatch(format!(
1167 "forward_model_from_base_xs: base_xs({})/thicknesses({})/resonance_data({}) length mismatch",
1168 base_xs.len(),
1169 thicknesses.len(),
1170 resonance_data.len(),
1171 )));
1172 }
1173 for (i, row) in base_xs.iter().enumerate() {
1174 if row.len() != energies.len() {
1175 return Err(TransmissionError::InputMismatch(format!(
1176 "base_xs[{i}] has {} energies but expected {}",
1177 row.len(),
1178 energies.len(),
1179 )));
1180 }
1181 }
1182 let n = energies.len();
1183 if n == 0 {
1184 return Ok(vec![]);
1185 }
1186
1187 if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
1190 return Err(ResolutionError::UnsortedEnergies.into());
1191 }
1192
1193 let rd_refs: Vec<&ResonanceData> = resonance_data.iter().collect();
1196 let ext_grid = build_aux_grid(energies, instrument, &rd_refs);
1197 let is_data_point: Option<Vec<bool>> = ext_grid.as_ref().map(|(ext_e, di)| {
1198 let mut mask = vec![false; ext_e.len()];
1199 for &idx in di {
1200 mask[idx] = true;
1201 }
1202 mask
1203 });
1204
1205 let (work_energies, work_len): (&[f64], usize) = if let Some((ref ext_e, _)) = ext_grid {
1207 (ext_e.as_slice(), ext_e.len())
1208 } else {
1209 (energies, n)
1210 };
1211
1212 let doppler_xs: Result<Vec<Vec<f64>>, TransmissionError> = base_xs
1215 .par_iter()
1216 .zip(resonance_data.par_iter())
1217 .map(|(xs_raw, rd)| {
1218 if temperature_k > 0.0 {
1219 return continuous_doppler::broaden(work_energies, rd, temperature_k)
1220 .map_err(Into::into);
1221 }
1222 let xs_ext = if let Some((ref ext_energies, ref data_indices)) = ext_grid {
1223 let mask = is_data_point.as_ref().unwrap();
1224 build_extended_xs_from_base(ext_energies, data_indices, mask, xs_raw, rd)
1225 } else {
1226 xs_raw.clone()
1227 };
1228 Ok(xs_ext)
1229 })
1230 .collect();
1231 let doppler_xs = doppler_xs?;
1232
1233 let mut total_attenuation = vec![0.0f64; work_len];
1235 for (xs, &thickness) in doppler_xs.iter().zip(thicknesses.iter()) {
1236 if thickness <= 0.0 {
1237 continue;
1238 }
1239 for i in 0..work_len {
1240 total_attenuation[i] += thickness * xs[i];
1241 }
1242 }
1243 let transmission: Vec<f64> = total_attenuation.iter().map(|&att| (-att).exp()).collect();
1244
1245 if let Some(inst) = instrument {
1250 let t_broadened =
1251 resolution::apply_resolution_presorted(work_energies, &transmission, &inst.resolution);
1252 if let Some((_, ref data_indices)) = ext_grid {
1253 Ok(data_indices.iter().map(|&i| t_broadened[i]).collect())
1254 } else {
1255 Ok(t_broadened)
1256 }
1257 } else {
1258 Ok(transmission)
1259 }
1260}
1261
1262#[cfg(test)]
1263mod tests {
1264 use super::*;
1265 use nereids_core::types::Isotope;
1266 use nereids_endf::resonance::test_support::u238_single_resonance;
1267 use nereids_endf::resonance::{LGroup, Resonance, ResonanceFormalism, ResonanceRange};
1268
1269 #[test]
1271 fn resonance_centres_include_lrf7_levels() {
1272 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1273 .join("../../tests/data/endf/mf2/W-182.endf");
1274 let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path:?}: {e}"));
1275 let data = nereids_endf::parser::parse_endf_file2(&text).expect("W-182");
1276 let rml = data.ranges[0].r_matrix_limited.as_ref().expect("LRF=7");
1277 let mut expected: Vec<f64> = rml
1278 .spin_groups
1279 .iter()
1280 .flat_map(|g| g.resonances.iter().map(|r| r.energy))
1281 .collect();
1282 expected.sort_by(f64::total_cmp);
1283 expected.dedup();
1284 assert!(!expected.is_empty());
1285 assert_eq!(resonance_center_energies(&[&data]), expected);
1286 }
1287
1288 #[test]
1295 fn working_grid_layout_matches_across_separate_calls() {
1296 let data = u238_single_resonance();
1297 let energies: Vec<f64> = (0..401).map(|i| 4.0 + (i as f64) * 0.015).collect();
1298 let inst = InstrumentParams {
1299 resolution: crate::resolution::ResolutionFunction::Gaussian(
1300 crate::resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
1301 ),
1302 };
1303 let layout_a = resolution_working_grid(&energies, Some(&inst), &[&data]).unwrap();
1304 let working = broadened_cross_sections_on_working_grid(
1305 &energies,
1306 std::slice::from_ref(&data),
1307 300.0,
1308 Some(&inst),
1309 None,
1310 )
1311 .unwrap();
1312 let layout_b = working.layout;
1313 assert!(
1314 !layout_a.is_identity(),
1315 "Gaussian resolution should build a non-identity auxiliary grid"
1316 );
1317 assert_eq!(
1318 layout_a.data_indices, layout_b.data_indices,
1319 "data-index maps must match across the two builders"
1320 );
1321 assert_eq!(
1322 layout_a.energies.len(),
1323 layout_b.energies.len(),
1324 "working-grid length must match"
1325 );
1326 for (a, b) in layout_a.energies.iter().zip(layout_b.energies.iter()) {
1327 assert_eq!(
1328 a.to_bits(),
1329 b.to_bits(),
1330 "working-grid energies must be bit-identical"
1331 );
1332 }
1333 }
1334
1335 #[test]
1336 fn test_beer_lambert_zero_thickness() {
1337 let xs = vec![100.0, 200.0, 300.0];
1338 let t = beer_lambert(&xs, 0.0);
1339 assert_eq!(t, vec![1.0, 1.0, 1.0]);
1340 }
1341
1342 #[test]
1343 fn test_beer_lambert_basic() {
1344 let xs = vec![100.0];
1347 let t = beer_lambert(&xs, 0.01);
1348 assert!(
1349 (t[0] - (-1.0_f64).exp()).abs() < 1e-10,
1350 "T = {}, expected {}",
1351 t[0],
1352 (-1.0_f64).exp()
1353 );
1354 }
1355
1356 #[test]
1357 fn test_beer_lambert_opaque() {
1358 let xs = vec![1000.0];
1360 let t = beer_lambert(&xs, 1.0);
1361 assert_eq!(t[0], 0.0, "T = {}, expected 0.0", t[0]);
1362 }
1363
1364 #[test]
1365 fn test_beer_lambert_multi_additive() {
1366 let xs1 = vec![100.0];
1371 let xs2 = vec![200.0];
1372 let t = beer_lambert_multi(&[&xs1, &xs2], &[0.01, 0.005]).unwrap();
1373 assert!(
1374 (t[0] - (-2.0_f64).exp()).abs() < 1e-10,
1375 "T = {}, expected {}",
1376 t[0],
1377 (-2.0_f64).exp()
1378 );
1379 }
1380
1381 #[test]
1382 fn test_transmission_dip_at_resonance() {
1383 let data = u238_single_resonance();
1386 let thickness = 0.001; let energies = [1.0, 3.0, 6.674, 10.0, 20.0];
1390 let xs: Vec<f64> = energies
1391 .iter()
1392 .map(|&e| reich_moore::cross_sections_at_energy(&data, e).total)
1393 .collect();
1394 let trans = beer_lambert(&xs, thickness);
1395
1396 let t_on_res = trans[2];
1398 let t_off_res = trans[0]; assert!(
1401 t_on_res < t_off_res,
1402 "On-resonance T ({}) should be < off-resonance T ({})",
1403 t_on_res,
1404 t_off_res
1405 );
1406
1407 assert!(
1409 t_on_res < 0.01,
1410 "On-resonance T ({}) should be very small",
1411 t_on_res
1412 );
1413 }
1414
1415 #[test]
1416 fn test_forward_model_no_broadening() {
1417 let data = u238_single_resonance();
1420 let thickness = 0.001;
1421
1422 let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
1423
1424 let xs: Vec<f64> = energies
1426 .iter()
1427 .map(|&e| reich_moore::cross_sections_at_energy(&data, e).total)
1428 .collect();
1429 let t_direct = beer_lambert(&xs, thickness);
1430
1431 let sample = SampleParams::new(0.0, vec![(data, thickness)]).unwrap();
1433 let t_forward = forward_model(&energies, &sample, None).unwrap();
1434
1435 for i in 0..energies.len() {
1436 assert!(
1437 (t_direct[i] - t_forward[i]).abs() < 1e-10,
1438 "Mismatch at E={}: direct={}, forward={}",
1439 energies[i],
1440 t_direct[i],
1441 t_forward[i]
1442 );
1443 }
1444 }
1445
1446 #[test]
1447 fn test_forward_model_with_broadening() {
1448 let data = u238_single_resonance();
1451 let thickness = 0.0001; let energies: Vec<f64> = (0..401).map(|i| 5.0 + (i as f64) * 0.01).collect();
1454
1455 let sample_cold = SampleParams::new(0.0, vec![(data.clone(), thickness)]).unwrap();
1457 let t_cold = forward_model(&energies, &sample_cold, None).unwrap();
1458
1459 let sample_hot = SampleParams::new(300.0, vec![(data, thickness)]).unwrap();
1461 let t_hot = forward_model(&energies, &sample_hot, None).unwrap();
1462
1463 let min_cold = t_cold.iter().cloned().fold(f64::MAX, f64::min);
1465 let min_hot = t_hot.iter().cloned().fold(f64::MAX, f64::min);
1466
1467 assert!(
1469 min_hot > min_cold,
1470 "Broadened min T ({}) should be > unbroadened min T ({})",
1471 min_hot,
1472 min_cold
1473 );
1474 }
1475
1476 #[test]
1477 fn test_forward_model_multi_isotope() {
1478 let u238 = u238_single_resonance();
1480
1481 let other = ResonanceData {
1483 isotope: Isotope::new(1, 10).unwrap(),
1484 za: 1010,
1485 awr: 10.0,
1486 smooth: Default::default(),
1487 ranges: vec![ResonanceRange {
1488 energy_low: 0.0,
1489 energy_high: 100.0,
1490 resolved: true,
1491 formalism: ResonanceFormalism::ReichMoore,
1492 target_spin: 0.0,
1493 scattering_radius: 5.0,
1494 naps: 1,
1495 l_groups: vec![LGroup {
1496 l: 0,
1497 awr: 10.0,
1498 apl: 5.0,
1499 qx: 0.0,
1500 lrx: 0,
1501 resonances: vec![Resonance {
1502 energy: 20.0,
1503 j: 0.5,
1504 gn: 0.1,
1505 gg: 0.05,
1506 gfa: 0.0,
1507 gfb: 0.0,
1508 }],
1509 }],
1510 ap_table: None,
1511 r_external: vec![],
1512 r_matrix_limited: None,
1513 }],
1514 };
1515
1516 let energies: Vec<f64> = (0..301).map(|i| 1.0 + (i as f64) * 0.1).collect();
1517
1518 let sample = SampleParams::new(0.0, vec![(u238, 0.0001), (other, 0.0001)]).unwrap();
1519 let t = forward_model(&energies, &sample, None).unwrap();
1520
1521 let idx_u238 = energies
1523 .iter()
1524 .position(|&e| (e - 6.7).abs() < 0.05)
1525 .unwrap();
1526 let idx_other = energies
1528 .iter()
1529 .position(|&e| (e - 20.0).abs() < 0.05)
1530 .unwrap();
1531 let idx_off = energies
1533 .iter()
1534 .position(|&e| (e - 15.0).abs() < 0.05)
1535 .unwrap();
1536
1537 assert!(
1539 t[idx_u238] < t[idx_off],
1540 "U-238 dip at 6.7 eV: T={}, off-res: T={}",
1541 t[idx_u238],
1542 t[idx_off]
1543 );
1544 assert!(
1545 t[idx_other] < t[idx_off],
1546 "Other dip at 20 eV: T={}, off-res: T={}",
1547 t[idx_other],
1548 t[idx_off]
1549 );
1550 }
1551
1552 #[test]
1553 fn test_broadened_xs_analytical_derivative() {
1554 let data = u238_single_resonance();
1557 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1558 let temperature = 300.0;
1559
1560 let base_xs =
1561 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1562 let (xs, dxs_dt) = broadened_cross_sections_with_analytical_derivative_from_base(
1563 &energies,
1564 &base_xs,
1565 std::slice::from_ref(&data),
1566 temperature,
1567 None,
1568 )
1569 .unwrap();
1570
1571 assert_eq!(xs.len(), 1, "one isotope");
1573 assert_eq!(dxs_dt.len(), 1, "one isotope derivative");
1574 assert_eq!(xs[0].len(), energies.len());
1575 assert_eq!(dxs_dt[0].len(), energies.len());
1576
1577 let idx_res = energies
1580 .iter()
1581 .position(|&e| (e - 6.674).abs() < 0.05)
1582 .unwrap();
1583 assert!(
1584 dxs_dt[0][idx_res].abs() > 0.0,
1585 "dσ/dT should be non-zero near resonance, got {}",
1586 dxs_dt[0][idx_res]
1587 );
1588
1589 let big_dt = 1.0;
1592 let xs_up = broadened_cross_sections(
1593 &energies,
1594 std::slice::from_ref(&data),
1595 temperature + big_dt,
1596 None,
1597 None,
1598 )
1599 .unwrap();
1600 let xs_down =
1601 broadened_cross_sections(&energies, &[data], temperature - big_dt, None, None).unwrap();
1602
1603 let manual_deriv: Vec<f64> = xs_up[0]
1604 .iter()
1605 .zip(xs_down[0].iter())
1606 .map(|(&u, &d)| (u - d) / (2.0 * big_dt))
1607 .collect();
1608
1609 let deriv_analytical = dxs_dt[0][idx_res];
1611 let deriv_coarse = manual_deriv[idx_res];
1612 let rel_err = (deriv_analytical - deriv_coarse).abs()
1613 / deriv_analytical.abs().max(deriv_coarse.abs()).max(1e-30);
1614 assert!(
1615 rel_err < 0.05,
1616 "Analytical vs FD derivatives disagree: analytical={}, coarse={}, rel_err={}",
1617 deriv_analytical,
1618 deriv_coarse,
1619 rel_err,
1620 );
1621 }
1622
1623 #[test]
1624 fn test_broadened_xs_analytical_derivative_low_temperature() {
1625 let data = u238_single_resonance();
1627 let energies: Vec<f64> = (0..51).map(|i| 5.0 + (i as f64) * 0.1).collect();
1628
1629 let base_xs =
1630 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1631
1632 let (xs_low, dxs_low) = broadened_cross_sections_with_analytical_derivative_from_base(
1634 &energies,
1635 &base_xs,
1636 std::slice::from_ref(&data),
1637 0.05,
1638 None,
1639 )
1640 .unwrap();
1641 assert!(!xs_low.is_empty());
1642 for deriv_vec in &dxs_low {
1645 for &d in deriv_vec {
1646 assert!(d.is_finite(), "derivative must be finite at T=0.05 K");
1647 }
1648 }
1649
1650 let (xs_zero, dxs_zero) = broadened_cross_sections_with_analytical_derivative_from_base(
1652 &energies,
1653 &base_xs,
1654 std::slice::from_ref(&data),
1655 0.0,
1656 None,
1657 )
1658 .unwrap();
1659 assert!(!xs_zero.is_empty());
1660 for deriv_vec in &dxs_zero {
1661 for &d in deriv_vec {
1662 assert!(d.is_finite(), "derivative must be finite at T=0.0 K");
1663 }
1664 }
1665 }
1666
1667 #[test]
1670 fn test_sample_params_valid() {
1671 let sample = SampleParams::new(300.0, vec![]).unwrap();
1672 assert!((sample.temperature_k() - 300.0).abs() < 1e-15);
1673 assert!(sample.isotopes().is_empty());
1674 }
1675
1676 #[test]
1677 fn test_sample_params_zero_temperature() {
1678 let sample = SampleParams::new(0.0, vec![]).unwrap();
1679 assert!((sample.temperature_k()).abs() < 1e-15);
1680 }
1681
1682 #[test]
1683 fn test_sample_params_rejects_negative_temperature() {
1684 let err = SampleParams::new(-1.0, vec![]).unwrap_err();
1685 assert_eq!(err, SampleParamsError::NegativeTemperature(-1.0));
1686 }
1687
1688 #[test]
1689 fn test_sample_params_rejects_nan_temperature() {
1690 let err = SampleParams::new(f64::NAN, vec![]).unwrap_err();
1691 assert!(matches!(err, SampleParamsError::NonFiniteTemperature(_)));
1692 }
1693
1694 #[test]
1695 fn test_sample_params_rejects_infinite_temperature() {
1696 let err = SampleParams::new(f64::INFINITY, vec![]).unwrap_err();
1697 assert!(matches!(err, SampleParamsError::NonFiniteTemperature(_)));
1698 }
1699
1700 #[test]
1701 fn test_sample_params_rejects_neg_infinite_temperature() {
1702 let err = SampleParams::new(f64::NEG_INFINITY, vec![]).unwrap_err();
1703 assert!(matches!(err, SampleParamsError::NonFiniteTemperature(_)));
1704 }
1705
1706 #[test]
1709 fn test_forward_model_from_base_xs_matches_forward_model() {
1710 let data = u238_single_resonance();
1711 let thickness = 0.0005;
1712 let temperature = 300.0;
1713 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1714
1715 let sample = SampleParams::new(temperature, vec![(data.clone(), thickness)]).unwrap();
1717 let t_ref = forward_model(&energies, &sample, None).unwrap();
1718
1719 let base_xs =
1721 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1722 let t_cached = forward_model_from_base_xs(
1723 &energies,
1724 &base_xs,
1725 std::slice::from_ref(&data),
1726 &[thickness],
1727 temperature,
1728 None,
1729 )
1730 .unwrap();
1731
1732 for (i, (&r, &c)) in t_ref.iter().zip(t_cached.iter()).enumerate() {
1733 assert!(
1734 (r - c).abs() < 1e-12,
1735 "Mismatch at E[{}]={}: ref={}, cached={}",
1736 i,
1737 energies[i],
1738 r,
1739 c
1740 );
1741 }
1742 }
1743
1744 #[test]
1745 fn test_broadened_from_base_matches_broadened() {
1746 let data = u238_single_resonance();
1747 let temperature = 300.0;
1748 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1749
1750 let xs_ref = broadened_cross_sections(
1751 &energies,
1752 std::slice::from_ref(&data),
1753 temperature,
1754 None,
1755 None,
1756 )
1757 .unwrap();
1758 let base_xs =
1759 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1760 let xs_cached = broadened_cross_sections_from_base(
1761 &energies,
1762 &base_xs,
1763 std::slice::from_ref(&data),
1764 temperature,
1765 None,
1766 )
1767 .unwrap();
1768
1769 assert_eq!(xs_ref.len(), xs_cached.len());
1770 for (r, c) in xs_ref[0].iter().zip(xs_cached[0].iter()) {
1771 assert!(
1772 (r - c).abs() < 1e-12,
1773 "broadened_from_base mismatch: ref={}, cached={}",
1774 r,
1775 c
1776 );
1777 }
1778 }
1779
1780 #[test]
1781 fn test_analytical_derivative_from_base_shape_and_finiteness() {
1782 let data = u238_single_resonance();
1785 let temperature = 300.0;
1786 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1787
1788 let base_xs =
1789 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1790 let (xs, dxs_dt) = broadened_cross_sections_with_analytical_derivative_from_base(
1791 &energies,
1792 &base_xs,
1793 std::slice::from_ref(&data),
1794 temperature,
1795 None,
1796 )
1797 .unwrap();
1798
1799 assert_eq!(xs.len(), 1);
1800 assert_eq!(dxs_dt.len(), 1);
1801 assert_eq!(xs[0].len(), energies.len());
1802 assert_eq!(dxs_dt[0].len(), energies.len());
1803
1804 for &v in &xs[0] {
1806 assert!(v.is_finite(), "XS must be finite, got {v}");
1807 }
1808 for &v in &dxs_dt[0] {
1809 assert!(v.is_finite(), "dXS/dT must be finite, got {v}");
1810 }
1811
1812 let xs_full = broadened_cross_sections(
1814 &energies,
1815 std::slice::from_ref(&data),
1816 temperature,
1817 None,
1818 None,
1819 )
1820 .unwrap();
1821 for (r, c) in xs_full[0].iter().zip(xs[0].iter()) {
1822 assert!(
1823 (r - c).abs() < 1e-12,
1824 "XS mismatch: full={}, from_base={}",
1825 r,
1826 c
1827 );
1828 }
1829 }
1830
1831 #[test]
1842 fn test_forward_model_resolution_after_beer_lambert() {
1843 let data = u238_single_resonance();
1844 let thickness = 0.0005; let temperature = 300.0;
1846
1847 let energies: Vec<f64> = (0..401).map(|i| 4.0 + (i as f64) * 0.015).collect();
1849
1850 let inst = InstrumentParams {
1851 resolution: resolution::ResolutionFunction::Gaussian(
1852 resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
1853 ),
1854 };
1855
1856 let sigma_d = continuous_doppler::broaden(&energies, &data, temperature).unwrap();
1863
1864 let transmission: Vec<f64> = sigma_d.iter().map(|&s| (-thickness * s).exp()).collect();
1866
1867 let t_expected =
1869 resolution::apply_resolution(&energies, &transmission, &inst.resolution).unwrap();
1870
1871 let sigma_broadened =
1873 resolution::apply_resolution(&energies, &sigma_d, &inst.resolution).unwrap();
1874 let t_wrong: Vec<f64> = sigma_broadened
1875 .iter()
1876 .map(|&s| (-thickness * s).exp())
1877 .collect();
1878
1879 let sample = SampleParams::new(temperature, vec![(data, thickness)]).unwrap();
1881 let t_forward = forward_model(&energies, &sample, Some(&inst)).unwrap();
1882
1883 let interior = 20..energies.len() - 20; let mut max_err_correct = 0.0f64;
1888 let mut max_err_wrong = 0.0f64;
1889 for i in interior.clone() {
1890 let err_correct = (t_forward[i] - t_expected[i]).abs();
1891 let err_wrong = (t_forward[i] - t_wrong[i]).abs();
1892 max_err_correct = max_err_correct.max(err_correct);
1893 max_err_wrong = max_err_wrong.max(err_wrong);
1894 }
1895
1896 assert!(
1904 max_err_correct < max_err_wrong,
1905 "forward_model is closer to the WRONG ordering than the correct one. \
1906 Error vs correct = {max_err_correct}, error vs wrong = {max_err_wrong}"
1907 );
1908
1909 assert!(
1912 max_err_correct < max_err_wrong * 0.5,
1913 "forward_model should be clearly closer to the correct ordering. \
1914 Error vs correct = {max_err_correct}, error vs wrong = {max_err_wrong}, \
1915 ratio = {:.2}",
1916 max_err_correct / max_err_wrong
1917 );
1918
1919 let ordering_diff: f64 = interior
1922 .map(|i| (t_expected[i] - t_wrong[i]).abs())
1923 .fold(0.0f64, f64::max);
1924 assert!(
1925 ordering_diff > 1e-4,
1926 "The two orderings should differ measurably at the resonance dip, \
1927 but max diff = {ordering_diff}. Test parameters may be too weak."
1928 );
1929 }
1930
1931 #[test]
1935 fn test_broadened_xs_is_doppler_only_with_instrument() {
1936 let data = u238_single_resonance();
1937 let temperature = 300.0;
1938 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1939
1940 let inst = InstrumentParams {
1941 resolution: resolution::ResolutionFunction::Gaussian(
1942 resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
1943 ),
1944 };
1945
1946 let xs_with_inst = broadened_cross_sections(
1948 &energies,
1949 std::slice::from_ref(&data),
1950 temperature,
1951 Some(&inst),
1952 None,
1953 )
1954 .unwrap();
1955
1956 let xs_no_inst = broadened_cross_sections(
1958 &energies,
1959 std::slice::from_ref(&data),
1960 temperature,
1961 None,
1962 None,
1963 )
1964 .unwrap();
1965
1966 assert_eq!(xs_with_inst.len(), 1);
1970 assert_eq!(xs_no_inst.len(), 1);
1971 assert_eq!(xs_with_inst[0].len(), energies.len());
1972
1973 let sigma_resolved =
1975 resolution::apply_resolution(&energies, &xs_no_inst[0], &inst.resolution).unwrap();
1976
1977 let idx_dip = energies
1981 .iter()
1982 .position(|&e| (e - 6.674).abs() < 0.05)
1983 .unwrap();
1984 let diff_doppler = (xs_with_inst[0][idx_dip] - xs_no_inst[0][idx_dip]).abs();
1985 let diff_resolved = (xs_with_inst[0][idx_dip] - sigma_resolved[idx_dip]).abs();
1986
1987 assert!(
1990 diff_doppler < diff_resolved,
1991 "broadened_cross_sections with instrument should return Doppler-only σ, \
1992 not resolution-broadened σ. \
1993 diff(with_inst, no_inst) = {diff_doppler}, \
1994 diff(with_inst, resolved) = {diff_resolved}"
1995 );
1996 }
1997
1998 #[test]
2001 fn test_broadened_from_base_is_doppler_only_with_instrument() {
2002 let data = u238_single_resonance();
2003 let temperature = 300.0;
2004 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
2005
2006 let inst = InstrumentParams {
2007 resolution: resolution::ResolutionFunction::Gaussian(
2008 resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2009 ),
2010 };
2011
2012 let base_xs =
2013 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
2014
2015 let xs_with_inst = broadened_cross_sections_from_base(
2017 &energies,
2018 &base_xs,
2019 std::slice::from_ref(&data),
2020 temperature,
2021 Some(&inst),
2022 )
2023 .unwrap();
2024
2025 let xs_no_inst = broadened_cross_sections_from_base(
2027 &energies,
2028 &base_xs,
2029 std::slice::from_ref(&data),
2030 temperature,
2031 None,
2032 )
2033 .unwrap();
2034
2035 let sigma_resolved =
2038 resolution::apply_resolution(&energies, &xs_no_inst[0], &inst.resolution).unwrap();
2039
2040 let idx_dip = energies
2041 .iter()
2042 .position(|&e| (e - 6.674).abs() < 0.05)
2043 .unwrap();
2044 let diff_doppler = (xs_with_inst[0][idx_dip] - xs_no_inst[0][idx_dip]).abs();
2045 let diff_resolved = (xs_with_inst[0][idx_dip] - sigma_resolved[idx_dip]).abs();
2046
2047 assert!(
2048 diff_doppler < diff_resolved,
2049 "broadened_cross_sections_from_base with instrument should return Doppler-only σ. \
2050 diff(with_inst, no_inst) = {diff_doppler}, \
2051 diff(with_inst, resolved) = {diff_resolved}"
2052 );
2053 }
2054
2055 #[test]
2060 fn test_forward_model_from_base_xs_matches_forward_model_with_resolution() {
2061 let data = u238_single_resonance();
2062 let thickness = 0.0005;
2063 let temperature = 300.0;
2064 let energies: Vec<f64> = (0..401).map(|i| 4.0 + (i as f64) * 0.015).collect();
2065
2066 let inst = InstrumentParams {
2067 resolution: resolution::ResolutionFunction::Gaussian(
2068 resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2069 ),
2070 };
2071
2072 let sample = SampleParams::new(temperature, vec![(data.clone(), thickness)]).unwrap();
2074 let t_ref = forward_model(&energies, &sample, Some(&inst)).unwrap();
2075
2076 let base_xs =
2078 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
2079 let t_base = forward_model_from_base_xs(
2080 &energies,
2081 &base_xs,
2082 std::slice::from_ref(&data),
2083 &[thickness],
2084 temperature,
2085 Some(&inst),
2086 )
2087 .unwrap();
2088
2089 let interior = 20..energies.len() - 20;
2097 let mut max_err = 0.0f64;
2098 for i in interior.clone() {
2099 max_err = max_err.max((t_ref[i] - t_base[i]).abs());
2100 }
2101 assert!(
2102 max_err < 1e-12,
2103 "forward_model_from_base_xs with resolution must match forward_model \
2104 to round-off (identical aux-grid pipeline). Max error = {max_err}"
2105 );
2106
2107 let t_no_res = forward_model_from_base_xs(
2109 &energies,
2110 &base_xs,
2111 std::slice::from_ref(&data),
2112 &[thickness],
2113 temperature,
2114 None,
2115 )
2116 .unwrap();
2117 let res_diff: f64 = interior
2118 .map(|i| (t_base[i] - t_no_res[i]).abs())
2119 .fold(0.0f64, f64::max);
2120 assert!(
2121 res_diff > 1e-4,
2122 "Resolution should make a measurable difference, but max diff = {res_diff}"
2123 );
2124 }
2125
2126 #[test]
2129 fn test_forward_model_from_base_xs_no_resolution_unchanged() {
2130 let data = u238_single_resonance();
2131 let thickness = 0.0005;
2132 let temperature = 300.0;
2133 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
2134
2135 let sample = SampleParams::new(temperature, vec![(data.clone(), thickness)]).unwrap();
2136 let t_ref = forward_model(&energies, &sample, None).unwrap();
2137
2138 let base_xs =
2139 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
2140 let t_base = forward_model_from_base_xs(
2141 &energies,
2142 &base_xs,
2143 std::slice::from_ref(&data),
2144 &[thickness],
2145 temperature,
2146 None,
2147 )
2148 .unwrap();
2149
2150 for (i, (&r, &b)) in t_ref.iter().zip(t_base.iter()).enumerate() {
2151 assert!(
2152 (r - b).abs() < 1e-12,
2153 "No-resolution mismatch at E[{i}]={}: ref={r}, base={b}",
2154 energies[i]
2155 );
2156 }
2157 }
2158
2159 #[test]
2165 fn test_derivative_helper_is_doppler_only_with_instrument() {
2166 let data = u238_single_resonance();
2167 let temperature = 300.0;
2168 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
2169
2170 let inst = InstrumentParams {
2171 resolution: resolution::ResolutionFunction::Gaussian(
2172 resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2173 ),
2174 };
2175
2176 let base_xs =
2177 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
2178
2179 let (xs_inst, dxs_inst) = broadened_cross_sections_with_analytical_derivative_from_base(
2181 &energies,
2182 &base_xs,
2183 std::slice::from_ref(&data),
2184 temperature,
2185 Some(&inst),
2186 )
2187 .unwrap();
2188
2189 let (xs_none, dxs_none) = broadened_cross_sections_with_analytical_derivative_from_base(
2191 &energies,
2192 &base_xs,
2193 std::slice::from_ref(&data),
2194 temperature,
2195 None,
2196 )
2197 .unwrap();
2198
2199 assert_eq!(xs_inst.len(), 1);
2200 assert_eq!(dxs_inst.len(), 1);
2201
2202 let sigma_resolved =
2206 resolution::apply_resolution(&energies, &xs_none[0], &inst.resolution).unwrap();
2207
2208 let idx_dip = energies
2209 .iter()
2210 .position(|&e| (e - 6.674).abs() < 0.05)
2211 .unwrap();
2212
2213 let diff_doppler = (xs_inst[0][idx_dip] - xs_none[0][idx_dip]).abs();
2216 let diff_resolved = (xs_inst[0][idx_dip] - sigma_resolved[idx_dip]).abs();
2217 assert!(
2218 diff_doppler < diff_resolved,
2219 "derivative helper σ with instrument should be Doppler-only. \
2220 diff(inst, none) = {diff_doppler}, diff(inst, resolved) = {diff_resolved}"
2221 );
2222
2223 let dxs_resolved =
2225 resolution::apply_resolution(&energies, &dxs_none[0], &inst.resolution).unwrap();
2226 let ddiff_doppler = (dxs_inst[0][idx_dip] - dxs_none[0][idx_dip]).abs();
2227 let ddiff_resolved = (dxs_inst[0][idx_dip] - dxs_resolved[idx_dip]).abs();
2228 assert!(
2229 ddiff_doppler < ddiff_resolved,
2230 "derivative helper ∂σ/∂T with instrument should be Doppler-only. \
2231 diff(inst, none) = {ddiff_doppler}, diff(inst, resolved) = {ddiff_resolved}"
2232 );
2233 }
2234
2235 #[test]
2238 fn test_derivative_helper_no_resolution_unchanged() {
2239 let data = u238_single_resonance();
2240 let temperature = 300.0;
2241 let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
2242
2243 let base_xs =
2244 unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
2245
2246 let (xs, dxs) = broadened_cross_sections_with_analytical_derivative_from_base(
2247 &energies,
2248 &base_xs,
2249 std::slice::from_ref(&data),
2250 temperature,
2251 None,
2252 )
2253 .unwrap();
2254
2255 let xs_ref = broadened_cross_sections(
2257 &energies,
2258 std::slice::from_ref(&data),
2259 temperature,
2260 None,
2261 None,
2262 )
2263 .unwrap();
2264
2265 for (i, (&a, &b)) in xs[0].iter().zip(xs_ref[0].iter()).enumerate() {
2266 assert!(
2267 (a - b).abs() < 1e-12,
2268 "σ mismatch at E[{i}]: derivative_helper={a}, broadened={b}"
2269 );
2270 }
2271
2272 assert_eq!(dxs.len(), 1);
2274 assert_eq!(dxs[0].len(), energies.len());
2275 let idx_res = energies
2276 .iter()
2277 .position(|&e| (e - 6.674).abs() < 0.05)
2278 .unwrap();
2279 assert!(
2280 dxs[0][idx_res].abs() > 0.0,
2281 "∂σ/∂T should be non-zero near resonance"
2282 );
2283 for &d in &dxs[0] {
2284 assert!(d.is_finite(), "∂σ/∂T must be finite");
2285 }
2286 }
2287
2288 #[test]
2294 fn test_broadened_for_transmission_rejects_bad_thickness() {
2295 let data = u238_single_resonance();
2296 let energies: Vec<f64> = (0..51).map(|i| 5.0 + (i as f64) * 0.1).collect();
2297 let inst = InstrumentParams {
2298 resolution: resolution::ResolutionFunction::Gaussian(
2299 resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2300 ),
2301 };
2302
2303 for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
2304 let err = broadened_cross_sections_for_transmission(
2305 &energies,
2306 std::slice::from_ref(&data),
2307 300.0,
2308 &inst,
2309 bad,
2310 None,
2311 )
2312 .unwrap_err();
2313 assert!(
2314 matches!(err, TransmissionError::InputMismatch(_)),
2315 "thickness = {bad} should be rejected with InputMismatch, got {err:?}"
2316 );
2317 }
2318
2319 let ok = broadened_cross_sections_for_transmission(
2321 &energies,
2322 std::slice::from_ref(&data),
2323 300.0,
2324 &inst,
2325 0.001,
2326 None,
2327 );
2328 assert!(ok.is_ok(), "valid thickness should succeed: {ok:?}");
2329 }
2330
2331 #[test]
2336 fn from_base_working_grid_rejects_malformed_inputs() {
2337 let rd = vec![u238_single_resonance()];
2338 let energies: Vec<f64> = (0..21).map(|i| 1.0 + (i as f64) * 0.1).collect();
2339 let n_e = energies.len();
2340 let good_base = vec![vec![10.0f64; n_e]];
2341 let e1 = broadened_cross_sections_with_analytical_derivative_from_base(
2343 &energies,
2344 &[vec![10.0; n_e], vec![10.0; n_e]],
2345 &rd,
2346 300.0,
2347 None,
2348 )
2349 .unwrap_err();
2350 assert!(e1.to_string().contains("isotopes"), "got: {e1}");
2351 let e2 = broadened_cross_sections_with_analytical_derivative_from_base(
2353 &energies,
2354 &[vec![10.0; n_e - 1]],
2355 &rd,
2356 300.0,
2357 None,
2358 )
2359 .unwrap_err();
2360 assert!(e2.to_string().contains("energies"), "got: {e2}");
2361 let inst = InstrumentParams {
2363 resolution: resolution::ResolutionFunction::Gaussian(
2364 resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2365 ),
2366 };
2367 let mut unsorted = energies.clone();
2368 unsorted.swap(0, 1);
2369 let e3 = broadened_cross_sections_with_analytical_derivative_from_base(
2370 &unsorted,
2371 &good_base,
2372 &rd,
2373 300.0,
2374 Some(&inst),
2375 )
2376 .unwrap_err();
2377 assert!(e3.to_string().contains("sorted"), "got: {e3}");
2378 }
2379}