Skip to main content

nereids_physics/
transmission.rs

1//! Transmission forward model via the Beer-Lambert law.
2//!
3//! Computes theoretical neutron transmission spectra from resonance parameters,
4//! applying cross-section calculation, Doppler broadening, resolution broadening,
5//! and the Beer-Lambert attenuation law.
6//!
7//! ## Beer-Lambert Law
8//!
9//! For a single isotope:
10//!   T(E) = exp(-n·d·σ(E))
11//!
12//! For multiple isotopes:
13//!   T(E) = exp(-Σᵢ nᵢ·dᵢ·σᵢ(E))
14//!
15//! where n is number density (atoms/cm³), d is thickness (cm),
16//! and σ(E) is the total cross-section in barns (1 barn = 10⁻²⁴ cm²).
17//!
18//! In practice, the product n·d is expressed as "areal density" in
19//! atoms/barn, so T(E) = exp(-thickness × σ(E)) with thickness in atoms/barn.
20//!
21//! ## SAMMY Reference
22//! - `cro/` and `xxx/` modules — cross-section to transmission conversion
23//! - Manual Section II (cross-section theory); transmission experiments
24//!   Section III.E.1
25
26use 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
38/// Build the auxiliary extended grid for resolution broadening.
39///
40/// Every resolution family gets the boundary extension by its kernel's reach;
41/// the Gaussian family also gets the adaptive intermediate points.  Returns
42/// `None` if no extension is needed (no resolution, or grid unchanged).
43///
44/// Intermediate points are inserted only when the resolution broadening at
45/// the grid midpoint uses the PW-linear Gaussian path (exp tail negligible
46/// or absent).  For genuine combined-kernel cases, intermediates create
47/// non-uniform spacing transitions that degrade the Xcoef quadrature.
48fn 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            // P-9: Check the Gaussian-to-exp-tail ratio at MULTIPLE energies
56            // to decide whether intermediates help or hurt.  The ratio C =
57            // W_g/(2·W_e) determines which broadening path is used per-energy
58            // in resolution_broaden_presorted.  When C > 2.5 the PW-linear
59            // Gaussian path is used, which benefits from intermediates.
60            //
61            // wrong decision if the ratio crosses 2.5 within the energy range.
62            // Now checks at 5 points (lo, 25%, mid, 75%, hi) and uses
63            // intermediates if a MAJORITY of points have C > 2.5.
64            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                // Majority rule: use intermediates if ≥3 of 5 points qualify.
77                n_pw_linear >= 3
78            } else {
79                true
80            };
81
82            // Extract (energy_eV, gd_eV) pairs for fine-structure densification.
83            // gd = total resonance width, used by Fspken to identify regions
84            // needing denser grid points around narrow resonances.
85            // SAMMY Ref: dat/mdat4.f90 Fspken lines 243-284
86            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            // Tabulated and Ikeda-Carpenter kernels get the boundary
95            // extension without the Gaussian path's intermediates.
96            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
102/// Extract (energy_eV, gd_eV) pairs from resonance data for fine-structure
103/// grid densification.
104///
105/// `gd` is a level's total width, the sum of its absolute widths
106/// ([`nereids_endf::resonance::ResonanceRange::levels`]), over each range that
107/// [`nereids_endf::resonance::ResonanceRange::is_evaluable`] accepts.
108///
109/// SAMMY Ref: dat/mdat4.f90 Fspken — uses total width to define the region
110/// [E_res − gd, E_res + gd] for fine-structure point insertion.
111fn 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
124/// Distinct resonance center energies (eV) across every isotope and formalism,
125/// sorted ascending.
126///
127/// Used by the energy-scale calibration peak-matching seed: the
128/// (t0, L_scale) calibration is recovered by matching measured transmission-dip
129/// positions to these known resonance energies via the linear TOF relation
130/// `tof_measured = t0 + L_scale · tof_nominal`, which seeds the LM into the
131/// global-minimum basin of the (sharply non-convex, post-) calibration χ²
132/// surface — a basin too thin for a cold start or grid scan to find reliably.
133///
134/// **Deduplicated**: two resonances at the same center energy
135/// (e.g. across isotopes in a grouped fit) are a single POSITION for dip
136/// matching — multiplicity is irrelevant — and a duplicate would drive the
137/// minimum inter-resonance spacing, and hence the seed's `match_tol`, to 0,
138/// rejecting every dip and silently dropping the seed to a cold start.
139pub 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    // Sorted ⇒ exact duplicates are adjacent; `dedup` removes them.
146    e.dedup();
147    e
148}
149
150/// Build the unbroadened cross-section vector on the extended/auxiliary grid
151/// from cached data-grid values.
152///
153/// Data-grid positions are copied verbatim from `xs_raw` (the cached
154/// unbroadened σ for this isotope); the auxiliary-only positions (boundary
155/// extension + fine-structure points) are evaluated fresh.  `is_data_point`
156/// marks which extended-grid indices are data-grid points.
157///
158/// This is the cheap reuse of cached XS that the base-XS family relies on: only
159/// the few hundred auxiliary points are recomputed, not the full grid.
160fn 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
179/// Validate `base_xs` shape against `resonance_data` and `energies`.
180///
181/// Shared by the base-XS broadening family so the same `InputMismatch`
182/// diagnostics are produced from one place.
183fn 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
207/// Build a bool mask identifying data-grid positions within the extended grid.
208///
209/// `None` when no auxiliary grid was built (the working grid is the data grid).
210fn 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
220/// Resolve the working grid (extended grid when available, else the data grid)
221/// and the [`WorkingGridLayout`] mapping data points back into it.
222fn 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
238/// Compute the working-grid layout for `(energies, instrument, resonance_data)`.
239///
240/// With a resolution function the grid is extended past both ends by the
241/// kernel's reach ([`ResolutionFunction::grid_bounds_ev`]), and a Gaussian
242/// also gets the intermediate points and resonance fine structure.  Without
243/// one the data grid comes back with identity indices.
244///
245/// `resonance_data` may be empty (e.g. the energy-scale model has no resonance
246/// data of its own); the auxiliary grid then carries boundary extension only,
247/// with no resonance fine-structure densification.
248///
249/// # Errors
250/// * [`TransmissionError::Resolution`] — if `instrument` is `Some` and
251///   `energies` is not sorted ascending.
252pub 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/// Errors from the transmission forward model.
266#[derive(Debug)]
267pub enum TransmissionError {
268    /// The energy grid is not sorted or has a length mismatch with data.
269    Resolution(ResolutionError),
270    /// Doppler broadening parameter validation failed.
271    Doppler(DopplerParamsError),
272    /// Doppler broadening input validation failed (e.g. length mismatch).
273    DopplerBroadening(crate::doppler::DopplerError),
274    /// Computation was cancelled via the cancel token.
275    Cancelled,
276    /// Input array mismatch (e.g. cross-sections vs thicknesses length).
277    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
322/// Broadened cross-sections and their temperature derivative.
323///
324/// `xs[k][e]` is the Doppler+resolution-broadened cross-section for isotope
325/// `k` at energy index `e`; `dxs_dt[k][e]` is the analytical derivative
326/// with respect to temperature.
327pub type BroadenedXsWithDerivative = (Vec<Vec<f64>>, Vec<Vec<f64>>);
328
329/// The working energy grid used for broadening, plus the map back to the
330/// data grid.
331///
332/// Doppler, Beer-Lambert and resolution run on the working grid, the data
333/// grid extended by the kernel's reach plus a Gaussian's intermediate and
334/// fine-structure points, and the data points are extracted last.  A
335/// [`crate::resolution::ResolutionPlan`] must be built on [`Self::energies`].
336#[derive(Debug, Clone)]
337pub struct WorkingGridLayout {
338    /// Working-grid energies (eV, ascending).  Equals the input data grid
339    /// when no auxiliary grid was built.
340    pub energies: Vec<f64>,
341    /// `data_indices[i]` is the index of data energy `i` within
342    /// [`Self::energies`].  Identity (`0..n`) when no auxiliary grid.
343    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    /// `true` when the working grid is the data grid itself (no auxiliary
355    /// extension was built).  In that case [`Self::extract`] is a no-op clone.
356    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    /// Extract the data-grid points from a working-grid spectrum.
366    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
379/// Working-grid Doppler-broadened cross-sections plus the data-grid map.
380///
381/// Resolution broadening is **not** applied (resolution is applied
382/// to the total transmission after Beer-Lambert).  The caller applies
383/// Beer-Lambert + resolution on [`WorkingGridLayout::energies`] and extracts
384/// the data points last via [`WorkingGridLayout::extract`].
385pub struct WorkingGridXs {
386    /// Doppler-broadened σ per isotope, on the working grid.
387    pub sigma: Vec<Vec<f64>>,
388    /// Working-grid layout (energies + data-grid index map).
389    pub layout: WorkingGridLayout,
390}
391
392/// Working-grid Doppler-broadened cross-sections and their analytical
393/// temperature derivative, plus the data-grid map.
394///
395/// Like [`WorkingGridXs`] but additionally carries `∂σ/∂T` on the working
396/// grid so the analytical Jacobian can form `−T·Σᵢ nᵢ·rᵢ·∂σᵢ/∂T` on the
397/// working grid, resolution-broaden it there, and extract the data points
398/// last.
399pub struct WorkingGridXsWithDerivative {
400    /// Doppler-broadened σ per isotope, on the working grid.
401    pub sigma: Vec<Vec<f64>>,
402    /// `∂σ/∂T` per isotope, on the working grid.
403    pub dsigma_dt: Vec<Vec<f64>>,
404    /// Working-grid layout (energies + data-grid index map).
405    pub layout: WorkingGridLayout,
406}
407
408/// Compute transmission from cross-sections via Beer-Lambert law.
409///
410/// T(E) = exp(-thickness × σ(E))
411///
412/// # Arguments
413/// * `cross_sections` — Total cross-sections in barns at each energy point.
414/// * `thickness` — Areal density in atoms/barn (= number_density × path_length).
415///
416/// # Returns
417/// Transmission values (0 to 1) at each energy point.
418pub 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
425/// Compute transmission for multiple isotopes.
426///
427/// T(E) = exp(-Σᵢ thicknessᵢ × σᵢ(E))
428///
429/// # Arguments
430/// * `cross_sections_per_isotope` — Vec of cross-section arrays, one per isotope.
431///   Each inner slice has the same length as the energy grid.
432/// * `thicknesses` — Areal density (atoms/barn) for each isotope.
433///
434/// # Returns
435/// Combined transmission values at each energy point.
436pub 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/// Errors from `SampleParams` construction.
478#[derive(Debug, PartialEq)]
479pub enum SampleParamsError {
480    /// Temperature must be finite.
481    NonFiniteTemperature(f64),
482    /// Temperature must be non-negative.
483    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/// Sample description for the forward model.
502#[derive(Debug, Clone)]
503pub struct SampleParams {
504    /// Temperature in Kelvin (for Doppler broadening).
505    temperature_k: f64,
506    /// Isotope compositions: (resonance data, areal density in atoms/barn).
507    isotopes: Vec<(ResonanceData, f64)>,
508}
509
510impl SampleParams {
511    /// Create validated sample parameters.
512    ///
513    /// # Errors
514    /// Returns `SampleParamsError::NonFiniteTemperature` if `temperature_k` is
515    /// NaN or infinity.
516    /// Returns `SampleParamsError::NegativeTemperature` if `temperature_k < 0.0`.
517    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    /// Returns the sample temperature in Kelvin.
534    #[must_use]
535    pub fn temperature_k(&self) -> f64 {
536        self.temperature_k
537    }
538
539    /// Returns the isotope compositions: (resonance data, areal density).
540    #[must_use]
541    pub fn isotopes(&self) -> &[(ResonanceData, f64)] {
542        &self.isotopes
543    }
544}
545
546/// Optional instrument resolution parameters.
547#[derive(Debug, Clone)]
548pub struct InstrumentParams {
549    /// Resolution broadening function (Gaussian or tabulated).
550    pub resolution: ResolutionFunction,
551}
552
553/// Compute a complete theoretical transmission spectrum.
554///
555/// This is the main forward model that chains:
556///   ENDF parameters → cross-sections → Doppler broadening → resolution → transmission
557///
558/// # Arguments
559/// * `energies` — Energy grid in eV (sorted ascending).
560/// * `sample` — Sample parameters (isotopes with areal densities, temperature).
561/// * `instrument` — Optional instrument parameters (resolution broadening).
562///
563/// # Returns
564/// Theoretical transmission spectrum on the energy grid.
565///
566/// # Errors
567/// * [`TransmissionError::Resolution`] — if resolution broadening is
568///   enabled (`instrument` is `Some`) and `energies` is not sorted ascending.
569/// * [`TransmissionError::Doppler`] — if Doppler broadening is enabled
570///   (`temperature_k > 0.0`) and `DopplerParams` validation fails
571///   (e.g., non-positive or non-finite AWR).
572///
573/// **Note**: isotopes with thickness <= 0.0 are silently skipped
574/// (they contribute zero attenuation). This allows callers to include
575/// inactive isotopes in `SampleParams` without causing errors.
576pub 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    // Validate energy grid once before the per-isotope loop so that
587    // resolution broadening can use the presorted (unchecked) path,
588    // avoiding redundant O(N) sort checks per isotope.
589    if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
590        return Err(ResolutionError::UnsortedEnergies.into());
591    }
592
593    // Build auxiliary grid with boundary extension + resonance fine-structure.
594    // Collect references to avoid cloning full ResonanceData structs.
595    // SAMMY Ref: dat/mdat4.f90 Escale, Fspken, Add_Pnts
596    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    // Compute Doppler-broadened cross-sections for all isotopes in parallel.
605    // Resolution is NOT applied here — it must be applied after Beer-Lambert
606    // on the total transmission.
607    //
608    // SAMMY Ref: DopplerAndResolutionBroadener.cpp — resolution broadening is
609    // applied to T(E), not to σ(E).  Due to Jensen's inequality (exp is
610    // convex), broadening σ before the nonlinear Beer-Lambert systematically
611    // overestimates effective cross-sections at resonance peaks.
612    //
613    // Correct pipeline:
614    //   1. Per-isotope: σ → Doppler → σ_D   (on working grid)
615    //   2. Accumulate:  attenuation = Σᵢ nᵢ·σ_{D,i}
616    //   3. Beer-Lambert: T = exp(−attenuation)
617    //   4. Resolution:  T_broad = R ⊗ T     (on working grid)
618    //   5. Extract at data positions
619    //
620    // The working grid is the extended grid (with boundary+fine-structure
621    // points) when available, otherwise the data grid.
622
623    // Determine working grid: extended grid for resolution boundary handling,
624    // or the data grid when no extension was needed.
625    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    // 4. Accumulate total attenuation: Σᵢ thicknessᵢ × σ_{D,i}(E)
644    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    // 5. Beer-Lambert: T = exp(−attenuation)
652    let transmission: Vec<f64> = total_attenuation.iter().map(|&att| (-att).exp()).collect();
653
654    // 6. Resolution broadening on total transmission, then extract at data positions.
655    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
668/// Compute Doppler-broadened cross-sections for each isotope.
669///
670/// Returns **Doppler-only** cross-sections.  Resolution broadening is NOT
671/// applied here because it must be applied after Beer-Lambert on the total
672/// transmission for physically correct results.
673///
674/// When `instrument` is `Some`, the auxiliary extended grid is still
675/// constructed for Doppler boundary accuracy, but the resolution
676/// convolution is not performed.
677///
678/// This is the expensive physics step; cache the result once before
679/// fitting many pixels on the same isotopes and energy grid.
680///
681/// # Arguments
682/// * `energies`        — Energy grid in eV (sorted ascending).
683/// * `resonance_data`  — Resonance parameters for each isotope.
684/// * `temperature_k`   — Sample temperature for Doppler broadening.
685/// * `instrument`      — Optional instrument resolution parameters.
686///   Used only for auxiliary grid construction (Doppler boundary accuracy).
687///   Resolution broadening is NOT applied.
688/// * `cancel`          — Optional cancellation token.  Cancellation is checked
689///   at the start of each isotope's parallel task; in-flight tasks run to
690///   completion (consistent with the rayon pattern in `spatial.rs`).
691///
692/// # Returns
693/// One cross-section vector per isotope on success.
694///
695/// # Errors
696/// * [`TransmissionError::Cancelled`] — if the `cancel` flag was observed
697///   during parallel execution (either before an isotope started or after
698///   all tasks completed).
699/// * [`TransmissionError::Resolution`] — if `instrument` is `Some` and
700///   `energies` is not sorted ascending.
701/// * [`TransmissionError::Doppler`] — if Doppler broadening is enabled
702///   (`temperature_k > 0.0`) and `DopplerParams` validation fails
703///   (e.g., non-positive or non-finite AWR).
704pub 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    // Delegate to the working-grid variant and extract the data points.
712    // Doppler runs on the working (aux) grid for boundary accuracy.
713    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
723/// Like [`broadened_cross_sections`] but returns the Doppler-broadened σ on the
724/// **working grid** (the data grid extended by the kernel's reach, else the
725/// data grid) together with the [`WorkingGridLayout`].
726///
727/// Returns σ on the working grid before Beer-Lambert and resolution.
728/// Extract the data points after those operations, as in [`forward_model`].
729pub 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    // Validate energy grid once before the per-isotope loop.
737    if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
738        return Err(ResolutionError::UnsortedEnergies.into());
739    }
740
741    // Build auxiliary grid with boundary extension + resonance fine-structure.
742    // SAMMY extends the energy grid beyond the data range and adds dense points
743    // around narrow resonances so the broadening convolution integrals have
744    // adequate quadrature points.
745    // SAMMY Ref: dat/mdat4.f90 Escale+Fspken+Add_Pnts, dat/mdata.f90 Vqcon
746    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    // Parallelize across isotopes — Doppler broadening for each isotope is
751    // independent. Resolution is NOT applied here (resolution
752    // must be applied after Beer-Lambert on total transmission).  σ is returned
753    // on the working grid WITHOUT extracting the data points.
754    // Cancellation is checked per-isotope inside the parallel map.
755    let result: Result<Vec<Vec<f64>>, TransmissionError> = resonance_data
756        .par_iter()
757        .map(|rd| {
758            // Check cancellation before starting this isotope.
759            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    // Final cancellation check: if cancel was set during parallel execution,
768    // some tasks may have completed before observing it.
769    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
779/// Compute Doppler+resolution-broadened cross-sections using SAMMY's
780/// Beer-Lambert-aware pipeline for transmission data.
781///
782/// For transmission data, SAMMY applies resolution broadening to the
783/// transmission T = exp(-nd×σ_D) rather than to σ_D directly.  Due to
784/// Jensen's inequality (the exponential is convex), direct σ broadening
785/// overestimates the effective cross section at resonance peaks.  This
786/// function implements SAMMY's correct pipeline:
787///
788/// 1. Evaluate unbroadened σ on extended grid
789/// 2. Doppler-broaden σ → σ_D
790/// 3. Convert to transmission: T = exp(-nd × σ_D)
791/// 4. Resolution-broaden T → T_broadened
792/// 5. Convert back: σ_eff = -ln(T_broadened) / nd
793///
794/// SAMMY Ref: DopplerAndResolutionBroadener.cpp — resolution broadening is
795/// applied after Beer-Lambert conversion in the SAMMY pipeline.
796///
797/// # Arguments
798/// * `energies`             — Energy grid in eV (sorted ascending).
799/// * `resonance_data`       — Resonance parameters for each isotope.
800/// * `temperature_k`        — Sample temperature for Doppler broadening.
801/// * `instrument`           — Instrument resolution parameters.
802/// * `thickness_atoms_barn`  — Sample thickness n×d (atoms/barn).  Must be > 0.
803/// * `cancel`               — Optional cancellation token.
804///
805/// # Returns
806/// One effective cross-section vector per isotope on success.
807pub 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    // The Beer-Lambert→σ_eff inversion (step 5: σ_eff = −ln(T)/nd) divides by
816    // the thickness, so a non-positive or non-finite nd would yield ±∞/NaN
817    // cross-sections.  The docstring contract requires nd > 0; enforce it
818    // up-front before any work, matching the module's `InputMismatch` style.
819    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                // Extended grid available: evaluate the full pipeline on the
841                // extended grid and extract at data positions.
842
843                // 1. Doppler-broadened cross sections on the extended grid.
844                let after_doppler = continuous_doppler::broaden(ext_energies, rd, temperature_k)?;
845
846                // 3. Convert to transmission: T = exp(-nd × σ_D).
847                let transmission: Vec<f64> = after_doppler
848                    .iter()
849                    .map(|&sigma| (-nd * sigma).exp())
850                    .collect();
851
852                // 4. Resolution-broaden T.
853                let t_broadened = resolution::apply_resolution_presorted(
854                    ext_energies,
855                    &transmission,
856                    &instrument.resolution,
857                );
858
859                // 5. Convert back to effective σ: σ_eff = -ln(T_broad) / nd.
860                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                // No extended grid (e.g. tabulated resolution with no aux grid):
869                // Doppler on data grid, Beer-Lambert, resolution on data grid.
870                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
903/// Compute unbroadened (raw Reich-Moore) cross-sections for each isotope.
904///
905/// This is the temperature-independent first step of the forward model.
906/// The result can be cached and reused across multiple temperature evaluations
907/// (e.g., during LM iterations where temperature is a free parameter).
908///
909/// # Returns
910/// One total cross-section vector per isotope: `result[k][e]` is the
911/// unbroadened total cross-section (barns) for isotope `k` at energy `e`.
912pub 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
937/// Compute Doppler-broadened cross-sections from precomputed unbroadened
938/// cross-sections.
939///
940/// Returns **Doppler-only** cross-sections.  Resolution broadening is NOT
941/// applied (must be applied after Beer-Lambert on total T).
942///
943/// Like [`broadened_cross_sections`] but skips the expensive Reich-Moore
944/// calculation (step 1). Use [`unbroadened_cross_sections`] to compute
945/// `base_xs` once, then call this function repeatedly with different
946/// temperatures.
947pub 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    // Delegate to the working-grid variant and extract the data points.
955    // Doppler runs on the working (aux) grid for boundary accuracy; resolution
956    // is NOT applied here. Extracting the data points here keeps
957    // this function's contract (data-grid σ) unchanged.
958    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
968/// Like [`broadened_cross_sections_from_base`] but returns the Doppler-broadened
969/// σ on the **working grid** (auxiliary extended grid when Gaussian resolution
970/// is active, else the data grid) together with the [`WorkingGridLayout`].
971///
972/// This is the building block the LM fit's cached / precomputed paths use to
973/// apply resolution broadening on the working grid and extract the data points
974/// last — matching [`forward_model`]. Resolution is NOT applied
975///.
976pub 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    // Build auxiliary grid with boundary extension + resonance fine-structure.
989    // base_xs is on the data grid; we extend it to the aux grid by evaluating
990    // cross-sections at the auxiliary-only points (cheap: only the few hundred
991    // extra points, not the full grid).
992    // SAMMY Ref: dat/mdat4.f90 Escale+Fspken+Add_Pnts
993    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    // Resolution is NOT applied. Doppler broadening on the
999    // working grid, returned WITHOUT extracting the data points.
1000    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                // The base table IS the resonance equation sampled on the
1006                // data grid, so integrating from `rd` answers the same
1007                // question without the sampling error. Broadening the table
1008                // instead cost a systematic -3.7 K on the synthetic
1009                // temperature fixture.
1010                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
1029/// Compute Doppler-broadened cross-sections and their **analytical**
1030/// temperature derivative from precomputed unbroadened cross-sections.
1031///
1032/// Returns **Doppler-only** cross-sections and derivatives.  Resolution
1033/// broadening is NOT applied (must be applied after
1034/// Beer-Lambert on total T).
1035///
1036/// Uses `doppler_broaden_with_derivative` for exact ∂σ/∂T in a single pass
1037/// (1× broadening), replacing the 3× FD approach.
1038///
1039/// Returns `BroadenedXsWithDerivative`: `(sigma_k, dsigma_k_dT)`.
1040pub 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    // Delegate to the working-grid variant and extract the data points for
1048    // both σ and ∂σ/∂T.  Keeps this function's data-grid contract unchanged.
1049    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
1065/// Like [`broadened_cross_sections_with_analytical_derivative_from_base`] but
1066/// returns σ and ∂σ/∂T on the **working grid** (auxiliary extended grid when
1067/// Gaussian resolution is active, else the data grid) together with the
1068/// [`WorkingGridLayout`].
1069///
1070/// The analytical Jacobian uses this so it can form `−T·Σᵢ nᵢ·rᵢ·∂σᵢ/∂T` on
1071/// the working grid, resolution-broaden it there, and extract the data points
1072/// last. Resolution is NOT applied.
1073pub 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    // Build auxiliary grid (same as broadened_cross_sections_from_base).
1086    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    // Per-isotope: Doppler broaden with analytical derivative on the WORKING
1092    // grid, returned WITHOUT extracting the data points.
1093    // Resolution is NOT applied.
1094    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                // Value and derivative converge on the SAME quadrature
1101                // panels, so the optimiser cannot step on a derivative that
1102                // describes a different curve from the one it lands on.
1103                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    // Separate into (sigma, dsigma_dt) on the working grid.
1122    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
1136/// Compute a transmission spectrum from precomputed unbroadened cross-sections.
1137///
1138/// Applies Doppler broadening and Beer-Lambert using cached base XS, then
1139/// resolution broadening on the total transmission. This skips
1140/// the expensive resonance calculation when temperature varies.
1141///
1142/// The full pipeline runs on the **auxiliary extended grid** (boundary
1143/// extension + resonance fine-structure points) so that Doppler AND resolution
1144/// broadening have adequate quadrature support and correct boundary handling;
1145/// the data-grid points are extracted LAST.  This mirrors the non-cached
1146/// [`forward_model`] and [`broadened_cross_sections_for_transmission`] exactly
1147/// resolution broadening, degrading the convolution near the grid edges and
1148/// around narrow resonances.
1149///
1150/// Pipeline:
1151///   1. Per isotope: build σ on the extended grid (cached data-grid values +
1152///      freshly-evaluated auxiliary points), Doppler-broaden on the extended grid
1153///   2. Accumulate total attenuation on the extended grid: Σᵢ nᵢ·σ_{D,i}
1154///   3. Beer-Lambert: T = exp(−attenuation) on the extended grid
1155///   4. Resolution: T_broad = R ⊗ T on the extended grid (when instrument present)
1156///   5. Extract the data-grid points
1157pub 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    // Validate the data grid once before the per-isotope loop so resolution can
1188    // use the presorted (unchecked) path, matching forward_model.
1189    if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
1190        return Err(ResolutionError::UnsortedEnergies.into());
1191    }
1192
1193    // Build the auxiliary extended grid (boundary + resonance fine-structure).
1194    // SAMMY Ref: dat/mdat4.f90 Escale+Fspken+Add_Pnts.
1195    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    // Working grid: the extended grid when available, else the data grid.
1206    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    // Step 1: per-isotope Doppler-broadened σ on the WORKING (extended) grid.
1213    // Resolution is NOT applied here — it goes on total T below.
1214    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    // Step 2-3: accumulate attenuation on the working grid, then Beer-Lambert.
1234    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    // Step 4-5: resolution broadening on total transmission (on the working
1246    // grid), then extract the data-grid points last.  When `instrument` is
1247    // `None`, `build_aux_grid` returns `None`, so the working grid IS the data
1248    // grid and `transmission` is returned directly (matching `forward_model`).
1249    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    /// The levels of an LRF=7 evaluation are resonance centres, as LRF=3 levels are.
1270    #[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    /// The spatial pipeline builds the working-grid σ
1289    /// (`broadened_cross_sections_on_working_grid`) and the shared layout
1290    /// (`resolution_working_grid`) via SEPARATE calls — they must produce
1291    /// bit-identical layouts, or per-pixel σ and the shared layout would
1292    /// disagree.  Both route through `build_aux_grid` with the same arguments;
1293    /// this pins that determinism against a future divergence.
1294    #[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        // σ = 100 barns, thickness = 0.01 atoms/barn
1345        // T = exp(-1.0) ≈ 0.3679
1346        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        // Very thick sample: T should be 0 (exp(-1000) underflows)
1359        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        // Two isotopes should combine additively in the exponent.
1367        // σ₁ = 100 barns, t₁ = 0.01 → att₁ = 1.0
1368        // σ₂ = 200 barns, t₂ = 0.005 → att₂ = 1.0
1369        // T = exp(-(1.0 + 1.0)) = exp(-2.0)
1370        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        // U-238 has a huge capture resonance at 6.674 eV.
1384        // A thin sample should show a transmission dip there.
1385        let data = u238_single_resonance();
1386        let thickness = 0.001; // atoms/barn (thin)
1387
1388        // Evaluate at a few energies
1389        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        // At 6.674 eV (on resonance), transmission should be much lower
1397        let t_on_res = trans[2];
1398        let t_off_res = trans[0]; // 1 eV, off resonance
1399
1400        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        // On-resonance with huge σ (~25000 barns), T ≈ exp(-25) ≈ 0
1408        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        // Forward model at T=0 with no resolution should give
1418        // the same result as direct Beer-Lambert on unbroadened σ.
1419        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        // Direct calculation
1425        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        // Forward model
1432        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        // Forward model with Doppler broadening should smooth out the
1449        // transmission dip, making it wider and shallower.
1450        let data = u238_single_resonance();
1451        let thickness = 0.0001; // Very thin (to avoid total absorption)
1452
1453        let energies: Vec<f64> = (0..401).map(|i| 5.0 + (i as f64) * 0.01).collect();
1454
1455        // Cold (no broadening)
1456        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        // Hot (300 K Doppler)
1460        let sample_hot = SampleParams::new(300.0, vec![(data, thickness)]).unwrap();
1461        let t_hot = forward_model(&energies, &sample_hot, None).unwrap();
1462
1463        // Find minima
1464        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        // Broadened dip should be shallower (higher minimum transmission)
1468        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        // Two isotopes with different resonances should create two dips.
1479        let u238 = u238_single_resonance();
1480
1481        // Create a fictitious second isotope with a resonance at 20 eV
1482        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        // Find the transmission near 6.674 eV (U-238 resonance)
1522        let idx_u238 = energies
1523            .iter()
1524            .position(|&e| (e - 6.7).abs() < 0.05)
1525            .unwrap();
1526        // Find the transmission near 20 eV (other resonance)
1527        let idx_other = energies
1528            .iter()
1529            .position(|&e| (e - 20.0).abs() < 0.05)
1530            .unwrap();
1531        // Off-resonance
1532        let idx_off = energies
1533            .iter()
1534            .position(|&e| (e - 15.0).abs() < 0.05)
1535            .unwrap();
1536
1537        // Both dips should be visible
1538        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        // Verify analytical ∂σ/∂T against a manual FD at a larger step (1 K)
1555        // and check they agree to reasonable tolerance.
1556        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        // Basic shape checks
1572        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        // The derivative should be non-zero near the resonance at 6.674 eV
1578        // where Doppler broadening has a strong effect.
1579        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        // Cross-check: compute a manual FD at a larger step and verify
1590        // the analytical derivative is consistent (within ~5% relative error).
1591        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        // Compare near the resonance where the derivative is large.
1610        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        // Regression test: derivative must have correct sign at low temperature.
1626        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        // T = 0.05 K
1633        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        // Derivative should be finite and mostly positive (Doppler broadening
1643        // increases with temperature for narrow resonances).
1644        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        // T = 0.0 K: edge case
1651        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    // --- SampleParams validation tests ---
1668
1669    #[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    // --- Base XS caching tests ---
1707
1708    #[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        // Reference: full forward model
1716        let sample = SampleParams::new(temperature, vec![(data.clone(), thickness)]).unwrap();
1717        let t_ref = forward_model(&energies, &sample, None).unwrap();
1718
1719        // Cached path: unbroadened XS → forward_model_from_base_xs
1720        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        // Verify the analytical derivative from base returns correct shapes
1783        // and finite values.
1784        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        // All values should be finite.
1805        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        // XS from base should match XS from full broadening.
1813        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    /// Regression test for resolution broadening must be applied
1832    /// to the total transmission T(E) AFTER Beer-Lambert, not to σ(E) before.
1833    ///
1834    /// This test constructs the expected result from first principles:
1835    ///
1836    ///   1. Doppler-broaden σ
1837    ///   2. Beer-Lambert: T = exp(−n·σ_D)
1838    ///   3. Resolution-broaden T
1839    ///
1840    /// and asserts that `forward_model()` matches.
1841    #[test]
1842    fn test_forward_model_resolution_after_beer_lambert() {
1843        let data = u238_single_resonance();
1844        let thickness = 0.0005; // atoms/barn
1845        let temperature = 300.0;
1846
1847        // Energy grid around the 6.674 eV resonance.
1848        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        // --- Build expected from first principles ---
1857
1858        // Step 1: Doppler-broadened σ on the data grid, by the same method
1859        // the model uses. This test is about the ORDER of Beer-Lambert and
1860        // resolution; building the reference with a different broadening
1861        // method would fold that difference into the discriminant below.
1862        let sigma_d = continuous_doppler::broaden(&energies, &data, temperature).unwrap();
1863
1864        // Step 2: Beer-Lambert on total transmission.
1865        let transmission: Vec<f64> = sigma_d.iter().map(|&s| (-thickness * s).exp()).collect();
1866
1867        // Step 3: Resolution-broaden the transmission.
1868        let t_expected =
1869            resolution::apply_resolution(&energies, &transmission, &inst.resolution).unwrap();
1870
1871        // --- Wrong ordering for comparison: Resolution(σ) then Beer-Lambert ---
1872        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        // --- forward_model() output ---
1880        let sample = SampleParams::new(temperature, vec![(data, thickness)]).unwrap();
1881        let t_forward = forward_model(&energies, &sample, Some(&inst)).unwrap();
1882
1883        // forward_model should match the correct ordering (resolution after Beer-Lambert).
1884        // The extended grid in forward_model adds boundary points, so the match
1885        // is approximate — but should be very close on the interior grid.
1886        let interior = 20..energies.len() - 20; // skip boundary region
1887        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        // The key discriminant: forward_model must be much closer to the
1897        // correct ordering (resolution after Beer-Lambert) than to the wrong
1898        // ordering (resolution before Beer-Lambert).
1899        //
1900        // Small absolute differences (~1%) between forward_model and the
1901        // data-grid reference are expected because forward_model uses an
1902        // extended grid for boundary handling.
1903        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        // The error against the correct ordering should be at least 5× smaller
1910        // than the error against the wrong ordering.
1911        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        // Verify the two orderings actually differ — if they don't, the test
1920        // is not exercising the bug.
1921        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    /// containment: `broadened_cross_sections` must return
1932    /// Doppler-only σ even when `instrument` is `Some`.  Resolution
1933    /// broadening must NOT be applied inside this function.
1934    #[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        // With instrument (used for aux grid, but NOT for resolution broadening).
1947        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        // Without instrument (pure Doppler on data grid).
1957        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        // Both should return Doppler-only σ.  The with-instrument path uses
1967        // the extended grid for Doppler which may produce slightly different
1968        // values, but they must be close (no resolution smoothing).
1969        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        // Compute what resolution-broadened σ would look like.
1974        let sigma_resolved =
1975            resolution::apply_resolution(&energies, &xs_no_inst[0], &inst.resolution).unwrap();
1976
1977        // The with-instrument result must NOT match the resolution-broadened version.
1978        // Near the resonance dip, resolution broadening smooths the peak — the
1979        // Doppler-only result should have a deeper dip than the resolved one.
1980        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        // The Doppler-only values from both paths should be closer to each
1988        // other than to the resolution-broadened value.
1989        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    /// containment: `broadened_cross_sections_from_base` must
1999    /// return Doppler-only σ even when `instrument` is `Some`.
2000    #[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        // With instrument.
2016        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        // Without instrument.
2026        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        // Both should produce Doppler-only σ.  With-instrument may use
2036        // extended grid, but the extracted data-grid values should be close.
2037        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    // ── forward_model_from_base_xs resolution ordering ──
2056
2057    /// `forward_model_from_base_xs` with resolution must
2058    /// match `forward_model()` with resolution for the same sample.
2059    #[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        // Reference: forward_model() (fixed in Step 1).
2073        let sample = SampleParams::new(temperature, vec![(data.clone(), thickness)]).unwrap();
2074        let t_ref = forward_model(&energies, &sample, Some(&inst)).unwrap();
2075
2076        // Base-XS path: unbroadened → forward_model_from_base_xs with resolution.
2077        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        // Both now run the IDENTICAL pipeline on the auxiliary extended grid
2090        // (Doppler → Beer-Lambert → resolution, data points extracted last), so
2091        // they must agree to floating-point round-off.  The cached path reuses
2092        // the data-grid base XS, which is bit-identical to forward_model's fresh
2093        // evaluation at those points; the auxiliary-only points are evaluated the
2094        // same way in both.  (Before Fix #7 the cached path applied resolution on
2095        // the coarse data grid and the two differed by ~1%.)
2096        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        // Verify resolution actually made a difference (not a vacuous test).
2108        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    /// `forward_model_from_base_xs` without resolution
2127    /// must remain unchanged (matches existing no-resolution test).
2128    #[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    // ── derivative helper containment ──────────────────────
2160
2161    /// `broadened_cross_sections_with_analytical_derivative_from_base`
2162    /// must return Doppler-only σ and Doppler-only ∂σ/∂T even when instrument
2163    /// is present.  Resolution broadening must NOT be applied inside.
2164    #[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        // With instrument.
2180        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        // Without instrument.
2190        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        // Both should return Doppler-only.  Compute what resolution-broadened
2203        // σ would look like, and verify the with-instrument result is closer
2204        // to the no-instrument result than to the resolved version.
2205        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        // σ check: with-instrument should be close to no-instrument (Doppler-only),
2214        // not to the resolution-broadened version.
2215        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        // ∂σ/∂T check: same pattern — should be Doppler-only derivative.
2224        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    /// Derivative helper without resolution must be
2236    /// unchanged — same σ and ∂σ/∂T as before.
2237    #[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        // σ should match broadened_cross_sections (no instrument).
2256        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        // ∂σ/∂T should be finite and non-trivial near resonance.
2273        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    // ── Fix #8: broadened_cross_sections_for_transmission thickness guard ──
2289
2290    /// `broadened_cross_sections_for_transmission` divides by the thickness in
2291    /// the σ_eff = −ln(T)/nd inversion, so a non-positive or non-finite
2292    /// thickness must be rejected up-front (the docstring requires nd > 0).
2293    #[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        // A valid positive thickness still succeeds.
2320        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    /// the working-grid `*_from_base` path's
2332    /// input validation — `validate_base_xs` shape errors and the unsorted-grid
2333    /// guard.  These error branches were untested (the temperature fit always
2334    /// supplies well-formed, sorted inputs).
2335    #[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        // base_xs isotope-count mismatch (2 rows vs 1 isotope).
2342        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        // base_xs row-length mismatch.
2352        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        // Unsorted energies + an instrument ⇒ the sorted-grid guard fires.
2362        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}