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            // Previously checked at a single midpoint, which could make the
62            // wrong decision if the ratio crosses 2.5 within the energy range.
63            // Now checks at 5 points (lo, 25%, mid, 75%, hi) and uses
64            // intermediates if a MAJORITY of points have C > 2.5.
65            let use_intermediates = if energies.len() >= 2 {
66                let n = energies.len();
67                let check_indices = [0, n / 4, n / 2, 3 * n / 4, n - 1];
68                let n_pw_linear = check_indices
69                    .iter()
70                    .filter(|&&i| {
71                        let e = energies[i];
72                        let wg = params.gaussian_width(e);
73                        let we = params.exp_width(e);
74                        we < 1e-60 || wg / (2.0 * we) > 2.5
75                    })
76                    .count();
77                // Majority rule: use intermediates if ≥3 of 5 points qualify.
78                n_pw_linear >= 3
79            } else {
80                true
81            };
82
83            // Extract (energy_eV, gd_eV) pairs for fine-structure densification.
84            // gd = total resonance width, used by Fspken to identify regions
85            // needing denser grid points around narrow resonances.
86            // SAMMY Ref: dat/mdat4.f90 Fspken lines 243-284
87            let resonances = extract_resonance_widths(resonance_data);
88
89            if use_intermediates {
90                crate::auxiliary_grid::build_extended_grid(energies, Some(params), &resonances)
91            } else {
92                crate::auxiliary_grid::build_extended_grid_boundary_only(energies, Some(params))
93            }
94        } else {
95            // Tabulated and Ikeda-Carpenter kernels get the boundary
96            // extension without the Gaussian path's intermediates.
97            crate::auxiliary_grid::build_extended_grid_for(energies, &inst.resolution)
98        };
99        (ext_e.len() > energies.len()).then_some((ext_e, di))
100    })
101}
102
103/// Extract (energy_eV, gd_eV) pairs from resonance data for fine-structure
104/// grid densification.
105///
106/// For LRF=1/2/3 (BW and Reich-Moore): `gd = |Γn| + |Γγ| + |Γf1| + |Γf2|`,
107/// walked from each resolved range's `l_groups`. Non-evaluable ranges
108/// (LRF=7, LRU=2) carry empty `l_groups` and contribute no pairs.
109///
110/// SAMMY Ref: dat/mdat4.f90 Fspken — uses total width to define the region
111/// [E_res − gd, E_res + gd] for fine-structure point insertion.
112fn extract_resonance_widths(resonance_data: &[&ResonanceData]) -> Vec<(f64, f64)> {
113    let mut pairs = Vec::new();
114    for rd in resonance_data {
115        for range in &rd.ranges {
116            if !range.resolved {
117                continue;
118            }
119            // LRF=1/2/3: resonances grouped by L. LRF=7 (and LRU=2) ranges are
120            // parsed-and-skipped with empty l_groups and are not evaluated, so
121            // they contribute no resonance dips here.
122            for lg in &range.l_groups {
123                for res in &lg.resonances {
124                    let gd = res.gn.abs() + res.gg.abs() + res.gfa.abs() + res.gfb.abs();
125                    if gd > 0.0 {
126                        pairs.push((res.energy, gd));
127                    }
128                }
129            }
130        }
131    }
132    pairs
133}
134
135/// Distinct resonance center energies (eV) across every isotope and formalism,
136/// sorted ascending.
137///
138/// Used by the energy-scale calibration peak-matching seed (issue #608): the
139/// (t0, L_scale) calibration is recovered by matching measured transmission-dip
140/// positions to these known resonance energies via the linear TOF relation
141/// `tof_measured = t0 + L_scale · tof_nominal`, which seeds the LM into the
142/// global-minimum basin of the (sharply non-convex, post-#608) calibration χ²
143/// surface — a basin too thin for a cold start or grid scan to find reliably.
144///
145/// **Deduplicated**: two resonances at the same center energy
146/// (e.g. across isotopes in a grouped fit) are a single POSITION for dip
147/// matching — multiplicity is irrelevant — and a duplicate would drive the
148/// minimum inter-resonance spacing, and hence the seed's `match_tol`, to 0,
149/// rejecting every dip and silently dropping the seed to a cold start.
150pub fn resonance_center_energies(resonance_data: &[&ResonanceData]) -> Vec<f64> {
151    let mut e: Vec<f64> = extract_resonance_widths(resonance_data)
152        .into_iter()
153        .map(|(energy, _gd)| energy)
154        .collect();
155    e.sort_by(f64::total_cmp);
156    // Sorted ⇒ exact duplicates are adjacent; `dedup` removes them.
157    e.dedup();
158    e
159}
160
161/// Build the unbroadened cross-section vector on the extended/auxiliary grid
162/// from cached data-grid values.
163///
164/// Data-grid positions are copied verbatim from `xs_raw` (the cached
165/// unbroadened σ for this isotope); the auxiliary-only positions (boundary
166/// extension + fine-structure points) are evaluated fresh.  `is_data_point`
167/// marks which extended-grid indices are data-grid points.
168///
169/// This is the cheap reuse of cached XS that the base-XS family relies on: only
170/// the few hundred auxiliary points are recomputed, not the full grid.
171fn build_extended_xs_from_base(
172    ext_energies: &[f64],
173    data_indices: &[usize],
174    is_data_point: &[bool],
175    xs_raw: &[f64],
176    rd: &ResonanceData,
177) -> Vec<f64> {
178    let mut xs_ext = vec![0.0f64; ext_energies.len()];
179    for (data_i, &ext_i) in data_indices.iter().enumerate() {
180        xs_ext[ext_i] = xs_raw[data_i];
181    }
182    for (j, &e) in ext_energies.iter().enumerate() {
183        if !is_data_point[j] {
184            xs_ext[j] = reich_moore::cross_sections_at_energy(rd, e).total;
185        }
186    }
187    xs_ext
188}
189
190/// Validate `base_xs` shape against `resonance_data` and `energies`.
191///
192/// Shared by the base-XS broadening family so the same `InputMismatch`
193/// diagnostics are produced from one place.
194fn validate_base_xs(
195    energies: &[f64],
196    base_xs: &[Vec<f64>],
197    resonance_data: &[ResonanceData],
198) -> Result<(), TransmissionError> {
199    if base_xs.len() != resonance_data.len() {
200        return Err(TransmissionError::InputMismatch(format!(
201            "base_xs has {} isotopes but resonance_data has {}",
202            base_xs.len(),
203            resonance_data.len(),
204        )));
205    }
206    for (i, row) in base_xs.iter().enumerate() {
207        if row.len() != energies.len() {
208            return Err(TransmissionError::InputMismatch(format!(
209                "base_xs[{i}] has {} energies but expected {}",
210                row.len(),
211                energies.len(),
212            )));
213        }
214    }
215    Ok(())
216}
217
218/// Build a bool mask identifying data-grid positions within the extended grid.
219///
220/// `None` when no auxiliary grid was built (the working grid is the data grid).
221fn data_point_mask(ext_grid: Option<&(Vec<f64>, Vec<usize>)>) -> Option<Vec<bool>> {
222    ext_grid.map(|(ext_e, di)| {
223        let mut mask = vec![false; ext_e.len()];
224        for &idx in di {
225            mask[idx] = true;
226        }
227        mask
228    })
229}
230
231/// Resolve the working grid (extended grid when available, else the data grid)
232/// and the [`WorkingGridLayout`] mapping data points back into it.
233fn working_grid_layout<'a>(
234    energies: &'a [f64],
235    ext_grid: Option<&'a (Vec<f64>, Vec<usize>)>,
236) -> (&'a [f64], WorkingGridLayout) {
237    match ext_grid {
238        Some((ext_e, di)) => (
239            ext_e.as_slice(),
240            WorkingGridLayout {
241                energies: ext_e.clone(),
242                data_indices: di.clone(),
243            },
244        ),
245        None => (energies, WorkingGridLayout::identity(energies)),
246    }
247}
248
249/// Compute the working-grid layout for `(energies, instrument, resonance_data)`.
250///
251/// With a resolution function the grid is extended past both ends by the
252/// kernel's reach ([`ResolutionFunction::grid_bounds_ev`]), and a Gaussian
253/// also gets the intermediate points and resonance fine structure.  Without
254/// one the data grid comes back with identity indices.
255///
256/// `resonance_data` may be empty (e.g. the energy-scale model has no resonance
257/// data of its own); the auxiliary grid then carries boundary extension only,
258/// with no resonance fine-structure densification.
259///
260/// # Errors
261/// * [`TransmissionError::Resolution`] — if `instrument` is `Some` and
262///   `energies` is not sorted ascending.
263pub fn resolution_working_grid(
264    energies: &[f64],
265    instrument: Option<&InstrumentParams>,
266    resonance_data: &[&ResonanceData],
267) -> Result<WorkingGridLayout, TransmissionError> {
268    if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
269        return Err(ResolutionError::UnsortedEnergies.into());
270    }
271    let ext_grid = build_aux_grid(energies, instrument, resonance_data);
272    let (_, layout) = working_grid_layout(energies, ext_grid.as_ref());
273    Ok(layout)
274}
275
276/// Errors from the transmission forward model.
277#[derive(Debug)]
278pub enum TransmissionError {
279    /// The energy grid is not sorted or has a length mismatch with data.
280    Resolution(ResolutionError),
281    /// Doppler broadening parameter validation failed.
282    Doppler(DopplerParamsError),
283    /// Doppler broadening input validation failed (e.g. length mismatch).
284    DopplerBroadening(crate::doppler::DopplerError),
285    /// Computation was cancelled via the cancel token.
286    Cancelled,
287    /// Input array mismatch (e.g. cross-sections vs thicknesses length).
288    InputMismatch(String),
289}
290
291impl fmt::Display for TransmissionError {
292    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293        match self {
294            Self::Resolution(e) => write!(f, "resolution broadening error: {}", e),
295            Self::Doppler(e) => write!(f, "Doppler parameter error: {}", e),
296            Self::DopplerBroadening(e) => write!(f, "Doppler broadening error: {}", e),
297            Self::Cancelled => write!(f, "computation cancelled"),
298            Self::InputMismatch(msg) => write!(f, "input mismatch: {}", msg),
299        }
300    }
301}
302
303impl std::error::Error for TransmissionError {
304    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
305        match self {
306            Self::Resolution(e) => Some(e),
307            Self::Doppler(e) => Some(e),
308            Self::DopplerBroadening(e) => Some(e),
309            Self::Cancelled => None,
310            Self::InputMismatch(_) => None,
311        }
312    }
313}
314
315impl From<ResolutionError> for TransmissionError {
316    fn from(e: ResolutionError) -> Self {
317        Self::Resolution(e)
318    }
319}
320
321impl From<DopplerParamsError> for TransmissionError {
322    fn from(e: DopplerParamsError) -> Self {
323        Self::Doppler(e)
324    }
325}
326
327impl From<crate::doppler::DopplerError> for TransmissionError {
328    fn from(e: crate::doppler::DopplerError) -> Self {
329        Self::DopplerBroadening(e)
330    }
331}
332
333/// Broadened cross-sections and their temperature derivative.
334///
335/// `xs[k][e]` is the Doppler+resolution-broadened cross-section for isotope
336/// `k` at energy index `e`; `dxs_dt[k][e]` is the analytical derivative
337/// with respect to temperature.
338pub type BroadenedXsWithDerivative = (Vec<Vec<f64>>, Vec<Vec<f64>>);
339
340/// The working energy grid used for broadening, plus the map back to the
341/// data grid.
342///
343/// Doppler, Beer-Lambert and resolution run on the working grid, the data
344/// grid extended by the kernel's reach plus a Gaussian's intermediate and
345/// fine-structure points, and the data points are extracted last.  A
346/// [`crate::resolution::ResolutionPlan`] must be built on [`Self::energies`].
347#[derive(Debug, Clone)]
348pub struct WorkingGridLayout {
349    /// Working-grid energies (eV, ascending).  Equals the input data grid
350    /// when no auxiliary grid was built.
351    pub energies: Vec<f64>,
352    /// `data_indices[i]` is the index of data energy `i` within
353    /// [`Self::energies`].  Identity (`0..n`) when no auxiliary grid.
354    pub data_indices: Vec<usize>,
355}
356
357impl WorkingGridLayout {
358    pub fn identity(energies: &[f64]) -> Self {
359        Self {
360            energies: energies.to_vec(),
361            data_indices: (0..energies.len()).collect(),
362        }
363    }
364
365    /// `true` when the working grid is the data grid itself (no auxiliary
366    /// extension was built).  In that case [`Self::extract`] is a no-op clone.
367    pub fn is_identity(&self) -> bool {
368        self.data_indices.len() == self.energies.len()
369            && self
370                .data_indices
371                .iter()
372                .enumerate()
373                .all(|(i, &idx)| i == idx)
374    }
375
376    /// Extract the data-grid points from a working-grid spectrum.
377    pub fn extract(&self, working: &[f64]) -> Vec<f64> {
378        self.data_indices.iter().map(|&i| working[i]).collect()
379    }
380
381    pub fn extract_owned(&self, working: Vec<f64>) -> Vec<f64> {
382        if self.is_identity() {
383            working
384        } else {
385            self.extract(&working)
386        }
387    }
388}
389
390/// Working-grid Doppler-broadened cross-sections plus the data-grid map.
391///
392/// Resolution broadening is **not** applied (issue #442: resolution is applied
393/// to the total transmission after Beer-Lambert).  The caller applies
394/// Beer-Lambert + resolution on [`WorkingGridLayout::energies`] and extracts
395/// the data points last via [`WorkingGridLayout::extract`].
396pub struct WorkingGridXs {
397    /// Doppler-broadened σ per isotope, on the working grid.
398    pub sigma: Vec<Vec<f64>>,
399    /// Working-grid layout (energies + data-grid index map).
400    pub layout: WorkingGridLayout,
401}
402
403/// Working-grid Doppler-broadened cross-sections and their analytical
404/// temperature derivative, plus the data-grid map.
405///
406/// Like [`WorkingGridXs`] but additionally carries `∂σ/∂T` on the working
407/// grid so the analytical Jacobian can form `−T·Σᵢ nᵢ·rᵢ·∂σᵢ/∂T` on the
408/// working grid, resolution-broaden it there, and extract the data points
409/// last (issue #608).
410pub struct WorkingGridXsWithDerivative {
411    /// Doppler-broadened σ per isotope, on the working grid.
412    pub sigma: Vec<Vec<f64>>,
413    /// `∂σ/∂T` per isotope, on the working grid.
414    pub dsigma_dt: Vec<Vec<f64>>,
415    /// Working-grid layout (energies + data-grid index map).
416    pub layout: WorkingGridLayout,
417}
418
419/// Compute transmission from cross-sections via Beer-Lambert law.
420///
421/// T(E) = exp(-thickness × σ(E))
422///
423/// # Arguments
424/// * `cross_sections` — Total cross-sections in barns at each energy point.
425/// * `thickness` — Areal density in atoms/barn (= number_density × path_length).
426///
427/// # Returns
428/// Transmission values (0 to 1) at each energy point.
429pub fn beer_lambert(cross_sections: &[f64], thickness: f64) -> Vec<f64> {
430    cross_sections
431        .iter()
432        .map(|&sigma| (-thickness * sigma).exp())
433        .collect()
434}
435
436/// Compute transmission for multiple isotopes.
437///
438/// T(E) = exp(-Σᵢ thicknessᵢ × σᵢ(E))
439///
440/// # Arguments
441/// * `cross_sections_per_isotope` — Vec of cross-section arrays, one per isotope.
442///   Each inner slice has the same length as the energy grid.
443/// * `thicknesses` — Areal density (atoms/barn) for each isotope.
444///
445/// # Returns
446/// Combined transmission values at each energy point.
447pub fn beer_lambert_multi(
448    cross_sections_per_isotope: &[&[f64]],
449    thicknesses: &[f64],
450) -> Result<Vec<f64>, TransmissionError> {
451    if cross_sections_per_isotope.len() != thicknesses.len() {
452        return Err(TransmissionError::InputMismatch(format!(
453            "cross_sections_per_isotope length ({}) must match thicknesses length ({})",
454            cross_sections_per_isotope.len(),
455            thicknesses.len()
456        )));
457    }
458    if cross_sections_per_isotope.is_empty() {
459        return Err(TransmissionError::InputMismatch(
460            "cross_sections_per_isotope must not be empty".into(),
461        ));
462    }
463
464    let n_energies = cross_sections_per_isotope[0].len();
465    for (k, sigma) in cross_sections_per_isotope.iter().enumerate() {
466        if sigma.len() != n_energies {
467            return Err(TransmissionError::InputMismatch(format!(
468                "cross_sections_per_isotope[{}] length ({}) must match [0] length ({})",
469                k,
470                sigma.len(),
471                n_energies
472            )));
473        }
474    }
475
476    Ok((0..n_energies)
477        .map(|i| {
478            let total_attenuation: f64 = cross_sections_per_isotope
479                .iter()
480                .zip(thicknesses.iter())
481                .map(|(sigma, &thick)| thick * sigma[i])
482                .sum();
483            (-total_attenuation).exp()
484        })
485        .collect())
486}
487
488/// Errors from `SampleParams` construction.
489#[derive(Debug, PartialEq)]
490pub enum SampleParamsError {
491    /// Temperature must be finite.
492    NonFiniteTemperature(f64),
493    /// Temperature must be non-negative.
494    NegativeTemperature(f64),
495}
496
497impl fmt::Display for SampleParamsError {
498    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
499        match self {
500            Self::NonFiniteTemperature(v) => {
501                write!(f, "temperature must be finite, got {v}")
502            }
503            Self::NegativeTemperature(v) => {
504                write!(f, "temperature must be non-negative, got {v}")
505            }
506        }
507    }
508}
509
510impl std::error::Error for SampleParamsError {}
511
512/// Sample description for the forward model.
513#[derive(Debug, Clone)]
514pub struct SampleParams {
515    /// Temperature in Kelvin (for Doppler broadening).
516    temperature_k: f64,
517    /// Isotope compositions: (resonance data, areal density in atoms/barn).
518    isotopes: Vec<(ResonanceData, f64)>,
519}
520
521impl SampleParams {
522    /// Create validated sample parameters.
523    ///
524    /// # Errors
525    /// Returns `SampleParamsError::NonFiniteTemperature` if `temperature_k` is
526    /// NaN or infinity.
527    /// Returns `SampleParamsError::NegativeTemperature` if `temperature_k < 0.0`.
528    pub fn new(
529        temperature_k: f64,
530        isotopes: Vec<(ResonanceData, f64)>,
531    ) -> Result<Self, SampleParamsError> {
532        if !temperature_k.is_finite() {
533            return Err(SampleParamsError::NonFiniteTemperature(temperature_k));
534        }
535        if temperature_k < 0.0 {
536            return Err(SampleParamsError::NegativeTemperature(temperature_k));
537        }
538        Ok(Self {
539            temperature_k,
540            isotopes,
541        })
542    }
543
544    /// Returns the sample temperature in Kelvin.
545    #[must_use]
546    pub fn temperature_k(&self) -> f64 {
547        self.temperature_k
548    }
549
550    /// Returns the isotope compositions: (resonance data, areal density).
551    #[must_use]
552    pub fn isotopes(&self) -> &[(ResonanceData, f64)] {
553        &self.isotopes
554    }
555}
556
557/// Optional instrument resolution parameters.
558#[derive(Debug, Clone)]
559pub struct InstrumentParams {
560    /// Resolution broadening function (Gaussian or tabulated).
561    pub resolution: ResolutionFunction,
562}
563
564/// Compute a complete theoretical transmission spectrum.
565///
566/// This is the main forward model that chains:
567///   ENDF parameters → cross-sections → Doppler broadening → resolution → transmission
568///
569/// # Arguments
570/// * `energies` — Energy grid in eV (sorted ascending).
571/// * `sample` — Sample parameters (isotopes with areal densities, temperature).
572/// * `instrument` — Optional instrument parameters (resolution broadening).
573///
574/// # Returns
575/// Theoretical transmission spectrum on the energy grid.
576///
577/// # Errors
578/// * [`TransmissionError::Resolution`] — if resolution broadening is
579///   enabled (`instrument` is `Some`) and `energies` is not sorted ascending.
580/// * [`TransmissionError::Doppler`] — if Doppler broadening is enabled
581///   (`temperature_k > 0.0`) and `DopplerParams` validation fails
582///   (e.g., non-positive or non-finite AWR).
583///
584/// **Note**: isotopes with thickness <= 0.0 are silently skipped
585/// (they contribute zero attenuation). This allows callers to include
586/// inactive isotopes in `SampleParams` without causing errors.
587pub fn forward_model(
588    energies: &[f64],
589    sample: &SampleParams,
590    instrument: Option<&InstrumentParams>,
591) -> Result<Vec<f64>, TransmissionError> {
592    let n = energies.len();
593    if n == 0 {
594        return Ok(vec![]);
595    }
596
597    // Validate energy grid once before the per-isotope loop so that
598    // resolution broadening can use the presorted (unchecked) path,
599    // avoiding redundant O(N) sort checks per isotope.
600    if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
601        return Err(ResolutionError::UnsortedEnergies.into());
602    }
603
604    // Build auxiliary grid with boundary extension + resonance fine-structure.
605    // Collect references to avoid cloning full ResonanceData structs.
606    // SAMMY Ref: dat/mdat4.f90 Escale, Fspken, Add_Pnts
607    let active_rd: Vec<&ResonanceData> = sample
608        .isotopes()
609        .iter()
610        .filter(|(_, t)| *t > 0.0)
611        .map(|(rd, _)| rd)
612        .collect();
613    let ext_grid = build_aux_grid(energies, instrument, &active_rd);
614
615    // Compute Doppler-broadened cross-sections for all isotopes in parallel.
616    // Resolution is NOT applied here — it must be applied after Beer-Lambert
617    // on the total transmission.
618    //
619    // SAMMY Ref: DopplerAndResolutionBroadener.cpp — resolution broadening is
620    // applied to T(E), not to σ(E).  Due to Jensen's inequality (exp is
621    // convex), broadening σ before the nonlinear Beer-Lambert systematically
622    // overestimates effective cross-sections at resonance peaks.
623    //
624    // Correct pipeline:
625    //   1. Per-isotope: σ → Doppler → σ_D   (on working grid)
626    //   2. Accumulate:  attenuation = Σᵢ nᵢ·σ_{D,i}
627    //   3. Beer-Lambert: T = exp(−attenuation)
628    //   4. Resolution:  T_broad = R ⊗ T     (on working grid)
629    //   5. Extract at data positions
630    //
631    // The working grid is the extended grid (with boundary+fine-structure
632    // points) when available, otherwise the data grid.
633
634    // Determine working grid: extended grid for resolution boundary handling,
635    // or the data grid when no extension was needed.
636    let (work_energies, work_len): (&[f64], usize) = if let Some((ref ext_e, _)) = ext_grid {
637        (ext_e.as_slice(), ext_e.len())
638    } else {
639        (energies, n)
640    };
641
642    let doppler_xs: Result<Vec<(Vec<f64>, f64)>, TransmissionError> = sample
643        .isotopes()
644        .par_iter()
645        .filter(|(_, thickness)| *thickness > 0.0)
646        .map(|(res_data, thickness)| {
647            let after_doppler =
648                continuous_doppler::broaden(work_energies, res_data, sample.temperature_k())?;
649            Ok((after_doppler, *thickness))
650        })
651        .collect();
652    let doppler_xs = doppler_xs?;
653
654    // 4. Accumulate total attenuation: Σᵢ thicknessᵢ × σ_{D,i}(E)
655    let mut total_attenuation = vec![0.0f64; work_len];
656    for (xs, thickness) in &doppler_xs {
657        for i in 0..work_len {
658            total_attenuation[i] += thickness * xs[i];
659        }
660    }
661
662    // 5. Beer-Lambert: T = exp(−attenuation)
663    let transmission: Vec<f64> = total_attenuation.iter().map(|&att| (-att).exp()).collect();
664
665    // 6. Resolution broadening on total transmission, then extract at data positions.
666    if let Some(inst) = instrument {
667        let t_broadened =
668            resolution::apply_resolution_presorted(work_energies, &transmission, &inst.resolution);
669        if let Some((_, ref data_indices)) = ext_grid {
670            Ok(data_indices.iter().map(|&i| t_broadened[i]).collect())
671        } else {
672            Ok(t_broadened)
673        }
674    } else {
675        Ok(transmission)
676    }
677}
678
679/// Compute Doppler-broadened cross-sections for each isotope.
680///
681/// Returns **Doppler-only** cross-sections.  Resolution broadening is NOT
682/// applied here because it must be applied after Beer-Lambert on the total
683/// transmission for physically correct results (issue #442).
684///
685/// When `instrument` is `Some`, the auxiliary extended grid is still
686/// constructed for Doppler boundary accuracy, but the resolution
687/// convolution is not performed.
688///
689/// This is the expensive physics step; cache the result once before
690/// fitting many pixels on the same isotopes and energy grid.
691///
692/// # Arguments
693/// * `energies`        — Energy grid in eV (sorted ascending).
694/// * `resonance_data`  — Resonance parameters for each isotope.
695/// * `temperature_k`   — Sample temperature for Doppler broadening.
696/// * `instrument`      — Optional instrument resolution parameters.
697///   Used only for auxiliary grid construction (Doppler boundary accuracy).
698///   Resolution broadening is NOT applied.
699/// * `cancel`          — Optional cancellation token.  Cancellation is checked
700///   at the start of each isotope's parallel task; in-flight tasks run to
701///   completion (consistent with the rayon pattern in `spatial.rs`).
702///
703/// # Returns
704/// One cross-section vector per isotope on success.
705///
706/// # Errors
707/// * [`TransmissionError::Cancelled`] — if the `cancel` flag was observed
708///   during parallel execution (either before an isotope started or after
709///   all tasks completed).
710/// * [`TransmissionError::Resolution`] — if `instrument` is `Some` and
711///   `energies` is not sorted ascending.
712/// * [`TransmissionError::Doppler`] — if Doppler broadening is enabled
713///   (`temperature_k > 0.0`) and `DopplerParams` validation fails
714///   (e.g., non-positive or non-finite AWR).
715pub fn broadened_cross_sections(
716    energies: &[f64],
717    resonance_data: &[ResonanceData],
718    temperature_k: f64,
719    instrument: Option<&InstrumentParams>,
720    cancel: Option<&AtomicBool>,
721) -> Result<Vec<Vec<f64>>, TransmissionError> {
722    // Delegate to the working-grid variant and extract the data points.
723    // Doppler runs on the working (aux) grid for boundary accuracy.
724    let WorkingGridXs { sigma, layout } = broadened_cross_sections_on_working_grid(
725        energies,
726        resonance_data,
727        temperature_k,
728        instrument,
729        cancel,
730    )?;
731    Ok(sigma.iter().map(|s| layout.extract(s)).collect())
732}
733
734/// Like [`broadened_cross_sections`] but returns the Doppler-broadened σ on the
735/// **working grid** (the data grid extended by the kernel's reach, else the
736/// data grid) together with the [`WorkingGridLayout`].
737///
738/// The spatial production pipeline (`spatial_map_typed`) uses this to pre-store
739/// σ on the working grid so each per-pixel `PrecomputedTransmissionModel`
740/// applies Beer-Lambert + resolution on the working grid and extracts the data
741/// points last — matching [`forward_model`] (issue #608).  Resolution is NOT
742/// applied (issue #442).
743pub fn broadened_cross_sections_on_working_grid(
744    energies: &[f64],
745    resonance_data: &[ResonanceData],
746    temperature_k: f64,
747    instrument: Option<&InstrumentParams>,
748    cancel: Option<&AtomicBool>,
749) -> Result<WorkingGridXs, TransmissionError> {
750    // Validate energy grid once before the per-isotope loop.
751    if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
752        return Err(ResolutionError::UnsortedEnergies.into());
753    }
754
755    // Build auxiliary grid with boundary extension + resonance fine-structure.
756    // SAMMY extends the energy grid beyond the data range and adds dense points
757    // around narrow resonances so the broadening convolution integrals have
758    // adequate quadrature points.
759    // SAMMY Ref: dat/mdat4.f90 Escale+Fspken+Add_Pnts, dat/mdata.f90 Vqcon
760    let rd_refs: Vec<&ResonanceData> = resonance_data.iter().collect();
761    let ext_grid = build_aux_grid(energies, instrument, &rd_refs);
762    let (work_energies, layout) = working_grid_layout(energies, ext_grid.as_ref());
763
764    // Parallelize across isotopes — Doppler broadening for each isotope is
765    // independent.  Resolution is NOT applied here (issue #442: resolution
766    // must be applied after Beer-Lambert on total transmission).  σ is returned
767    // on the working grid WITHOUT extracting the data points.
768    // Cancellation is checked per-isotope inside the parallel map.
769    let result: Result<Vec<Vec<f64>>, TransmissionError> = resonance_data
770        .par_iter()
771        .map(|rd| {
772            // Check cancellation before starting this isotope.
773            if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
774                return Err(TransmissionError::Cancelled);
775            }
776
777            continuous_doppler::broaden(work_energies, rd, temperature_k).map_err(Into::into)
778        })
779        .collect();
780
781    // Final cancellation check: if cancel was set during parallel execution,
782    // some tasks may have completed before observing it.
783    if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
784        return Err(TransmissionError::Cancelled);
785    }
786
787    Ok(WorkingGridXs {
788        sigma: result?,
789        layout,
790    })
791}
792
793/// Compute Doppler+resolution-broadened cross-sections using SAMMY's
794/// Beer-Lambert-aware pipeline for transmission data.
795///
796/// For transmission data, SAMMY applies resolution broadening to the
797/// transmission T = exp(-nd×σ_D) rather than to σ_D directly.  Due to
798/// Jensen's inequality (the exponential is convex), direct σ broadening
799/// overestimates the effective cross section at resonance peaks.  This
800/// function implements SAMMY's correct pipeline:
801///
802/// 1. Evaluate unbroadened σ on extended grid
803/// 2. Doppler-broaden σ → σ_D
804/// 3. Convert to transmission: T = exp(-nd × σ_D)
805/// 4. Resolution-broaden T → T_broadened
806/// 5. Convert back: σ_eff = -ln(T_broadened) / nd
807///
808/// SAMMY Ref: DopplerAndResolutionBroadener.cpp — resolution broadening is
809/// applied after Beer-Lambert conversion in the SAMMY pipeline.
810///
811/// # Arguments
812/// * `energies`             — Energy grid in eV (sorted ascending).
813/// * `resonance_data`       — Resonance parameters for each isotope.
814/// * `temperature_k`        — Sample temperature for Doppler broadening.
815/// * `instrument`           — Instrument resolution parameters.
816/// * `thickness_atoms_barn`  — Sample thickness n×d (atoms/barn).  Must be > 0.
817/// * `cancel`               — Optional cancellation token.
818///
819/// # Returns
820/// One effective cross-section vector per isotope on success.
821pub fn broadened_cross_sections_for_transmission(
822    energies: &[f64],
823    resonance_data: &[ResonanceData],
824    temperature_k: f64,
825    instrument: &InstrumentParams,
826    thickness_atoms_barn: f64,
827    cancel: Option<&AtomicBool>,
828) -> Result<Vec<Vec<f64>>, TransmissionError> {
829    // The Beer-Lambert→σ_eff inversion (step 5: σ_eff = −ln(T)/nd) divides by
830    // the thickness, so a non-positive or non-finite nd would yield ±∞/NaN
831    // cross-sections.  The docstring contract requires nd > 0; enforce it
832    // up-front before any work, matching the module's `InputMismatch` style.
833    if !thickness_atoms_barn.is_finite() || thickness_atoms_barn <= 0.0 {
834        return Err(TransmissionError::InputMismatch(format!(
835            "thickness_atoms_barn must be finite and > 0, got {thickness_atoms_barn}"
836        )));
837    }
838    if !energies.windows(2).all(|w| w[0] <= w[1]) {
839        return Err(ResolutionError::UnsortedEnergies.into());
840    }
841
842    let rd_refs: Vec<&ResonanceData> = resonance_data.iter().collect();
843    let ext_grid = build_aux_grid(energies, Some(instrument), &rd_refs);
844    let nd = thickness_atoms_barn;
845
846    let result: Result<Vec<Vec<f64>>, TransmissionError> = resonance_data
847        .par_iter()
848        .map(|rd| {
849            if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
850                return Err(TransmissionError::Cancelled);
851            }
852
853            let sigma_eff = if let Some((ref ext_energies, ref data_indices)) = ext_grid {
854                // Extended grid available: evaluate the full pipeline on the
855                // extended grid and extract at data positions.
856
857                // 1. Doppler-broadened cross sections on the extended grid.
858                let after_doppler = continuous_doppler::broaden(ext_energies, rd, temperature_k)?;
859
860                // 3. Convert to transmission: T = exp(-nd × σ_D).
861                let transmission: Vec<f64> = after_doppler
862                    .iter()
863                    .map(|&sigma| (-nd * sigma).exp())
864                    .collect();
865
866                // 4. Resolution-broaden T.
867                let t_broadened = resolution::apply_resolution_presorted(
868                    ext_energies,
869                    &transmission,
870                    &instrument.resolution,
871                );
872
873                // 5. Convert back to effective σ: σ_eff = -ln(T_broad) / nd.
874                data_indices
875                    .iter()
876                    .map(|&i| {
877                        let t = t_broadened[i].clamp(1e-30, 1.0);
878                        -t.ln() / nd
879                    })
880                    .collect()
881            } else {
882                // No extended grid (e.g. tabulated resolution with no aux grid):
883                // Doppler on data grid, Beer-Lambert, resolution on data grid.
884                let after_doppler = continuous_doppler::broaden(energies, rd, temperature_k)?;
885
886                let transmission: Vec<f64> = after_doppler
887                    .iter()
888                    .map(|&sigma| (-nd * sigma).exp())
889                    .collect();
890
891                let t_broadened = resolution::apply_resolution_presorted(
892                    energies,
893                    &transmission,
894                    &instrument.resolution,
895                );
896
897                t_broadened
898                    .iter()
899                    .map(|&t| {
900                        let t_clamped = t.clamp(1e-30, 1.0);
901                        -t_clamped.ln() / nd
902                    })
903                    .collect()
904            };
905
906            Ok(sigma_eff)
907        })
908        .collect();
909
910    if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
911        return Err(TransmissionError::Cancelled);
912    }
913
914    result
915}
916
917/// Compute unbroadened (raw Reich-Moore) cross-sections for each isotope.
918///
919/// This is the temperature-independent first step of the forward model.
920/// The result can be cached and reused across multiple temperature evaluations
921/// (e.g., during LM iterations where temperature is a free parameter).
922///
923/// # Returns
924/// One total cross-section vector per isotope: `result[k][e]` is the
925/// unbroadened total cross-section (barns) for isotope `k` at energy `e`.
926pub fn unbroadened_cross_sections(
927    energies: &[f64],
928    resonance_data: &[ResonanceData],
929    cancel: Option<&AtomicBool>,
930) -> Result<Vec<Vec<f64>>, TransmissionError> {
931    let result: Result<Vec<Vec<f64>>, TransmissionError> = resonance_data
932        .par_iter()
933        .map(|rd| {
934            if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
935                return Err(TransmissionError::Cancelled);
936            }
937            let xs: Vec<f64> = energies
938                .iter()
939                .map(|&e| reich_moore::cross_sections_at_energy(rd, e).total)
940                .collect();
941            Ok(xs)
942        })
943        .collect();
944
945    if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
946        return Err(TransmissionError::Cancelled);
947    }
948    result
949}
950
951/// Compute Doppler-broadened cross-sections from precomputed unbroadened
952/// cross-sections.
953///
954/// Returns **Doppler-only** cross-sections.  Resolution broadening is NOT
955/// applied (issue #442: must be applied after Beer-Lambert on total T).
956///
957/// Like [`broadened_cross_sections`] but skips the expensive Reich-Moore
958/// calculation (step 1). Use [`unbroadened_cross_sections`] to compute
959/// `base_xs` once, then call this function repeatedly with different
960/// temperatures.
961pub fn broadened_cross_sections_from_base(
962    energies: &[f64],
963    base_xs: &[Vec<f64>],
964    resonance_data: &[ResonanceData],
965    temperature_k: f64,
966    instrument: Option<&InstrumentParams>,
967) -> Result<Vec<Vec<f64>>, TransmissionError> {
968    // Delegate to the working-grid variant and extract the data points.
969    // Doppler runs on the working (aux) grid for boundary accuracy; resolution
970    // is NOT applied here (issue #442).  Extracting the data points here keeps
971    // this function's contract (data-grid σ) unchanged.
972    let WorkingGridXs { sigma, layout } = broadened_cross_sections_from_base_on_working_grid(
973        energies,
974        base_xs,
975        resonance_data,
976        temperature_k,
977        instrument,
978    )?;
979    Ok(sigma.iter().map(|s| layout.extract(s)).collect())
980}
981
982/// Like [`broadened_cross_sections_from_base`] but returns the Doppler-broadened
983/// σ on the **working grid** (auxiliary extended grid when Gaussian resolution
984/// is active, else the data grid) together with the [`WorkingGridLayout`].
985///
986/// This is the building block the LM fit's cached / precomputed paths use to
987/// apply resolution broadening on the working grid and extract the data points
988/// last — matching [`forward_model`] (issue #608).  Resolution is NOT applied
989/// (issue #442).
990pub fn broadened_cross_sections_from_base_on_working_grid(
991    energies: &[f64],
992    base_xs: &[Vec<f64>],
993    resonance_data: &[ResonanceData],
994    temperature_k: f64,
995    instrument: Option<&InstrumentParams>,
996) -> Result<WorkingGridXs, TransmissionError> {
997    validate_base_xs(energies, base_xs, resonance_data)?;
998    if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
999        return Err(ResolutionError::UnsortedEnergies.into());
1000    }
1001
1002    // Build auxiliary grid with boundary extension + resonance fine-structure.
1003    // base_xs is on the data grid; we extend it to the aux grid by evaluating
1004    // cross-sections at the auxiliary-only points (cheap: only the few hundred
1005    // extra points, not the full grid).
1006    // SAMMY Ref: dat/mdat4.f90 Escale+Fspken+Add_Pnts
1007    let rd_refs: Vec<&ResonanceData> = resonance_data.iter().collect();
1008    let ext_grid = build_aux_grid(energies, instrument, &rd_refs);
1009    let is_data_point = data_point_mask(ext_grid.as_ref());
1010    let (work_energies, layout) = working_grid_layout(energies, ext_grid.as_ref());
1011
1012    // Resolution is NOT applied (issue #442).  Doppler broadening on the
1013    // working grid, returned WITHOUT extracting the data points.
1014    let sigma: Result<Vec<Vec<f64>>, TransmissionError> = base_xs
1015        .par_iter()
1016        .zip(resonance_data.par_iter())
1017        .map(|(xs_raw, rd)| {
1018            if temperature_k > 0.0 {
1019                // The base table IS the resonance equation sampled on the
1020                // data grid, so integrating from `rd` answers the same
1021                // question without the sampling error. Broadening the table
1022                // instead cost a systematic -3.7 K on the synthetic
1023                // temperature fixture.
1024                return continuous_doppler::broaden(work_energies, rd, temperature_k)
1025                    .map_err(Into::into);
1026            }
1027            let xs_work = if let Some((ref ext_energies, ref data_indices)) = ext_grid {
1028                let mask = is_data_point.as_ref().unwrap();
1029                build_extended_xs_from_base(ext_energies, data_indices, mask, xs_raw, rd)
1030            } else {
1031                xs_raw.clone()
1032            };
1033            Ok(xs_work)
1034        })
1035        .collect();
1036
1037    Ok(WorkingGridXs {
1038        sigma: sigma?,
1039        layout,
1040    })
1041}
1042
1043/// Compute Doppler-broadened cross-sections and their **analytical**
1044/// temperature derivative from precomputed unbroadened cross-sections.
1045///
1046/// Returns **Doppler-only** cross-sections and derivatives.  Resolution
1047/// broadening is NOT applied (issue #442: must be applied after
1048/// Beer-Lambert on total T).
1049///
1050/// Uses `doppler_broaden_with_derivative` for exact ∂σ/∂T in a single pass
1051/// (1× broadening), replacing the 3× FD approach.
1052///
1053/// Returns `BroadenedXsWithDerivative`: `(sigma_k, dsigma_k_dT)`.
1054pub fn broadened_cross_sections_with_analytical_derivative_from_base(
1055    energies: &[f64],
1056    base_xs: &[Vec<f64>],
1057    resonance_data: &[ResonanceData],
1058    temperature_k: f64,
1059    instrument: Option<&InstrumentParams>,
1060) -> Result<BroadenedXsWithDerivative, TransmissionError> {
1061    // Delegate to the working-grid variant and extract the data points for
1062    // both σ and ∂σ/∂T.  Keeps this function's data-grid contract unchanged.
1063    let WorkingGridXsWithDerivative {
1064        sigma,
1065        dsigma_dt,
1066        layout,
1067    } = broadened_cross_sections_with_analytical_derivative_from_base_on_working_grid(
1068        energies,
1069        base_xs,
1070        resonance_data,
1071        temperature_k,
1072        instrument,
1073    )?;
1074    let xs_all = sigma.iter().map(|s| layout.extract(s)).collect();
1075    let dxs_all = dsigma_dt.iter().map(|d| layout.extract(d)).collect();
1076    Ok((xs_all, dxs_all))
1077}
1078
1079/// Like [`broadened_cross_sections_with_analytical_derivative_from_base`] but
1080/// returns σ and ∂σ/∂T on the **working grid** (auxiliary extended grid when
1081/// Gaussian resolution is active, else the data grid) together with the
1082/// [`WorkingGridLayout`].
1083///
1084/// The analytical Jacobian uses this so it can form `−T·Σᵢ nᵢ·rᵢ·∂σᵢ/∂T` on
1085/// the working grid, resolution-broaden it there, and extract the data points
1086/// last (issue #608).  Resolution is NOT applied (issue #442).
1087pub fn broadened_cross_sections_with_analytical_derivative_from_base_on_working_grid(
1088    energies: &[f64],
1089    base_xs: &[Vec<f64>],
1090    resonance_data: &[ResonanceData],
1091    temperature_k: f64,
1092    instrument: Option<&InstrumentParams>,
1093) -> Result<WorkingGridXsWithDerivative, TransmissionError> {
1094    validate_base_xs(energies, base_xs, resonance_data)?;
1095    if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
1096        return Err(ResolutionError::UnsortedEnergies.into());
1097    }
1098
1099    // Build auxiliary grid (same as broadened_cross_sections_from_base).
1100    let rd_refs: Vec<&ResonanceData> = resonance_data.iter().collect();
1101    let ext_grid = build_aux_grid(energies, instrument, &rd_refs);
1102    let is_data_point = data_point_mask(ext_grid.as_ref());
1103    let (work_energies, layout) = working_grid_layout(energies, ext_grid.as_ref());
1104
1105    // Per-isotope: Doppler broaden with analytical derivative on the WORKING
1106    // grid, returned WITHOUT extracting the data points.
1107    // Resolution is NOT applied (issue #442).
1108    type IsotopeXsDxs = Result<(Vec<f64>, Vec<f64>), TransmissionError>;
1109    let results: Vec<IsotopeXsDxs> = base_xs
1110        .par_iter()
1111        .zip(resonance_data.par_iter())
1112        .map(|(xs_raw, rd)| {
1113            if temperature_k > 0.0 {
1114                // Value and derivative converge on the SAME quadrature
1115                // panels, so the optimiser cannot step on a derivative that
1116                // describes a different curve from the one it lands on.
1117                return continuous_doppler::broaden_with_derivative(
1118                    work_energies,
1119                    rd,
1120                    temperature_k,
1121                )
1122                .map_err(Into::into);
1123            }
1124            let xs_work = if let Some((ref ext_energies, ref data_indices)) = ext_grid {
1125                let mask = is_data_point.as_ref().unwrap();
1126                build_extended_xs_from_base(ext_energies, data_indices, mask, xs_raw, rd)
1127            } else {
1128                xs_raw.clone()
1129            };
1130            let zeros = vec![0.0; work_energies.len()];
1131            Ok((xs_work, zeros))
1132        })
1133        .collect();
1134
1135    // Separate into (sigma, dsigma_dt) on the working grid.
1136    let mut sigma = Vec::with_capacity(base_xs.len());
1137    let mut dsigma_dt = Vec::with_capacity(base_xs.len());
1138    for r in results {
1139        let (xs, dxs) = r?;
1140        sigma.push(xs);
1141        dsigma_dt.push(dxs);
1142    }
1143    Ok(WorkingGridXsWithDerivative {
1144        sigma,
1145        dsigma_dt,
1146        layout,
1147    })
1148}
1149
1150/// Compute a transmission spectrum from precomputed unbroadened cross-sections.
1151///
1152/// Applies Doppler broadening and Beer-Lambert using cached base XS, then
1153/// resolution broadening on the total transmission (issue #442).  This skips
1154/// the expensive Reich-Moore calculation, making it suitable for use inside
1155/// `TransmissionFitModel::evaluate()` when temperature is a free parameter.
1156///
1157/// The full pipeline runs on the **auxiliary extended grid** (boundary
1158/// extension + resonance fine-structure points) so that Doppler AND resolution
1159/// broadening have adequate quadrature support and correct boundary handling;
1160/// the data-grid points are extracted LAST.  This mirrors the non-cached
1161/// [`forward_model`] and [`broadened_cross_sections_for_transmission`] exactly
1162/// — previously this cached path collapsed σ to the coarse data grid before
1163/// resolution broadening, degrading the convolution near the grid edges and
1164/// around narrow resonances.
1165///
1166/// Pipeline:
1167///   1. Per isotope: build σ on the extended grid (cached data-grid values +
1168///      freshly-evaluated auxiliary points), Doppler-broaden on the extended grid
1169///   2. Accumulate total attenuation on the extended grid: Σᵢ nᵢ·σ_{D,i}
1170///   3. Beer-Lambert: T = exp(−attenuation) on the extended grid
1171///   4. Resolution: T_broad = R ⊗ T on the extended grid (when instrument present)
1172///   5. Extract the data-grid points
1173pub fn forward_model_from_base_xs(
1174    energies: &[f64],
1175    base_xs: &[Vec<f64>],
1176    resonance_data: &[ResonanceData],
1177    thicknesses: &[f64],
1178    temperature_k: f64,
1179    instrument: Option<&InstrumentParams>,
1180) -> Result<Vec<f64>, TransmissionError> {
1181    if base_xs.len() != resonance_data.len() || thicknesses.len() != resonance_data.len() {
1182        return Err(TransmissionError::InputMismatch(format!(
1183            "forward_model_from_base_xs: base_xs({})/thicknesses({})/resonance_data({}) length mismatch",
1184            base_xs.len(),
1185            thicknesses.len(),
1186            resonance_data.len(),
1187        )));
1188    }
1189    for (i, row) in base_xs.iter().enumerate() {
1190        if row.len() != energies.len() {
1191            return Err(TransmissionError::InputMismatch(format!(
1192                "base_xs[{i}] has {} energies but expected {}",
1193                row.len(),
1194                energies.len(),
1195            )));
1196        }
1197    }
1198    let n = energies.len();
1199    if n == 0 {
1200        return Ok(vec![]);
1201    }
1202
1203    // Validate the data grid once before the per-isotope loop so resolution can
1204    // use the presorted (unchecked) path, matching forward_model.
1205    if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
1206        return Err(ResolutionError::UnsortedEnergies.into());
1207    }
1208
1209    // Build the auxiliary extended grid (boundary + resonance fine-structure).
1210    // SAMMY Ref: dat/mdat4.f90 Escale+Fspken+Add_Pnts.
1211    let rd_refs: Vec<&ResonanceData> = resonance_data.iter().collect();
1212    let ext_grid = build_aux_grid(energies, instrument, &rd_refs);
1213    let is_data_point: Option<Vec<bool>> = ext_grid.as_ref().map(|(ext_e, di)| {
1214        let mut mask = vec![false; ext_e.len()];
1215        for &idx in di {
1216            mask[idx] = true;
1217        }
1218        mask
1219    });
1220
1221    // Working grid: the extended grid when available, else the data grid.
1222    let (work_energies, work_len): (&[f64], usize) = if let Some((ref ext_e, _)) = ext_grid {
1223        (ext_e.as_slice(), ext_e.len())
1224    } else {
1225        (energies, n)
1226    };
1227
1228    // Step 1: per-isotope Doppler-broadened σ on the WORKING (extended) grid.
1229    // Resolution is NOT applied here (issue #442) — it goes on total T below.
1230    let doppler_xs: Result<Vec<Vec<f64>>, TransmissionError> = base_xs
1231        .par_iter()
1232        .zip(resonance_data.par_iter())
1233        .map(|(xs_raw, rd)| {
1234            if temperature_k > 0.0 {
1235                return continuous_doppler::broaden(work_energies, rd, temperature_k)
1236                    .map_err(Into::into);
1237            }
1238            let xs_ext = if let Some((ref ext_energies, ref data_indices)) = ext_grid {
1239                let mask = is_data_point.as_ref().unwrap();
1240                build_extended_xs_from_base(ext_energies, data_indices, mask, xs_raw, rd)
1241            } else {
1242                xs_raw.clone()
1243            };
1244            Ok(xs_ext)
1245        })
1246        .collect();
1247    let doppler_xs = doppler_xs?;
1248
1249    // Step 2-3: accumulate attenuation on the working grid, then Beer-Lambert.
1250    let mut total_attenuation = vec![0.0f64; work_len];
1251    for (xs, &thickness) in doppler_xs.iter().zip(thicknesses.iter()) {
1252        if thickness <= 0.0 {
1253            continue;
1254        }
1255        for i in 0..work_len {
1256            total_attenuation[i] += thickness * xs[i];
1257        }
1258    }
1259    let transmission: Vec<f64> = total_attenuation.iter().map(|&att| (-att).exp()).collect();
1260
1261    // Step 4-5: resolution broadening on total transmission (on the working
1262    // grid), then extract the data-grid points last.  When `instrument` is
1263    // `None`, `build_aux_grid` returns `None`, so the working grid IS the data
1264    // grid and `transmission` is returned directly (matching `forward_model`).
1265    if let Some(inst) = instrument {
1266        let t_broadened =
1267            resolution::apply_resolution_presorted(work_energies, &transmission, &inst.resolution);
1268        if let Some((_, ref data_indices)) = ext_grid {
1269            Ok(data_indices.iter().map(|&i| t_broadened[i]).collect())
1270        } else {
1271            Ok(t_broadened)
1272        }
1273    } else {
1274        Ok(transmission)
1275    }
1276}
1277
1278#[cfg(test)]
1279mod tests {
1280    use super::*;
1281    use nereids_core::types::Isotope;
1282    use nereids_endf::resonance::test_support::u238_single_resonance;
1283    use nereids_endf::resonance::{LGroup, Resonance, ResonanceFormalism, ResonanceRange};
1284
1285    /// Issue #608 R2: the spatial pipeline builds the working-grid σ
1286    /// (`broadened_cross_sections_on_working_grid`) and the shared layout
1287    /// (`resolution_working_grid`) via SEPARATE calls — they must produce
1288    /// bit-identical layouts, or per-pixel σ and the shared layout would
1289    /// disagree.  Both route through `build_aux_grid` with the same arguments;
1290    /// this pins that determinism against a future divergence.
1291    #[test]
1292    fn working_grid_layout_matches_across_separate_calls() {
1293        let data = u238_single_resonance();
1294        let energies: Vec<f64> = (0..401).map(|i| 4.0 + (i as f64) * 0.015).collect();
1295        let inst = InstrumentParams {
1296            resolution: crate::resolution::ResolutionFunction::Gaussian(
1297                crate::resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
1298            ),
1299        };
1300        let layout_a = resolution_working_grid(&energies, Some(&inst), &[&data]).unwrap();
1301        let working = broadened_cross_sections_on_working_grid(
1302            &energies,
1303            std::slice::from_ref(&data),
1304            300.0,
1305            Some(&inst),
1306            None,
1307        )
1308        .unwrap();
1309        let layout_b = working.layout;
1310        assert!(
1311            !layout_a.is_identity(),
1312            "Gaussian resolution should build a non-identity auxiliary grid"
1313        );
1314        assert_eq!(
1315            layout_a.data_indices, layout_b.data_indices,
1316            "data-index maps must match across the two builders"
1317        );
1318        assert_eq!(
1319            layout_a.energies.len(),
1320            layout_b.energies.len(),
1321            "working-grid length must match"
1322        );
1323        for (a, b) in layout_a.energies.iter().zip(layout_b.energies.iter()) {
1324            assert_eq!(
1325                a.to_bits(),
1326                b.to_bits(),
1327                "working-grid energies must be bit-identical"
1328            );
1329        }
1330    }
1331
1332    #[test]
1333    fn test_beer_lambert_zero_thickness() {
1334        let xs = vec![100.0, 200.0, 300.0];
1335        let t = beer_lambert(&xs, 0.0);
1336        assert_eq!(t, vec![1.0, 1.0, 1.0]);
1337    }
1338
1339    #[test]
1340    fn test_beer_lambert_basic() {
1341        // σ = 100 barns, thickness = 0.01 atoms/barn
1342        // T = exp(-1.0) ≈ 0.3679
1343        let xs = vec![100.0];
1344        let t = beer_lambert(&xs, 0.01);
1345        assert!(
1346            (t[0] - (-1.0_f64).exp()).abs() < 1e-10,
1347            "T = {}, expected {}",
1348            t[0],
1349            (-1.0_f64).exp()
1350        );
1351    }
1352
1353    #[test]
1354    fn test_beer_lambert_opaque() {
1355        // Very thick sample: T should be 0 (exp(-1000) underflows)
1356        let xs = vec![1000.0];
1357        let t = beer_lambert(&xs, 1.0);
1358        assert_eq!(t[0], 0.0, "T = {}, expected 0.0", t[0]);
1359    }
1360
1361    #[test]
1362    fn test_beer_lambert_multi_additive() {
1363        // Two isotopes should combine additively in the exponent.
1364        // σ₁ = 100 barns, t₁ = 0.01 → att₁ = 1.0
1365        // σ₂ = 200 barns, t₂ = 0.005 → att₂ = 1.0
1366        // T = exp(-(1.0 + 1.0)) = exp(-2.0)
1367        let xs1 = vec![100.0];
1368        let xs2 = vec![200.0];
1369        let t = beer_lambert_multi(&[&xs1, &xs2], &[0.01, 0.005]).unwrap();
1370        assert!(
1371            (t[0] - (-2.0_f64).exp()).abs() < 1e-10,
1372            "T = {}, expected {}",
1373            t[0],
1374            (-2.0_f64).exp()
1375        );
1376    }
1377
1378    #[test]
1379    fn test_transmission_dip_at_resonance() {
1380        // U-238 has a huge capture resonance at 6.674 eV.
1381        // A thin sample should show a transmission dip there.
1382        let data = u238_single_resonance();
1383        let thickness = 0.001; // atoms/barn (thin)
1384
1385        // Evaluate at a few energies
1386        let energies = [1.0, 3.0, 6.674, 10.0, 20.0];
1387        let xs: Vec<f64> = energies
1388            .iter()
1389            .map(|&e| reich_moore::cross_sections_at_energy(&data, e).total)
1390            .collect();
1391        let trans = beer_lambert(&xs, thickness);
1392
1393        // At 6.674 eV (on resonance), transmission should be much lower
1394        let t_on_res = trans[2];
1395        let t_off_res = trans[0]; // 1 eV, off resonance
1396
1397        assert!(
1398            t_on_res < t_off_res,
1399            "On-resonance T ({}) should be < off-resonance T ({})",
1400            t_on_res,
1401            t_off_res
1402        );
1403
1404        // On-resonance with huge σ (~25000 barns), T ≈ exp(-25) ≈ 0
1405        assert!(
1406            t_on_res < 0.01,
1407            "On-resonance T ({}) should be very small",
1408            t_on_res
1409        );
1410    }
1411
1412    #[test]
1413    fn test_forward_model_no_broadening() {
1414        // Forward model at T=0 with no resolution should give
1415        // the same result as direct Beer-Lambert on unbroadened σ.
1416        let data = u238_single_resonance();
1417        let thickness = 0.001;
1418
1419        let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
1420
1421        // Direct calculation
1422        let xs: Vec<f64> = energies
1423            .iter()
1424            .map(|&e| reich_moore::cross_sections_at_energy(&data, e).total)
1425            .collect();
1426        let t_direct = beer_lambert(&xs, thickness);
1427
1428        // Forward model
1429        let sample = SampleParams::new(0.0, vec![(data, thickness)]).unwrap();
1430        let t_forward = forward_model(&energies, &sample, None).unwrap();
1431
1432        for i in 0..energies.len() {
1433            assert!(
1434                (t_direct[i] - t_forward[i]).abs() < 1e-10,
1435                "Mismatch at E={}: direct={}, forward={}",
1436                energies[i],
1437                t_direct[i],
1438                t_forward[i]
1439            );
1440        }
1441    }
1442
1443    #[test]
1444    fn test_forward_model_with_broadening() {
1445        // Forward model with Doppler broadening should smooth out the
1446        // transmission dip, making it wider and shallower.
1447        let data = u238_single_resonance();
1448        let thickness = 0.0001; // Very thin (to avoid total absorption)
1449
1450        let energies: Vec<f64> = (0..401).map(|i| 5.0 + (i as f64) * 0.01).collect();
1451
1452        // Cold (no broadening)
1453        let sample_cold = SampleParams::new(0.0, vec![(data.clone(), thickness)]).unwrap();
1454        let t_cold = forward_model(&energies, &sample_cold, None).unwrap();
1455
1456        // Hot (300 K Doppler)
1457        let sample_hot = SampleParams::new(300.0, vec![(data, thickness)]).unwrap();
1458        let t_hot = forward_model(&energies, &sample_hot, None).unwrap();
1459
1460        // Find minima
1461        let min_cold = t_cold.iter().cloned().fold(f64::MAX, f64::min);
1462        let min_hot = t_hot.iter().cloned().fold(f64::MAX, f64::min);
1463
1464        // Broadened dip should be shallower (higher minimum transmission)
1465        assert!(
1466            min_hot > min_cold,
1467            "Broadened min T ({}) should be > unbroadened min T ({})",
1468            min_hot,
1469            min_cold
1470        );
1471    }
1472
1473    #[test]
1474    fn test_forward_model_multi_isotope() {
1475        // Two isotopes with different resonances should create two dips.
1476        let u238 = u238_single_resonance();
1477
1478        // Create a fictitious second isotope with a resonance at 20 eV
1479        let other = ResonanceData {
1480            isotope: Isotope::new(1, 10).unwrap(),
1481            za: 1010,
1482            awr: 10.0,
1483            ranges: vec![ResonanceRange {
1484                energy_low: 0.0,
1485                energy_high: 100.0,
1486                resolved: true,
1487                formalism: ResonanceFormalism::ReichMoore,
1488                target_spin: 0.0,
1489                scattering_radius: 5.0,
1490                naps: 1,
1491                l_groups: vec![LGroup {
1492                    l: 0,
1493                    awr: 10.0,
1494                    apl: 5.0,
1495                    qx: 0.0,
1496                    lrx: 0,
1497                    resonances: vec![Resonance {
1498                        energy: 20.0,
1499                        j: 0.5,
1500                        gn: 0.1,
1501                        gg: 0.05,
1502                        gfa: 0.0,
1503                        gfb: 0.0,
1504                    }],
1505                }],
1506                ap_table: None,
1507                r_external: vec![],
1508            }],
1509        };
1510
1511        let energies: Vec<f64> = (0..301).map(|i| 1.0 + (i as f64) * 0.1).collect();
1512
1513        let sample = SampleParams::new(0.0, vec![(u238, 0.0001), (other, 0.0001)]).unwrap();
1514        let t = forward_model(&energies, &sample, None).unwrap();
1515
1516        // Find the transmission near 6.674 eV (U-238 resonance)
1517        let idx_u238 = energies
1518            .iter()
1519            .position(|&e| (e - 6.7).abs() < 0.05)
1520            .unwrap();
1521        // Find the transmission near 20 eV (other resonance)
1522        let idx_other = energies
1523            .iter()
1524            .position(|&e| (e - 20.0).abs() < 0.05)
1525            .unwrap();
1526        // Off-resonance
1527        let idx_off = energies
1528            .iter()
1529            .position(|&e| (e - 15.0).abs() < 0.05)
1530            .unwrap();
1531
1532        // Both dips should be visible
1533        assert!(
1534            t[idx_u238] < t[idx_off],
1535            "U-238 dip at 6.7 eV: T={}, off-res: T={}",
1536            t[idx_u238],
1537            t[idx_off]
1538        );
1539        assert!(
1540            t[idx_other] < t[idx_off],
1541            "Other dip at 20 eV: T={}, off-res: T={}",
1542            t[idx_other],
1543            t[idx_off]
1544        );
1545    }
1546
1547    #[test]
1548    fn test_broadened_xs_analytical_derivative() {
1549        // Verify analytical ∂σ/∂T against a manual FD at a larger step (1 K)
1550        // and check they agree to reasonable tolerance.
1551        let data = u238_single_resonance();
1552        let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1553        let temperature = 300.0;
1554
1555        let base_xs =
1556            unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1557        let (xs, dxs_dt) = broadened_cross_sections_with_analytical_derivative_from_base(
1558            &energies,
1559            &base_xs,
1560            std::slice::from_ref(&data),
1561            temperature,
1562            None,
1563        )
1564        .unwrap();
1565
1566        // Basic shape checks
1567        assert_eq!(xs.len(), 1, "one isotope");
1568        assert_eq!(dxs_dt.len(), 1, "one isotope derivative");
1569        assert_eq!(xs[0].len(), energies.len());
1570        assert_eq!(dxs_dt[0].len(), energies.len());
1571
1572        // The derivative should be non-zero near the resonance at 6.674 eV
1573        // where Doppler broadening has a strong effect.
1574        let idx_res = energies
1575            .iter()
1576            .position(|&e| (e - 6.674).abs() < 0.05)
1577            .unwrap();
1578        assert!(
1579            dxs_dt[0][idx_res].abs() > 0.0,
1580            "dσ/dT should be non-zero near resonance, got {}",
1581            dxs_dt[0][idx_res]
1582        );
1583
1584        // Cross-check: compute a manual FD at a larger step and verify
1585        // the analytical derivative is consistent (within ~5% relative error).
1586        let big_dt = 1.0;
1587        let xs_up = broadened_cross_sections(
1588            &energies,
1589            std::slice::from_ref(&data),
1590            temperature + big_dt,
1591            None,
1592            None,
1593        )
1594        .unwrap();
1595        let xs_down =
1596            broadened_cross_sections(&energies, &[data], temperature - big_dt, None, None).unwrap();
1597
1598        let manual_deriv: Vec<f64> = xs_up[0]
1599            .iter()
1600            .zip(xs_down[0].iter())
1601            .map(|(&u, &d)| (u - d) / (2.0 * big_dt))
1602            .collect();
1603
1604        // Compare near the resonance where the derivative is large.
1605        let deriv_analytical = dxs_dt[0][idx_res];
1606        let deriv_coarse = manual_deriv[idx_res];
1607        let rel_err = (deriv_analytical - deriv_coarse).abs()
1608            / deriv_analytical.abs().max(deriv_coarse.abs()).max(1e-30);
1609        assert!(
1610            rel_err < 0.05,
1611            "Analytical vs FD derivatives disagree: analytical={}, coarse={}, rel_err={}",
1612            deriv_analytical,
1613            deriv_coarse,
1614            rel_err,
1615        );
1616    }
1617
1618    #[test]
1619    fn test_broadened_xs_analytical_derivative_low_temperature() {
1620        // Regression test: derivative must have correct sign at low temperature.
1621        let data = u238_single_resonance();
1622        let energies: Vec<f64> = (0..51).map(|i| 5.0 + (i as f64) * 0.1).collect();
1623
1624        let base_xs =
1625            unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1626
1627        // T = 0.05 K
1628        let (xs_low, dxs_low) = broadened_cross_sections_with_analytical_derivative_from_base(
1629            &energies,
1630            &base_xs,
1631            std::slice::from_ref(&data),
1632            0.05,
1633            None,
1634        )
1635        .unwrap();
1636        assert!(!xs_low.is_empty());
1637        // Derivative should be finite and mostly positive (Doppler broadening
1638        // increases with temperature for narrow resonances).
1639        for deriv_vec in &dxs_low {
1640            for &d in deriv_vec {
1641                assert!(d.is_finite(), "derivative must be finite at T=0.05 K");
1642            }
1643        }
1644
1645        // T = 0.0 K: edge case
1646        let (xs_zero, dxs_zero) = broadened_cross_sections_with_analytical_derivative_from_base(
1647            &energies,
1648            &base_xs,
1649            std::slice::from_ref(&data),
1650            0.0,
1651            None,
1652        )
1653        .unwrap();
1654        assert!(!xs_zero.is_empty());
1655        for deriv_vec in &dxs_zero {
1656            for &d in deriv_vec {
1657                assert!(d.is_finite(), "derivative must be finite at T=0.0 K");
1658            }
1659        }
1660    }
1661
1662    // --- SampleParams validation tests ---
1663
1664    #[test]
1665    fn test_sample_params_valid() {
1666        let sample = SampleParams::new(300.0, vec![]).unwrap();
1667        assert!((sample.temperature_k() - 300.0).abs() < 1e-15);
1668        assert!(sample.isotopes().is_empty());
1669    }
1670
1671    #[test]
1672    fn test_sample_params_zero_temperature() {
1673        let sample = SampleParams::new(0.0, vec![]).unwrap();
1674        assert!((sample.temperature_k()).abs() < 1e-15);
1675    }
1676
1677    #[test]
1678    fn test_sample_params_rejects_negative_temperature() {
1679        let err = SampleParams::new(-1.0, vec![]).unwrap_err();
1680        assert_eq!(err, SampleParamsError::NegativeTemperature(-1.0));
1681    }
1682
1683    #[test]
1684    fn test_sample_params_rejects_nan_temperature() {
1685        let err = SampleParams::new(f64::NAN, vec![]).unwrap_err();
1686        assert!(matches!(err, SampleParamsError::NonFiniteTemperature(_)));
1687    }
1688
1689    #[test]
1690    fn test_sample_params_rejects_infinite_temperature() {
1691        let err = SampleParams::new(f64::INFINITY, vec![]).unwrap_err();
1692        assert!(matches!(err, SampleParamsError::NonFiniteTemperature(_)));
1693    }
1694
1695    #[test]
1696    fn test_sample_params_rejects_neg_infinite_temperature() {
1697        let err = SampleParams::new(f64::NEG_INFINITY, vec![]).unwrap_err();
1698        assert!(matches!(err, SampleParamsError::NonFiniteTemperature(_)));
1699    }
1700
1701    // --- Base XS caching tests ---
1702
1703    #[test]
1704    fn test_forward_model_from_base_xs_matches_forward_model() {
1705        let data = u238_single_resonance();
1706        let thickness = 0.0005;
1707        let temperature = 300.0;
1708        let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1709
1710        // Reference: full forward model
1711        let sample = SampleParams::new(temperature, vec![(data.clone(), thickness)]).unwrap();
1712        let t_ref = forward_model(&energies, &sample, None).unwrap();
1713
1714        // Cached path: unbroadened XS → forward_model_from_base_xs
1715        let base_xs =
1716            unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1717        let t_cached = forward_model_from_base_xs(
1718            &energies,
1719            &base_xs,
1720            std::slice::from_ref(&data),
1721            &[thickness],
1722            temperature,
1723            None,
1724        )
1725        .unwrap();
1726
1727        for (i, (&r, &c)) in t_ref.iter().zip(t_cached.iter()).enumerate() {
1728            assert!(
1729                (r - c).abs() < 1e-12,
1730                "Mismatch at E[{}]={}: ref={}, cached={}",
1731                i,
1732                energies[i],
1733                r,
1734                c
1735            );
1736        }
1737    }
1738
1739    #[test]
1740    fn test_broadened_from_base_matches_broadened() {
1741        let data = u238_single_resonance();
1742        let temperature = 300.0;
1743        let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1744
1745        let xs_ref = broadened_cross_sections(
1746            &energies,
1747            std::slice::from_ref(&data),
1748            temperature,
1749            None,
1750            None,
1751        )
1752        .unwrap();
1753        let base_xs =
1754            unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1755        let xs_cached = broadened_cross_sections_from_base(
1756            &energies,
1757            &base_xs,
1758            std::slice::from_ref(&data),
1759            temperature,
1760            None,
1761        )
1762        .unwrap();
1763
1764        assert_eq!(xs_ref.len(), xs_cached.len());
1765        for (r, c) in xs_ref[0].iter().zip(xs_cached[0].iter()) {
1766            assert!(
1767                (r - c).abs() < 1e-12,
1768                "broadened_from_base mismatch: ref={}, cached={}",
1769                r,
1770                c
1771            );
1772        }
1773    }
1774
1775    #[test]
1776    fn test_analytical_derivative_from_base_shape_and_finiteness() {
1777        // Verify the analytical derivative from base returns correct shapes
1778        // and finite values.
1779        let data = u238_single_resonance();
1780        let temperature = 300.0;
1781        let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1782
1783        let base_xs =
1784            unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1785        let (xs, dxs_dt) = broadened_cross_sections_with_analytical_derivative_from_base(
1786            &energies,
1787            &base_xs,
1788            std::slice::from_ref(&data),
1789            temperature,
1790            None,
1791        )
1792        .unwrap();
1793
1794        assert_eq!(xs.len(), 1);
1795        assert_eq!(dxs_dt.len(), 1);
1796        assert_eq!(xs[0].len(), energies.len());
1797        assert_eq!(dxs_dt[0].len(), energies.len());
1798
1799        // All values should be finite.
1800        for &v in &xs[0] {
1801            assert!(v.is_finite(), "XS must be finite, got {v}");
1802        }
1803        for &v in &dxs_dt[0] {
1804            assert!(v.is_finite(), "dXS/dT must be finite, got {v}");
1805        }
1806
1807        // XS from base should match XS from full broadening.
1808        let xs_full = broadened_cross_sections(
1809            &energies,
1810            std::slice::from_ref(&data),
1811            temperature,
1812            None,
1813            None,
1814        )
1815        .unwrap();
1816        for (r, c) in xs_full[0].iter().zip(xs[0].iter()) {
1817            assert!(
1818                (r - c).abs() < 1e-12,
1819                "XS mismatch: full={}, from_base={}",
1820                r,
1821                c
1822            );
1823        }
1824    }
1825
1826    /// Regression test for issue #442: resolution broadening must be applied
1827    /// to the total transmission T(E) AFTER Beer-Lambert, not to σ(E) before.
1828    ///
1829    /// This test constructs the expected result from first principles:
1830    ///
1831    ///   1. Doppler-broaden σ
1832    ///   2. Beer-Lambert: T = exp(−n·σ_D)
1833    ///   3. Resolution-broaden T
1834    ///
1835    /// and asserts that `forward_model()` matches.
1836    #[test]
1837    fn test_forward_model_resolution_after_beer_lambert() {
1838        let data = u238_single_resonance();
1839        let thickness = 0.0005; // atoms/barn
1840        let temperature = 300.0;
1841
1842        // Energy grid around the 6.674 eV resonance.
1843        let energies: Vec<f64> = (0..401).map(|i| 4.0 + (i as f64) * 0.015).collect();
1844
1845        let inst = InstrumentParams {
1846            resolution: resolution::ResolutionFunction::Gaussian(
1847                resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
1848            ),
1849        };
1850
1851        // --- Build expected from first principles ---
1852
1853        // Step 1: Doppler-broadened σ on the data grid, by the same method
1854        // the model uses. This test is about the ORDER of Beer-Lambert and
1855        // resolution; building the reference with a different broadening
1856        // method would fold that difference into the discriminant below.
1857        let sigma_d = continuous_doppler::broaden(&energies, &data, temperature).unwrap();
1858
1859        // Step 2: Beer-Lambert on total transmission.
1860        let transmission: Vec<f64> = sigma_d.iter().map(|&s| (-thickness * s).exp()).collect();
1861
1862        // Step 3: Resolution-broaden the transmission.
1863        let t_expected =
1864            resolution::apply_resolution(&energies, &transmission, &inst.resolution).unwrap();
1865
1866        // --- Wrong ordering for comparison: Resolution(σ) then Beer-Lambert ---
1867        let sigma_broadened =
1868            resolution::apply_resolution(&energies, &sigma_d, &inst.resolution).unwrap();
1869        let t_wrong: Vec<f64> = sigma_broadened
1870            .iter()
1871            .map(|&s| (-thickness * s).exp())
1872            .collect();
1873
1874        // --- forward_model() output ---
1875        let sample = SampleParams::new(temperature, vec![(data, thickness)]).unwrap();
1876        let t_forward = forward_model(&energies, &sample, Some(&inst)).unwrap();
1877
1878        // forward_model should match the correct ordering (resolution after Beer-Lambert).
1879        // The extended grid in forward_model adds boundary points, so the match
1880        // is approximate — but should be very close on the interior grid.
1881        let interior = 20..energies.len() - 20; // skip boundary region
1882        let mut max_err_correct = 0.0f64;
1883        let mut max_err_wrong = 0.0f64;
1884        for i in interior.clone() {
1885            let err_correct = (t_forward[i] - t_expected[i]).abs();
1886            let err_wrong = (t_forward[i] - t_wrong[i]).abs();
1887            max_err_correct = max_err_correct.max(err_correct);
1888            max_err_wrong = max_err_wrong.max(err_wrong);
1889        }
1890
1891        // The key discriminant: forward_model must be much closer to the
1892        // correct ordering (resolution after Beer-Lambert) than to the wrong
1893        // ordering (resolution before Beer-Lambert).
1894        //
1895        // Small absolute differences (~1%) between forward_model and the
1896        // data-grid reference are expected because forward_model uses an
1897        // extended grid for boundary handling.
1898        assert!(
1899            max_err_correct < max_err_wrong,
1900            "forward_model is closer to the WRONG ordering than the correct one. \
1901             Error vs correct = {max_err_correct}, error vs wrong = {max_err_wrong}"
1902        );
1903
1904        // The error against the correct ordering should be at least 5× smaller
1905        // than the error against the wrong ordering.
1906        assert!(
1907            max_err_correct < max_err_wrong * 0.5,
1908            "forward_model should be clearly closer to the correct ordering. \
1909             Error vs correct = {max_err_correct}, error vs wrong = {max_err_wrong}, \
1910             ratio = {:.2}",
1911            max_err_correct / max_err_wrong
1912        );
1913
1914        // Verify the two orderings actually differ — if they don't, the test
1915        // is not exercising the bug.
1916        let ordering_diff: f64 = interior
1917            .map(|i| (t_expected[i] - t_wrong[i]).abs())
1918            .fold(0.0f64, f64::max);
1919        assert!(
1920            ordering_diff > 1e-4,
1921            "The two orderings should differ measurably at the resonance dip, \
1922             but max diff = {ordering_diff}. Test parameters may be too weak."
1923        );
1924    }
1925
1926    /// Issue #442 containment: `broadened_cross_sections()` must return
1927    /// Doppler-only σ even when `instrument` is `Some`.  Resolution
1928    /// broadening must NOT be applied inside this function.
1929    #[test]
1930    fn test_broadened_xs_is_doppler_only_with_instrument() {
1931        let data = u238_single_resonance();
1932        let temperature = 300.0;
1933        let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1934
1935        let inst = InstrumentParams {
1936            resolution: resolution::ResolutionFunction::Gaussian(
1937                resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
1938            ),
1939        };
1940
1941        // With instrument (used for aux grid, but NOT for resolution broadening).
1942        let xs_with_inst = broadened_cross_sections(
1943            &energies,
1944            std::slice::from_ref(&data),
1945            temperature,
1946            Some(&inst),
1947            None,
1948        )
1949        .unwrap();
1950
1951        // Without instrument (pure Doppler on data grid).
1952        let xs_no_inst = broadened_cross_sections(
1953            &energies,
1954            std::slice::from_ref(&data),
1955            temperature,
1956            None,
1957            None,
1958        )
1959        .unwrap();
1960
1961        // Both should return Doppler-only σ.  The with-instrument path uses
1962        // the extended grid for Doppler which may produce slightly different
1963        // values, but they must be close (no resolution smoothing).
1964        assert_eq!(xs_with_inst.len(), 1);
1965        assert_eq!(xs_no_inst.len(), 1);
1966        assert_eq!(xs_with_inst[0].len(), energies.len());
1967
1968        // Compute what resolution-broadened σ would look like.
1969        let sigma_resolved =
1970            resolution::apply_resolution(&energies, &xs_no_inst[0], &inst.resolution).unwrap();
1971
1972        // The with-instrument result must NOT match the resolution-broadened version.
1973        // Near the resonance dip, resolution broadening smooths the peak — the
1974        // Doppler-only result should have a deeper dip than the resolved one.
1975        let idx_dip = energies
1976            .iter()
1977            .position(|&e| (e - 6.674).abs() < 0.05)
1978            .unwrap();
1979        let diff_doppler = (xs_with_inst[0][idx_dip] - xs_no_inst[0][idx_dip]).abs();
1980        let diff_resolved = (xs_with_inst[0][idx_dip] - sigma_resolved[idx_dip]).abs();
1981
1982        // The Doppler-only values from both paths should be closer to each
1983        // other than to the resolution-broadened value.
1984        assert!(
1985            diff_doppler < diff_resolved,
1986            "broadened_cross_sections with instrument should return Doppler-only σ, \
1987             not resolution-broadened σ.  \
1988             diff(with_inst, no_inst) = {diff_doppler}, \
1989             diff(with_inst, resolved) = {diff_resolved}"
1990        );
1991    }
1992
1993    /// Issue #442 containment: `broadened_cross_sections_from_base()` must
1994    /// return Doppler-only σ even when `instrument` is `Some`.
1995    #[test]
1996    fn test_broadened_from_base_is_doppler_only_with_instrument() {
1997        let data = u238_single_resonance();
1998        let temperature = 300.0;
1999        let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
2000
2001        let inst = InstrumentParams {
2002            resolution: resolution::ResolutionFunction::Gaussian(
2003                resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2004            ),
2005        };
2006
2007        let base_xs =
2008            unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
2009
2010        // With instrument.
2011        let xs_with_inst = broadened_cross_sections_from_base(
2012            &energies,
2013            &base_xs,
2014            std::slice::from_ref(&data),
2015            temperature,
2016            Some(&inst),
2017        )
2018        .unwrap();
2019
2020        // Without instrument.
2021        let xs_no_inst = broadened_cross_sections_from_base(
2022            &energies,
2023            &base_xs,
2024            std::slice::from_ref(&data),
2025            temperature,
2026            None,
2027        )
2028        .unwrap();
2029
2030        // Both should produce Doppler-only σ.  With-instrument may use
2031        // extended grid, but the extracted data-grid values should be close.
2032        let sigma_resolved =
2033            resolution::apply_resolution(&energies, &xs_no_inst[0], &inst.resolution).unwrap();
2034
2035        let idx_dip = energies
2036            .iter()
2037            .position(|&e| (e - 6.674).abs() < 0.05)
2038            .unwrap();
2039        let diff_doppler = (xs_with_inst[0][idx_dip] - xs_no_inst[0][idx_dip]).abs();
2040        let diff_resolved = (xs_with_inst[0][idx_dip] - sigma_resolved[idx_dip]).abs();
2041
2042        assert!(
2043            diff_doppler < diff_resolved,
2044            "broadened_cross_sections_from_base with instrument should return Doppler-only σ. \
2045             diff(with_inst, no_inst) = {diff_doppler}, \
2046             diff(with_inst, resolved) = {diff_resolved}"
2047        );
2048    }
2049
2050    // ── Issue #442 Step 5: forward_model_from_base_xs resolution ordering ──
2051
2052    /// Issue #442 Step 5: `forward_model_from_base_xs()` with resolution must
2053    /// match `forward_model()` with resolution for the same sample.
2054    #[test]
2055    fn test_forward_model_from_base_xs_matches_forward_model_with_resolution() {
2056        let data = u238_single_resonance();
2057        let thickness = 0.0005;
2058        let temperature = 300.0;
2059        let energies: Vec<f64> = (0..401).map(|i| 4.0 + (i as f64) * 0.015).collect();
2060
2061        let inst = InstrumentParams {
2062            resolution: resolution::ResolutionFunction::Gaussian(
2063                resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2064            ),
2065        };
2066
2067        // Reference: forward_model() (fixed in Step 1).
2068        let sample = SampleParams::new(temperature, vec![(data.clone(), thickness)]).unwrap();
2069        let t_ref = forward_model(&energies, &sample, Some(&inst)).unwrap();
2070
2071        // Base-XS path: unbroadened → forward_model_from_base_xs with resolution.
2072        let base_xs =
2073            unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
2074        let t_base = forward_model_from_base_xs(
2075            &energies,
2076            &base_xs,
2077            std::slice::from_ref(&data),
2078            &[thickness],
2079            temperature,
2080            Some(&inst),
2081        )
2082        .unwrap();
2083
2084        // Both now run the IDENTICAL pipeline on the auxiliary extended grid
2085        // (Doppler → Beer-Lambert → resolution, data points extracted last), so
2086        // they must agree to floating-point round-off.  The cached path reuses
2087        // the data-grid base XS, which is bit-identical to forward_model's fresh
2088        // evaluation at those points; the auxiliary-only points are evaluated the
2089        // same way in both.  (Before Fix #7 the cached path applied resolution on
2090        // the coarse data grid and the two differed by ~1%.)
2091        let interior = 20..energies.len() - 20;
2092        let mut max_err = 0.0f64;
2093        for i in interior.clone() {
2094            max_err = max_err.max((t_ref[i] - t_base[i]).abs());
2095        }
2096        assert!(
2097            max_err < 1e-12,
2098            "forward_model_from_base_xs with resolution must match forward_model \
2099             to round-off (identical aux-grid pipeline).  Max error = {max_err}"
2100        );
2101
2102        // Verify resolution actually made a difference (not a vacuous test).
2103        let t_no_res = forward_model_from_base_xs(
2104            &energies,
2105            &base_xs,
2106            std::slice::from_ref(&data),
2107            &[thickness],
2108            temperature,
2109            None,
2110        )
2111        .unwrap();
2112        let res_diff: f64 = interior
2113            .map(|i| (t_base[i] - t_no_res[i]).abs())
2114            .fold(0.0f64, f64::max);
2115        assert!(
2116            res_diff > 1e-4,
2117            "Resolution should make a measurable difference, but max diff = {res_diff}"
2118        );
2119    }
2120
2121    /// Issue #442 Step 5: `forward_model_from_base_xs()` without resolution
2122    /// must remain unchanged (matches existing no-resolution test).
2123    #[test]
2124    fn test_forward_model_from_base_xs_no_resolution_unchanged() {
2125        let data = u238_single_resonance();
2126        let thickness = 0.0005;
2127        let temperature = 300.0;
2128        let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
2129
2130        let sample = SampleParams::new(temperature, vec![(data.clone(), thickness)]).unwrap();
2131        let t_ref = forward_model(&energies, &sample, None).unwrap();
2132
2133        let base_xs =
2134            unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
2135        let t_base = forward_model_from_base_xs(
2136            &energies,
2137            &base_xs,
2138            std::slice::from_ref(&data),
2139            &[thickness],
2140            temperature,
2141            None,
2142        )
2143        .unwrap();
2144
2145        for (i, (&r, &b)) in t_ref.iter().zip(t_base.iter()).enumerate() {
2146            assert!(
2147                (r - b).abs() < 1e-12,
2148                "No-resolution mismatch at E[{i}]={}: ref={r}, base={b}",
2149                energies[i]
2150            );
2151        }
2152    }
2153
2154    // ── Issue #442 Step 7: derivative helper containment ───────────────────
2155
2156    /// Issue #442 Step 7: `broadened_cross_sections_with_analytical_derivative_from_base()`
2157    /// must return Doppler-only σ and Doppler-only ∂σ/∂T even when instrument
2158    /// is present.  Resolution broadening must NOT be applied inside.
2159    #[test]
2160    fn test_derivative_helper_is_doppler_only_with_instrument() {
2161        let data = u238_single_resonance();
2162        let temperature = 300.0;
2163        let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
2164
2165        let inst = InstrumentParams {
2166            resolution: resolution::ResolutionFunction::Gaussian(
2167                resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2168            ),
2169        };
2170
2171        let base_xs =
2172            unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
2173
2174        // With instrument.
2175        let (xs_inst, dxs_inst) = broadened_cross_sections_with_analytical_derivative_from_base(
2176            &energies,
2177            &base_xs,
2178            std::slice::from_ref(&data),
2179            temperature,
2180            Some(&inst),
2181        )
2182        .unwrap();
2183
2184        // Without instrument.
2185        let (xs_none, dxs_none) = broadened_cross_sections_with_analytical_derivative_from_base(
2186            &energies,
2187            &base_xs,
2188            std::slice::from_ref(&data),
2189            temperature,
2190            None,
2191        )
2192        .unwrap();
2193
2194        assert_eq!(xs_inst.len(), 1);
2195        assert_eq!(dxs_inst.len(), 1);
2196
2197        // Both should return Doppler-only.  Compute what resolution-broadened
2198        // σ would look like, and verify the with-instrument result is closer
2199        // to the no-instrument result than to the resolved version.
2200        let sigma_resolved =
2201            resolution::apply_resolution(&energies, &xs_none[0], &inst.resolution).unwrap();
2202
2203        let idx_dip = energies
2204            .iter()
2205            .position(|&e| (e - 6.674).abs() < 0.05)
2206            .unwrap();
2207
2208        // σ check: with-instrument should be close to no-instrument (Doppler-only),
2209        // not to the resolution-broadened version.
2210        let diff_doppler = (xs_inst[0][idx_dip] - xs_none[0][idx_dip]).abs();
2211        let diff_resolved = (xs_inst[0][idx_dip] - sigma_resolved[idx_dip]).abs();
2212        assert!(
2213            diff_doppler < diff_resolved,
2214            "derivative helper σ with instrument should be Doppler-only. \
2215             diff(inst, none) = {diff_doppler}, diff(inst, resolved) = {diff_resolved}"
2216        );
2217
2218        // ∂σ/∂T check: same pattern — should be Doppler-only derivative.
2219        let dxs_resolved =
2220            resolution::apply_resolution(&energies, &dxs_none[0], &inst.resolution).unwrap();
2221        let ddiff_doppler = (dxs_inst[0][idx_dip] - dxs_none[0][idx_dip]).abs();
2222        let ddiff_resolved = (dxs_inst[0][idx_dip] - dxs_resolved[idx_dip]).abs();
2223        assert!(
2224            ddiff_doppler < ddiff_resolved,
2225            "derivative helper ∂σ/∂T with instrument should be Doppler-only. \
2226             diff(inst, none) = {ddiff_doppler}, diff(inst, resolved) = {ddiff_resolved}"
2227        );
2228    }
2229
2230    /// Issue #442 Step 7: derivative helper without resolution must be
2231    /// unchanged — same σ and ∂σ/∂T as before.
2232    #[test]
2233    fn test_derivative_helper_no_resolution_unchanged() {
2234        let data = u238_single_resonance();
2235        let temperature = 300.0;
2236        let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
2237
2238        let base_xs =
2239            unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
2240
2241        let (xs, dxs) = broadened_cross_sections_with_analytical_derivative_from_base(
2242            &energies,
2243            &base_xs,
2244            std::slice::from_ref(&data),
2245            temperature,
2246            None,
2247        )
2248        .unwrap();
2249
2250        // σ should match broadened_cross_sections (no instrument).
2251        let xs_ref = broadened_cross_sections(
2252            &energies,
2253            std::slice::from_ref(&data),
2254            temperature,
2255            None,
2256            None,
2257        )
2258        .unwrap();
2259
2260        for (i, (&a, &b)) in xs[0].iter().zip(xs_ref[0].iter()).enumerate() {
2261            assert!(
2262                (a - b).abs() < 1e-12,
2263                "σ mismatch at E[{i}]: derivative_helper={a}, broadened={b}"
2264            );
2265        }
2266
2267        // ∂σ/∂T should be finite and non-trivial near resonance.
2268        assert_eq!(dxs.len(), 1);
2269        assert_eq!(dxs[0].len(), energies.len());
2270        let idx_res = energies
2271            .iter()
2272            .position(|&e| (e - 6.674).abs() < 0.05)
2273            .unwrap();
2274        assert!(
2275            dxs[0][idx_res].abs() > 0.0,
2276            "∂σ/∂T should be non-zero near resonance"
2277        );
2278        for &d in &dxs[0] {
2279            assert!(d.is_finite(), "∂σ/∂T must be finite");
2280        }
2281    }
2282
2283    // ── Fix #8: broadened_cross_sections_for_transmission thickness guard ──
2284
2285    /// `broadened_cross_sections_for_transmission` divides by the thickness in
2286    /// the σ_eff = −ln(T)/nd inversion, so a non-positive or non-finite
2287    /// thickness must be rejected up-front (the docstring requires nd > 0).
2288    #[test]
2289    fn test_broadened_for_transmission_rejects_bad_thickness() {
2290        let data = u238_single_resonance();
2291        let energies: Vec<f64> = (0..51).map(|i| 5.0 + (i as f64) * 0.1).collect();
2292        let inst = InstrumentParams {
2293            resolution: resolution::ResolutionFunction::Gaussian(
2294                resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2295            ),
2296        };
2297
2298        for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
2299            let err = broadened_cross_sections_for_transmission(
2300                &energies,
2301                std::slice::from_ref(&data),
2302                300.0,
2303                &inst,
2304                bad,
2305                None,
2306            )
2307            .unwrap_err();
2308            assert!(
2309                matches!(err, TransmissionError::InputMismatch(_)),
2310                "thickness = {bad} should be rejected with InputMismatch, got {err:?}"
2311            );
2312        }
2313
2314        // A valid positive thickness still succeeds.
2315        let ok = broadened_cross_sections_for_transmission(
2316            &energies,
2317            std::slice::from_ref(&data),
2318            300.0,
2319            &inst,
2320            0.001,
2321            None,
2322        );
2323        assert!(ok.is_ok(), "valid thickness should succeed: {ok:?}");
2324    }
2325
2326    /// Issue #608: the working-grid `*_from_base` path's
2327    /// input validation — `validate_base_xs` shape errors and the unsorted-grid
2328    /// guard.  These error branches were untested (the temperature fit always
2329    /// supplies well-formed, sorted inputs).
2330    #[test]
2331    fn from_base_working_grid_rejects_malformed_inputs() {
2332        let rd = vec![u238_single_resonance()];
2333        let energies: Vec<f64> = (0..21).map(|i| 1.0 + (i as f64) * 0.1).collect();
2334        let n_e = energies.len();
2335        let good_base = vec![vec![10.0f64; n_e]];
2336        // base_xs isotope-count mismatch (2 rows vs 1 isotope).
2337        let e1 = broadened_cross_sections_with_analytical_derivative_from_base(
2338            &energies,
2339            &[vec![10.0; n_e], vec![10.0; n_e]],
2340            &rd,
2341            300.0,
2342            None,
2343        )
2344        .unwrap_err();
2345        assert!(e1.to_string().contains("isotopes"), "got: {e1}");
2346        // base_xs row-length mismatch.
2347        let e2 = broadened_cross_sections_with_analytical_derivative_from_base(
2348            &energies,
2349            &[vec![10.0; n_e - 1]],
2350            &rd,
2351            300.0,
2352            None,
2353        )
2354        .unwrap_err();
2355        assert!(e2.to_string().contains("energies"), "got: {e2}");
2356        // Unsorted energies + an instrument ⇒ the sorted-grid guard fires.
2357        let inst = InstrumentParams {
2358            resolution: resolution::ResolutionFunction::Gaussian(
2359                resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2360            ),
2361        };
2362        let mut unsorted = energies.clone();
2363        unsorted.swap(0, 1);
2364        let e3 = broadened_cross_sections_with_analytical_derivative_from_base(
2365            &unsorted,
2366            &good_base,
2367            &rd,
2368            300.0,
2369            Some(&inst),
2370        )
2371        .unwrap_err();
2372        assert!(e3.to_string().contains("sorted"), "got: {e3}");
2373    }
2374}