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 2 (transmission definition), Section 5 (experimental corrections)
24
25use std::fmt;
26use std::sync::atomic::{AtomicBool, Ordering};
27
28use rayon::prelude::*;
29
30use nereids_endf::resonance::ResonanceData;
31
32use crate::doppler::{self, DopplerParams, DopplerParamsError};
33use crate::reich_moore;
34use crate::resolution::{self, ResolutionError, ResolutionFunction};
35
36/// Build the auxiliary extended grid for resolution broadening.
37///
38/// Shared helper that extracts Gaussian resolution params and resonance info
39/// to build the extended grid with boundary extension + adaptive intermediate
40/// points.  Returns `None` if no extension is needed (no resolution, or grid
41/// unchanged).
42///
43/// Intermediate points are inserted only when the resolution broadening at
44/// the grid midpoint uses the PW-linear Gaussian path (exp tail negligible
45/// or absent).  For genuine combined-kernel cases, intermediates create
46/// non-uniform spacing transitions that degrade the Xcoef quadrature.
47fn build_aux_grid(
48    energies: &[f64],
49    instrument: Option<&InstrumentParams>,
50    resonance_data: &[&ResonanceData],
51) -> Option<(Vec<f64>, Vec<usize>)> {
52    instrument.and_then(|inst| {
53        if let ResolutionFunction::Gaussian(ref params) = inst.resolution {
54            // P-9: Check the Gaussian-to-exp-tail ratio at MULTIPLE energies
55            // to decide whether intermediates help or hurt.  The ratio C =
56            // W_g/(2·W_e) determines which broadening path is used per-energy
57            // in resolution_broaden_presorted.  When C > 2.5 the PW-linear
58            // Gaussian path is used, which benefits from intermediates.
59            //
60            // Previously checked at a single midpoint, which could make the
61            // wrong decision if the ratio crosses 2.5 within the energy range.
62            // Now checks at 5 points (lo, 25%, mid, 75%, hi) and uses
63            // intermediates if a MAJORITY of points have C > 2.5.
64            let use_intermediates = if energies.len() >= 2 {
65                let n = energies.len();
66                let check_indices = [0, n / 4, n / 2, 3 * n / 4, n - 1];
67                let n_pw_linear = check_indices
68                    .iter()
69                    .filter(|&&i| {
70                        let e = energies[i];
71                        let wg = params.gaussian_width(e);
72                        let we = params.exp_width(e);
73                        we < 1e-60 || wg / (2.0 * we) > 2.5
74                    })
75                    .count();
76                // Majority rule: use intermediates if ≥3 of 5 points qualify.
77                n_pw_linear >= 3
78            } else {
79                true
80            };
81
82            // Extract (energy_eV, gd_eV) pairs for fine-structure densification.
83            // gd = total resonance width, used by Fspken to identify regions
84            // needing denser grid points around narrow resonances.
85            // SAMMY Ref: dat/mdat4.f90 Fspken lines 243-284
86            let resonances = extract_resonance_widths(resonance_data);
87
88            let (ext_e, di) = if use_intermediates {
89                crate::auxiliary_grid::build_extended_grid(energies, Some(params), &resonances)
90            } else {
91                crate::auxiliary_grid::build_extended_grid_boundary_only(energies, Some(params))
92            };
93            if ext_e.len() > energies.len() {
94                Some((ext_e, di))
95            } else {
96                None
97            }
98        } else {
99            None
100        }
101    })
102}
103
104/// Extract (energy_eV, gd_eV) pairs from resonance data for fine-structure
105/// grid densification.
106///
107/// For LRF=1/2/3 (BW and Reich-Moore): `gd = |Γn| + |Γγ| + |Γf1| + |Γf2|`
108/// For LRF=7 (R-Matrix Limited): `gd = |Γγ| + Σ|γ_i|²` (approximate)
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
120            for lg in &range.l_groups {
121                for res in &lg.resonances {
122                    let gd = res.gn.abs() + res.gg.abs() + res.gfa.abs() + res.gfb.abs();
123                    if gd > 0.0 {
124                        pairs.push((res.energy, gd));
125                    }
126                }
127            }
128            // LRF=7: resonances in spin groups
129            if let Some(ref rml) = range.rml {
130                for sg in &rml.spin_groups {
131                    for res in &sg.resonances {
132                        let mut gd = res.gamma_gamma.abs();
133                        for &w in &res.widths {
134                            gd += w * w; // γ² approximates Γ
135                        }
136                        if gd > 0.0 {
137                            pairs.push((res.energy, gd));
138                        }
139                    }
140                }
141            }
142        }
143    }
144    pairs
145}
146
147/// Errors from the transmission forward model.
148#[derive(Debug)]
149pub enum TransmissionError {
150    /// The energy grid is not sorted or has a length mismatch with data.
151    Resolution(ResolutionError),
152    /// Doppler broadening parameter validation failed.
153    Doppler(DopplerParamsError),
154    /// Doppler broadening input validation failed (e.g. length mismatch).
155    DopplerBroadening(crate::doppler::DopplerError),
156    /// Computation was cancelled via the cancel token.
157    Cancelled,
158    /// Input array mismatch (e.g. cross-sections vs thicknesses length).
159    InputMismatch(String),
160}
161
162impl fmt::Display for TransmissionError {
163    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164        match self {
165            Self::Resolution(e) => write!(f, "resolution broadening error: {}", e),
166            Self::Doppler(e) => write!(f, "Doppler parameter error: {}", e),
167            Self::DopplerBroadening(e) => write!(f, "Doppler broadening error: {}", e),
168            Self::Cancelled => write!(f, "computation cancelled"),
169            Self::InputMismatch(msg) => write!(f, "input mismatch: {}", msg),
170        }
171    }
172}
173
174impl std::error::Error for TransmissionError {
175    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
176        match self {
177            Self::Resolution(e) => Some(e),
178            Self::Doppler(e) => Some(e),
179            Self::DopplerBroadening(e) => Some(e),
180            Self::Cancelled => None,
181            Self::InputMismatch(_) => None,
182        }
183    }
184}
185
186impl From<ResolutionError> for TransmissionError {
187    fn from(e: ResolutionError) -> Self {
188        Self::Resolution(e)
189    }
190}
191
192impl From<DopplerParamsError> for TransmissionError {
193    fn from(e: DopplerParamsError) -> Self {
194        Self::Doppler(e)
195    }
196}
197
198impl From<crate::doppler::DopplerError> for TransmissionError {
199    fn from(e: crate::doppler::DopplerError) -> Self {
200        Self::DopplerBroadening(e)
201    }
202}
203
204/// Broadened cross-sections and their temperature derivative.
205///
206/// `xs[k][e]` is the Doppler+resolution-broadened cross-section for isotope
207/// `k` at energy index `e`; `dxs_dt[k][e]` is the analytical derivative
208/// with respect to temperature.
209pub type BroadenedXsWithDerivative = (Vec<Vec<f64>>, Vec<Vec<f64>>);
210
211/// Compute transmission from cross-sections via Beer-Lambert law.
212///
213/// T(E) = exp(-thickness × σ(E))
214///
215/// # Arguments
216/// * `cross_sections` — Total cross-sections in barns at each energy point.
217/// * `thickness` — Areal density in atoms/barn (= number_density × path_length).
218///
219/// # Returns
220/// Transmission values (0 to 1) at each energy point.
221pub fn beer_lambert(cross_sections: &[f64], thickness: f64) -> Vec<f64> {
222    cross_sections
223        .iter()
224        .map(|&sigma| (-thickness * sigma).exp())
225        .collect()
226}
227
228/// Compute transmission for multiple isotopes.
229///
230/// T(E) = exp(-Σᵢ thicknessᵢ × σᵢ(E))
231///
232/// # Arguments
233/// * `cross_sections_per_isotope` — Vec of cross-section arrays, one per isotope.
234///   Each inner slice has the same length as the energy grid.
235/// * `thicknesses` — Areal density (atoms/barn) for each isotope.
236///
237/// # Returns
238/// Combined transmission values at each energy point.
239pub fn beer_lambert_multi(
240    cross_sections_per_isotope: &[&[f64]],
241    thicknesses: &[f64],
242) -> Result<Vec<f64>, TransmissionError> {
243    if cross_sections_per_isotope.len() != thicknesses.len() {
244        return Err(TransmissionError::InputMismatch(format!(
245            "cross_sections_per_isotope length ({}) must match thicknesses length ({})",
246            cross_sections_per_isotope.len(),
247            thicknesses.len()
248        )));
249    }
250    if cross_sections_per_isotope.is_empty() {
251        return Err(TransmissionError::InputMismatch(
252            "cross_sections_per_isotope must not be empty".into(),
253        ));
254    }
255
256    let n_energies = cross_sections_per_isotope[0].len();
257    for (k, sigma) in cross_sections_per_isotope.iter().enumerate() {
258        if sigma.len() != n_energies {
259            return Err(TransmissionError::InputMismatch(format!(
260                "cross_sections_per_isotope[{}] length ({}) must match [0] length ({})",
261                k,
262                sigma.len(),
263                n_energies
264            )));
265        }
266    }
267
268    Ok((0..n_energies)
269        .map(|i| {
270            let total_attenuation: f64 = cross_sections_per_isotope
271                .iter()
272                .zip(thicknesses.iter())
273                .map(|(sigma, &thick)| thick * sigma[i])
274                .sum();
275            (-total_attenuation).exp()
276        })
277        .collect())
278}
279
280/// Errors from `SampleParams` construction.
281#[derive(Debug, PartialEq)]
282pub enum SampleParamsError {
283    /// Temperature must be finite.
284    NonFiniteTemperature(f64),
285    /// Temperature must be non-negative.
286    NegativeTemperature(f64),
287}
288
289impl fmt::Display for SampleParamsError {
290    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
291        match self {
292            Self::NonFiniteTemperature(v) => {
293                write!(f, "temperature must be finite, got {v}")
294            }
295            Self::NegativeTemperature(v) => {
296                write!(f, "temperature must be non-negative, got {v}")
297            }
298        }
299    }
300}
301
302impl std::error::Error for SampleParamsError {}
303
304/// Sample description for the forward model.
305#[derive(Debug, Clone)]
306pub struct SampleParams {
307    /// Temperature in Kelvin (for Doppler broadening).
308    temperature_k: f64,
309    /// Isotope compositions: (resonance data, areal density in atoms/barn).
310    isotopes: Vec<(ResonanceData, f64)>,
311}
312
313impl SampleParams {
314    /// Create validated sample parameters.
315    ///
316    /// # Errors
317    /// Returns `SampleParamsError::NonFiniteTemperature` if `temperature_k` is
318    /// NaN or infinity.
319    /// Returns `SampleParamsError::NegativeTemperature` if `temperature_k < 0.0`.
320    pub fn new(
321        temperature_k: f64,
322        isotopes: Vec<(ResonanceData, f64)>,
323    ) -> Result<Self, SampleParamsError> {
324        if !temperature_k.is_finite() {
325            return Err(SampleParamsError::NonFiniteTemperature(temperature_k));
326        }
327        if temperature_k < 0.0 {
328            return Err(SampleParamsError::NegativeTemperature(temperature_k));
329        }
330        Ok(Self {
331            temperature_k,
332            isotopes,
333        })
334    }
335
336    /// Returns the sample temperature in Kelvin.
337    #[must_use]
338    pub fn temperature_k(&self) -> f64 {
339        self.temperature_k
340    }
341
342    /// Returns the isotope compositions: (resonance data, areal density).
343    #[must_use]
344    pub fn isotopes(&self) -> &[(ResonanceData, f64)] {
345        &self.isotopes
346    }
347}
348
349/// Optional instrument resolution parameters.
350#[derive(Debug, Clone)]
351pub struct InstrumentParams {
352    /// Resolution broadening function (Gaussian or tabulated).
353    pub resolution: ResolutionFunction,
354}
355
356/// Compute a complete theoretical transmission spectrum.
357///
358/// This is the main forward model that chains:
359///   ENDF parameters → cross-sections → Doppler broadening → resolution → transmission
360///
361/// # Arguments
362/// * `energies` — Energy grid in eV (sorted ascending).
363/// * `sample` — Sample parameters (isotopes with areal densities, temperature).
364/// * `instrument` — Optional instrument parameters (resolution broadening).
365///
366/// # Returns
367/// Theoretical transmission spectrum on the energy grid.
368///
369/// # Errors
370/// * [`TransmissionError::Resolution`] — if resolution broadening is
371///   enabled (`instrument` is `Some`) and `energies` is not sorted ascending.
372/// * [`TransmissionError::Doppler`] — if Doppler broadening is enabled
373///   (`temperature_k > 0.0`) and `DopplerParams` validation fails
374///   (e.g., non-positive or non-finite AWR).
375///
376/// **Note**: isotopes with thickness <= 0.0 are silently skipped
377/// (they contribute zero attenuation). This allows callers to include
378/// inactive isotopes in `SampleParams` without causing errors.
379pub fn forward_model(
380    energies: &[f64],
381    sample: &SampleParams,
382    instrument: Option<&InstrumentParams>,
383) -> Result<Vec<f64>, TransmissionError> {
384    let n = energies.len();
385    if n == 0 {
386        return Ok(vec![]);
387    }
388
389    // Validate energy grid once before the per-isotope loop so that
390    // resolution broadening can use the presorted (unchecked) path,
391    // avoiding redundant O(N) sort checks per isotope.
392    if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
393        return Err(ResolutionError::UnsortedEnergies.into());
394    }
395
396    // Build auxiliary grid with boundary extension + resonance fine-structure.
397    // Collect references to avoid cloning full ResonanceData structs.
398    // SAMMY Ref: dat/mdat4.f90 Escale, Fspken, Add_Pnts
399    let active_rd: Vec<&ResonanceData> = sample
400        .isotopes()
401        .iter()
402        .filter(|(_, t)| *t > 0.0)
403        .map(|(rd, _)| rd)
404        .collect();
405    let ext_grid = build_aux_grid(energies, instrument, &active_rd);
406
407    // Compute Doppler-broadened cross-sections for all isotopes in parallel.
408    // Resolution is NOT applied here — it must be applied after Beer-Lambert
409    // on the total transmission.
410    //
411    // SAMMY Ref: DopplerAndResolutionBroadener.cpp — resolution broadening is
412    // applied to T(E), not to σ(E).  Due to Jensen's inequality (exp is
413    // convex), broadening σ before the nonlinear Beer-Lambert systematically
414    // overestimates effective cross-sections at resonance peaks.
415    //
416    // Correct pipeline:
417    //   1. Per-isotope: σ → Doppler → σ_D   (on working grid)
418    //   2. Accumulate:  attenuation = Σᵢ nᵢ·σ_{D,i}
419    //   3. Beer-Lambert: T = exp(−attenuation)
420    //   4. Resolution:  T_broad = R ⊗ T     (on working grid)
421    //   5. Extract at data positions
422    //
423    // The working grid is the extended grid (with boundary+fine-structure
424    // points) when available, otherwise the data grid.
425
426    // Determine working grid: extended grid for resolution boundary handling,
427    // or the data grid when no extension was needed.
428    let (work_energies, work_len): (&[f64], usize) = if let Some((ref ext_e, _)) = ext_grid {
429        (ext_e.as_slice(), ext_e.len())
430    } else {
431        (energies, n)
432    };
433
434    let doppler_xs: Result<Vec<(Vec<f64>, f64)>, TransmissionError> = sample
435        .isotopes()
436        .par_iter()
437        .filter(|(_, thickness)| *thickness > 0.0)
438        .map(|(res_data, thickness)| {
439            let unbroadened: Vec<f64> = work_energies
440                .iter()
441                .map(|&e| reich_moore::cross_sections_at_energy(res_data, e).total)
442                .collect();
443            let after_doppler = if sample.temperature_k() > 0.0 {
444                let params = DopplerParams::new(sample.temperature_k(), res_data.awr)?;
445                doppler::doppler_broaden(work_energies, &unbroadened, &params)?
446            } else {
447                unbroadened
448            };
449            Ok((after_doppler, *thickness))
450        })
451        .collect();
452    let doppler_xs = doppler_xs?;
453
454    // 4. Accumulate total attenuation: Σᵢ thicknessᵢ × σ_{D,i}(E)
455    let mut total_attenuation = vec![0.0f64; work_len];
456    for (xs, thickness) in &doppler_xs {
457        for i in 0..work_len {
458            total_attenuation[i] += thickness * xs[i];
459        }
460    }
461
462    // 5. Beer-Lambert: T = exp(−attenuation)
463    let transmission: Vec<f64> = total_attenuation.iter().map(|&att| (-att).exp()).collect();
464
465    // 6. Resolution broadening on total transmission, then extract at data positions.
466    if let Some(inst) = instrument {
467        let t_broadened =
468            resolution::apply_resolution_presorted(work_energies, &transmission, &inst.resolution);
469        if let Some((_, ref data_indices)) = ext_grid {
470            Ok(data_indices.iter().map(|&i| t_broadened[i]).collect())
471        } else {
472            Ok(t_broadened)
473        }
474    } else {
475        Ok(transmission)
476    }
477}
478
479/// Compute Doppler-broadened cross-sections for each isotope.
480///
481/// Returns **Doppler-only** cross-sections.  Resolution broadening is NOT
482/// applied here because it must be applied after Beer-Lambert on the total
483/// transmission for physically correct results (issue #442).
484///
485/// When `instrument` is `Some`, the auxiliary extended grid is still
486/// constructed for Doppler boundary accuracy, but the resolution
487/// convolution is not performed.
488///
489/// This is the expensive physics step that should be done **once** before
490/// fitting many pixels with the same isotopes and energy grid.  The result
491/// feeds into `nereids_fitting::transmission_model::PrecomputedTransmissionModel`,
492/// which currently applies Beer-Lambert only.  Post-Beer-Lambert resolution
493/// broadening per-pixel is not yet implemented (issue #442 Step 3).
494///
495/// # Arguments
496/// * `energies`        — Energy grid in eV (sorted ascending).
497/// * `resonance_data`  — Resonance parameters for each isotope.
498/// * `temperature_k`   — Sample temperature for Doppler broadening.
499/// * `instrument`      — Optional instrument resolution parameters.
500///   Used only for auxiliary grid construction (Doppler boundary accuracy).
501///   Resolution broadening is NOT applied.
502/// * `cancel`          — Optional cancellation token.  Cancellation is checked
503///   at the start of each isotope's parallel task; in-flight tasks run to
504///   completion (consistent with the rayon pattern in `spatial.rs`).
505///
506/// # Returns
507/// One cross-section vector per isotope on success.
508///
509/// # Errors
510/// * [`TransmissionError::Cancelled`] — if the `cancel` flag was observed
511///   during parallel execution (either before an isotope started or after
512///   all tasks completed).
513/// * [`TransmissionError::Resolution`] — if `instrument` is `Some` and
514///   `energies` is not sorted ascending.
515/// * [`TransmissionError::Doppler`] — if Doppler broadening is enabled
516///   (`temperature_k > 0.0`) and `DopplerParams` validation fails
517///   (e.g., non-positive or non-finite AWR).
518pub fn broadened_cross_sections(
519    energies: &[f64],
520    resonance_data: &[ResonanceData],
521    temperature_k: f64,
522    instrument: Option<&InstrumentParams>,
523    cancel: Option<&AtomicBool>,
524) -> Result<Vec<Vec<f64>>, TransmissionError> {
525    // Validate energy grid once before the per-isotope loop.
526    if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
527        return Err(ResolutionError::UnsortedEnergies.into());
528    }
529
530    // Build auxiliary grid with boundary extension + resonance fine-structure.
531    // SAMMY extends the energy grid beyond the data range and adds dense points
532    // around narrow resonances so the broadening convolution integrals have
533    // adequate quadrature points.
534    // SAMMY Ref: dat/mdat4.f90 Escale+Fspken+Add_Pnts, dat/mdata.f90 Vqcon
535    let rd_refs: Vec<&ResonanceData> = resonance_data.iter().collect();
536    let ext_grid = build_aux_grid(energies, instrument, &rd_refs);
537
538    // Parallelize across isotopes — Doppler broadening for each isotope is
539    // independent.  Resolution is NOT applied here (issue #442: resolution
540    // must be applied after Beer-Lambert on total transmission).
541    // Cancellation is checked per-isotope inside the parallel map.
542    let result: Result<Vec<Vec<f64>>, TransmissionError> = resonance_data
543        .par_iter()
544        .map(|rd| {
545            // Check cancellation before starting this isotope.
546            if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
547                return Err(TransmissionError::Cancelled);
548            }
549
550            // When an extended grid is available, evaluate XS + Doppler on
551            // the extended grid, then extract at data positions.  The
552            // boundary extension improves Doppler broadening accuracy near
553            // grid edges and narrow resonances.
554            let xs = if let Some((ref ext_energies, ref data_indices)) = ext_grid {
555                let unbroadened: Vec<f64> = ext_energies
556                    .iter()
557                    .map(|&e| reich_moore::cross_sections_at_energy(rd, e).total)
558                    .collect();
559                let after_doppler = if temperature_k > 0.0 {
560                    let params = DopplerParams::new(temperature_k, rd.awr)?;
561                    doppler::doppler_broaden(ext_energies, &unbroadened, &params)?
562                } else {
563                    unbroadened
564                };
565                data_indices.iter().map(|&i| after_doppler[i]).collect()
566            } else {
567                // No extended grid: Doppler on data grid only.
568                let unbroadened: Vec<f64> = energies
569                    .iter()
570                    .map(|&e| reich_moore::cross_sections_at_energy(rd, e).total)
571                    .collect();
572                if temperature_k > 0.0 {
573                    let params = DopplerParams::new(temperature_k, rd.awr)?;
574                    doppler::doppler_broaden(energies, &unbroadened, &params)?
575                } else {
576                    unbroadened
577                }
578            };
579
580            Ok(xs)
581        })
582        .collect();
583
584    // Final cancellation check: if cancel was set during parallel execution,
585    // some tasks may have completed before observing it.
586    if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
587        return Err(TransmissionError::Cancelled);
588    }
589
590    result
591}
592
593/// Compute Doppler+resolution-broadened cross-sections using SAMMY's
594/// Beer-Lambert-aware pipeline for transmission data.
595///
596/// For transmission data, SAMMY applies resolution broadening to the
597/// transmission T = exp(-nd×σ_D) rather than to σ_D directly.  Due to
598/// Jensen's inequality (the exponential is convex), direct σ broadening
599/// overestimates the effective cross section at resonance peaks.  This
600/// function implements SAMMY's correct pipeline:
601///
602/// 1. Evaluate unbroadened σ on extended grid
603/// 2. Doppler-broaden σ → σ_D
604/// 3. Convert to transmission: T = exp(-nd × σ_D)
605/// 4. Resolution-broaden T → T_broadened
606/// 5. Convert back: σ_eff = -ln(T_broadened) / nd
607///
608/// SAMMY Ref: DopplerAndResolutionBroadener.cpp — resolution broadening is
609/// applied after Beer-Lambert conversion in the SAMMY pipeline.
610///
611/// # Arguments
612/// * `energies`             — Energy grid in eV (sorted ascending).
613/// * `resonance_data`       — Resonance parameters for each isotope.
614/// * `temperature_k`        — Sample temperature for Doppler broadening.
615/// * `instrument`           — Instrument resolution parameters.
616/// * `thickness_atoms_barn`  — Sample thickness n×d (atoms/barn).  Must be > 0.
617/// * `cancel`               — Optional cancellation token.
618///
619/// # Returns
620/// One effective cross-section vector per isotope on success.
621pub fn broadened_cross_sections_for_transmission(
622    energies: &[f64],
623    resonance_data: &[ResonanceData],
624    temperature_k: f64,
625    instrument: &InstrumentParams,
626    thickness_atoms_barn: f64,
627    cancel: Option<&AtomicBool>,
628) -> Result<Vec<Vec<f64>>, TransmissionError> {
629    if !energies.windows(2).all(|w| w[0] <= w[1]) {
630        return Err(ResolutionError::UnsortedEnergies.into());
631    }
632
633    let rd_refs: Vec<&ResonanceData> = resonance_data.iter().collect();
634    let ext_grid = build_aux_grid(energies, Some(instrument), &rd_refs);
635    let nd = thickness_atoms_barn;
636
637    let result: Result<Vec<Vec<f64>>, TransmissionError> = resonance_data
638        .par_iter()
639        .map(|rd| {
640            if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
641                return Err(TransmissionError::Cancelled);
642            }
643
644            let sigma_eff = if let Some((ref ext_energies, ref data_indices)) = ext_grid {
645                // Extended grid available: evaluate the full pipeline on the
646                // extended grid and extract at data positions.
647
648                // 1. Unbroadened cross sections on extended grid.
649                let unbroadened: Vec<f64> = ext_energies
650                    .iter()
651                    .map(|&e| reich_moore::cross_sections_at_energy(rd, e).total)
652                    .collect();
653
654                // 2. Doppler broadening.
655                let after_doppler = if temperature_k > 0.0 {
656                    let params = DopplerParams::new(temperature_k, rd.awr)?;
657                    doppler::doppler_broaden(ext_energies, &unbroadened, &params)?
658                } else {
659                    unbroadened
660                };
661
662                // 3. Convert to transmission: T = exp(-nd × σ_D).
663                let transmission: Vec<f64> = after_doppler
664                    .iter()
665                    .map(|&sigma| (-nd * sigma).exp())
666                    .collect();
667
668                // 4. Resolution-broaden T.
669                let t_broadened = resolution::apply_resolution_presorted(
670                    ext_energies,
671                    &transmission,
672                    &instrument.resolution,
673                );
674
675                // 5. Convert back to effective σ: σ_eff = -ln(T_broad) / nd.
676                data_indices
677                    .iter()
678                    .map(|&i| {
679                        let t = t_broadened[i].clamp(1e-30, 1.0);
680                        -t.ln() / nd
681                    })
682                    .collect()
683            } else {
684                // No extended grid (e.g. tabulated resolution with no aux grid):
685                // Doppler on data grid, Beer-Lambert, resolution on data grid.
686                let unbroadened: Vec<f64> = energies
687                    .iter()
688                    .map(|&e| reich_moore::cross_sections_at_energy(rd, e).total)
689                    .collect();
690
691                let after_doppler = if temperature_k > 0.0 {
692                    let params = DopplerParams::new(temperature_k, rd.awr)?;
693                    doppler::doppler_broaden(energies, &unbroadened, &params)?
694                } else {
695                    unbroadened
696                };
697
698                let transmission: Vec<f64> = after_doppler
699                    .iter()
700                    .map(|&sigma| (-nd * sigma).exp())
701                    .collect();
702
703                let t_broadened = resolution::apply_resolution_presorted(
704                    energies,
705                    &transmission,
706                    &instrument.resolution,
707                );
708
709                t_broadened
710                    .iter()
711                    .map(|&t| {
712                        let t_clamped = t.clamp(1e-30, 1.0);
713                        -t_clamped.ln() / nd
714                    })
715                    .collect()
716            };
717
718            Ok(sigma_eff)
719        })
720        .collect();
721
722    if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
723        return Err(TransmissionError::Cancelled);
724    }
725
726    result
727}
728
729/// Compute unbroadened (raw Reich-Moore) cross-sections for each isotope.
730///
731/// This is the temperature-independent first step of the forward model.
732/// The result can be cached and reused across multiple temperature evaluations
733/// (e.g., during LM iterations where temperature is a free parameter).
734///
735/// # Returns
736/// One total cross-section vector per isotope: `result[k][e]` is the
737/// unbroadened total cross-section (barns) for isotope `k` at energy `e`.
738pub fn unbroadened_cross_sections(
739    energies: &[f64],
740    resonance_data: &[ResonanceData],
741    cancel: Option<&AtomicBool>,
742) -> Result<Vec<Vec<f64>>, TransmissionError> {
743    let result: Result<Vec<Vec<f64>>, TransmissionError> = resonance_data
744        .par_iter()
745        .map(|rd| {
746            if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
747                return Err(TransmissionError::Cancelled);
748            }
749            let xs: Vec<f64> = energies
750                .iter()
751                .map(|&e| reich_moore::cross_sections_at_energy(rd, e).total)
752                .collect();
753            Ok(xs)
754        })
755        .collect();
756
757    if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
758        return Err(TransmissionError::Cancelled);
759    }
760    result
761}
762
763/// Compute Doppler-broadened cross-sections from precomputed unbroadened
764/// cross-sections.
765///
766/// Returns **Doppler-only** cross-sections.  Resolution broadening is NOT
767/// applied (issue #442: must be applied after Beer-Lambert on total T).
768///
769/// Like [`broadened_cross_sections`] but skips the expensive Reich-Moore
770/// calculation (step 1). Use [`unbroadened_cross_sections`] to compute
771/// `base_xs` once, then call this function repeatedly with different
772/// temperatures.
773pub fn broadened_cross_sections_from_base(
774    energies: &[f64],
775    base_xs: &[Vec<f64>],
776    resonance_data: &[ResonanceData],
777    temperature_k: f64,
778    instrument: Option<&InstrumentParams>,
779) -> Result<Vec<Vec<f64>>, TransmissionError> {
780    if base_xs.len() != resonance_data.len() {
781        return Err(TransmissionError::InputMismatch(format!(
782            "base_xs has {} isotopes but resonance_data has {}",
783            base_xs.len(),
784            resonance_data.len(),
785        )));
786    }
787    for (i, row) in base_xs.iter().enumerate() {
788        if row.len() != energies.len() {
789            return Err(TransmissionError::InputMismatch(format!(
790                "base_xs[{i}] has {} energies but expected {}",
791                row.len(),
792                energies.len(),
793            )));
794        }
795    }
796    if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
797        return Err(ResolutionError::UnsortedEnergies.into());
798    }
799
800    // Build auxiliary grid with boundary extension + resonance fine-structure.
801    // base_xs is on the data grid; we extend it to the aux grid by evaluating
802    // cross-sections at the auxiliary-only points (cheap: only the few hundred
803    // extra points, not the full grid).
804    // SAMMY Ref: dat/mdat4.f90 Escale+Fspken+Add_Pnts
805    let rd_refs: Vec<&ResonanceData> = resonance_data.iter().collect();
806    let ext_grid = build_aux_grid(energies, instrument, &rd_refs);
807
808    // Build a bool mask to identify data-grid positions in the extended grid.
809    let is_data_point: Option<Vec<bool>> = ext_grid.as_ref().map(|(ext_e, di)| {
810        let mut mask = vec![false; ext_e.len()];
811        for &idx in di {
812            mask[idx] = true;
813        }
814        mask
815    });
816
817    // Resolution is NOT applied (issue #442).  Doppler broadening only.
818    base_xs
819        .par_iter()
820        .zip(resonance_data.par_iter())
821        .map(|(xs_raw, rd)| {
822            if let Some((ref ext_energies, ref data_indices)) = ext_grid {
823                let mask = is_data_point.as_ref().unwrap();
824
825                // Build extended XS: copy cached data-grid values, evaluate new points.
826                let mut xs_ext = vec![0.0f64; ext_energies.len()];
827                for (data_i, &ext_i) in data_indices.iter().enumerate() {
828                    xs_ext[ext_i] = xs_raw[data_i];
829                }
830                for (j, &e) in ext_energies.iter().enumerate() {
831                    if !mask[j] {
832                        xs_ext[j] = reich_moore::cross_sections_at_energy(rd, e).total;
833                    }
834                }
835
836                let after_doppler = if temperature_k > 0.0 {
837                    let params = DopplerParams::new(temperature_k, rd.awr)?;
838                    doppler::doppler_broaden(ext_energies, &xs_ext, &params)?
839                } else {
840                    xs_ext
841                };
842                Ok(data_indices.iter().map(|&i| after_doppler[i]).collect())
843            } else {
844                // No auxiliary grid — Doppler on data grid only.
845                let after_doppler = if temperature_k > 0.0 {
846                    let params = DopplerParams::new(temperature_k, rd.awr)?;
847                    doppler::doppler_broaden(energies, xs_raw, &params)?
848                } else {
849                    xs_raw.clone()
850                };
851                Ok(after_doppler)
852            }
853        })
854        .collect()
855}
856
857/// Compute Doppler-broadened cross-sections and their **analytical**
858/// temperature derivative from precomputed unbroadened cross-sections.
859///
860/// Returns **Doppler-only** cross-sections and derivatives.  Resolution
861/// broadening is NOT applied (issue #442: must be applied after
862/// Beer-Lambert on total T).
863///
864/// Uses `doppler_broaden_with_derivative` for exact ∂σ/∂T in a single pass
865/// (1× broadening), replacing the 3× FD approach.
866///
867/// Returns `BroadenedXsWithDerivative`: `(sigma_k, dsigma_k_dT)`.
868pub fn broadened_cross_sections_with_analytical_derivative_from_base(
869    energies: &[f64],
870    base_xs: &[Vec<f64>],
871    resonance_data: &[ResonanceData],
872    temperature_k: f64,
873    instrument: Option<&InstrumentParams>,
874) -> Result<BroadenedXsWithDerivative, TransmissionError> {
875    if base_xs.len() != resonance_data.len() {
876        return Err(TransmissionError::InputMismatch(format!(
877            "base_xs has {} isotopes but resonance_data has {}",
878            base_xs.len(),
879            resonance_data.len(),
880        )));
881    }
882    for (i, row) in base_xs.iter().enumerate() {
883        if row.len() != energies.len() {
884            return Err(TransmissionError::InputMismatch(format!(
885                "base_xs[{i}] has {} energies but expected {}",
886                row.len(),
887                energies.len(),
888            )));
889        }
890    }
891    if instrument.is_some() && !energies.windows(2).all(|w| w[0] <= w[1]) {
892        return Err(ResolutionError::UnsortedEnergies.into());
893    }
894
895    // Build auxiliary grid (same as broadened_cross_sections_from_base).
896    let rd_refs: Vec<&ResonanceData> = resonance_data.iter().collect();
897    let ext_grid = build_aux_grid(energies, instrument, &rd_refs);
898    let is_data_point: Option<Vec<bool>> = ext_grid.as_ref().map(|(ext_e, di)| {
899        let mut mask = vec![false; ext_e.len()];
900        for &idx in di {
901            mask[idx] = true;
902        }
903        mask
904    });
905
906    // Per-isotope: Doppler broaden with analytical derivative.
907    // Resolution is NOT applied (issue #442).
908    type IsotopeXsDxs = Result<(Vec<f64>, Vec<f64>), TransmissionError>;
909    let results: Vec<IsotopeXsDxs> = base_xs
910        .par_iter()
911        .zip(resonance_data.par_iter())
912        .map(|(xs_raw, rd)| {
913            if let Some((ref ext_energies, ref data_indices)) = ext_grid {
914                let mask = is_data_point.as_ref().unwrap();
915
916                // Build extended XS on auxiliary grid.
917                let mut xs_ext = vec![0.0f64; ext_energies.len()];
918                for (data_i, &ext_i) in data_indices.iter().enumerate() {
919                    xs_ext[ext_i] = xs_raw[data_i];
920                }
921                for (j, &e) in ext_energies.iter().enumerate() {
922                    if !mask[j] {
923                        xs_ext[j] = reich_moore::cross_sections_at_energy(rd, e).total;
924                    }
925                }
926
927                // Doppler broaden + analytical derivative in one pass.
928                // Resolution is NOT applied (issue #442).
929                let (after_doppler, dxs_dt_doppler) = if temperature_k > 0.0 {
930                    let params = DopplerParams::new(temperature_k, rd.awr)?;
931                    doppler::doppler_broaden_with_derivative(ext_energies, &xs_ext, &params)?
932                } else {
933                    (xs_ext, vec![0.0; ext_energies.len()])
934                };
935
936                // Extract data-grid points from extended grid.
937                let xs: Vec<f64> = data_indices.iter().map(|&i| after_doppler[i]).collect();
938                let dxs: Vec<f64> = data_indices.iter().map(|&i| dxs_dt_doppler[i]).collect();
939                Ok((xs, dxs))
940            } else {
941                // No auxiliary grid — Doppler on data grid only.
942                let (after_doppler, dxs_dt_doppler) = if temperature_k > 0.0 {
943                    let params = DopplerParams::new(temperature_k, rd.awr)?;
944                    doppler::doppler_broaden_with_derivative(energies, xs_raw, &params)?
945                } else {
946                    (xs_raw.clone(), vec![0.0; energies.len()])
947                };
948                Ok((after_doppler, dxs_dt_doppler))
949            }
950        })
951        .collect();
952
953    // Separate into (xs_all, dxs_all).
954    let mut xs_all = Vec::with_capacity(base_xs.len());
955    let mut dxs_all = Vec::with_capacity(base_xs.len());
956    for r in results {
957        let (xs, dxs) = r?;
958        xs_all.push(xs);
959        dxs_all.push(dxs);
960    }
961    Ok((xs_all, dxs_all))
962}
963
964/// Compute a transmission spectrum from precomputed unbroadened cross-sections.
965///
966/// Applies Doppler broadening and Beer-Lambert using cached base XS,
967/// then resolution broadening on the total transmission (issue #442).
968/// This skips the expensive Reich-Moore calculation, making it suitable
969/// for use inside `TransmissionFitModel::evaluate()` when temperature
970/// is a free parameter.
971///
972/// Pipeline:
973///   1. Doppler-broaden base σ (via `broadened_cross_sections_from_base`)
974///   2. Accumulate total attenuation: Σᵢ nᵢ·σ_{D,i}
975///   3. Beer-Lambert: T = exp(−attenuation)
976///   4. Resolution: T_broad = R ⊗ T  (when instrument is present)
977pub fn forward_model_from_base_xs(
978    energies: &[f64],
979    base_xs: &[Vec<f64>],
980    resonance_data: &[ResonanceData],
981    thicknesses: &[f64],
982    temperature_k: f64,
983    instrument: Option<&InstrumentParams>,
984) -> Result<Vec<f64>, TransmissionError> {
985    if base_xs.len() != resonance_data.len() || thicknesses.len() != resonance_data.len() {
986        return Err(TransmissionError::InputMismatch(format!(
987            "forward_model_from_base_xs: base_xs({})/thicknesses({})/resonance_data({}) length mismatch",
988            base_xs.len(),
989            thicknesses.len(),
990            resonance_data.len(),
991        )));
992    }
993    let n = energies.len();
994    if n == 0 {
995        return Ok(vec![]);
996    }
997
998    // Step 1: Doppler-only σ (resolution NOT applied — issue #442).
999    let doppler_xs = broadened_cross_sections_from_base(
1000        energies,
1001        base_xs,
1002        resonance_data,
1003        temperature_k,
1004        instrument,
1005    )?;
1006
1007    // Step 2-3: accumulate attenuation, Beer-Lambert.
1008    let mut total_attenuation = vec![0.0f64; n];
1009    for (xs, &thickness) in doppler_xs.iter().zip(thicknesses.iter()) {
1010        if thickness <= 0.0 {
1011            continue;
1012        }
1013        for i in 0..n {
1014            total_attenuation[i] += thickness * xs[i];
1015        }
1016    }
1017    let transmission: Vec<f64> = total_attenuation.iter().map(|&att| (-att).exp()).collect();
1018
1019    // Step 4: resolution broadening on total transmission.
1020    if let Some(inst) = instrument {
1021        resolution::apply_resolution(energies, &transmission, &inst.resolution)
1022            .map_err(TransmissionError::from)
1023    } else {
1024        Ok(transmission)
1025    }
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030    use super::*;
1031    use nereids_core::types::Isotope;
1032    use nereids_endf::resonance::{LGroup, Resonance, ResonanceFormalism, ResonanceRange};
1033
1034    fn u238_single_resonance() -> ResonanceData {
1035        ResonanceData {
1036            isotope: Isotope::new(92, 238).unwrap(),
1037            za: 92238,
1038            awr: 236.006,
1039            ranges: vec![ResonanceRange {
1040                energy_low: 1e-5,
1041                energy_high: 1e4,
1042                resolved: true,
1043                formalism: ResonanceFormalism::ReichMoore,
1044                target_spin: 0.0,
1045                scattering_radius: 9.4285,
1046                naps: 1,
1047                l_groups: vec![LGroup {
1048                    l: 0,
1049                    awr: 236.006,
1050                    apl: 0.0,
1051                    qx: 0.0,
1052                    lrx: 0,
1053                    resonances: vec![Resonance {
1054                        energy: 6.674,
1055                        j: 0.5,
1056                        gn: 1.493e-3,
1057                        gg: 23.0e-3,
1058                        gfa: 0.0,
1059                        gfb: 0.0,
1060                    }],
1061                }],
1062                rml: None,
1063                urr: None,
1064                ap_table: None,
1065                r_external: vec![],
1066            }],
1067        }
1068    }
1069
1070    #[test]
1071    fn test_beer_lambert_zero_thickness() {
1072        let xs = vec![100.0, 200.0, 300.0];
1073        let t = beer_lambert(&xs, 0.0);
1074        assert_eq!(t, vec![1.0, 1.0, 1.0]);
1075    }
1076
1077    #[test]
1078    fn test_beer_lambert_basic() {
1079        // σ = 100 barns, thickness = 0.01 atoms/barn
1080        // T = exp(-1.0) ≈ 0.3679
1081        let xs = vec![100.0];
1082        let t = beer_lambert(&xs, 0.01);
1083        assert!(
1084            (t[0] - (-1.0_f64).exp()).abs() < 1e-10,
1085            "T = {}, expected {}",
1086            t[0],
1087            (-1.0_f64).exp()
1088        );
1089    }
1090
1091    #[test]
1092    fn test_beer_lambert_opaque() {
1093        // Very thick sample: T should be 0 (exp(-1000) underflows)
1094        let xs = vec![1000.0];
1095        let t = beer_lambert(&xs, 1.0);
1096        assert_eq!(t[0], 0.0, "T = {}, expected 0.0", t[0]);
1097    }
1098
1099    #[test]
1100    fn test_beer_lambert_multi_additive() {
1101        // Two isotopes should combine additively in the exponent.
1102        // σ₁ = 100 barns, t₁ = 0.01 → att₁ = 1.0
1103        // σ₂ = 200 barns, t₂ = 0.005 → att₂ = 1.0
1104        // T = exp(-(1.0 + 1.0)) = exp(-2.0)
1105        let xs1 = vec![100.0];
1106        let xs2 = vec![200.0];
1107        let t = beer_lambert_multi(&[&xs1, &xs2], &[0.01, 0.005]).unwrap();
1108        assert!(
1109            (t[0] - (-2.0_f64).exp()).abs() < 1e-10,
1110            "T = {}, expected {}",
1111            t[0],
1112            (-2.0_f64).exp()
1113        );
1114    }
1115
1116    #[test]
1117    fn test_transmission_dip_at_resonance() {
1118        // U-238 has a huge capture resonance at 6.674 eV.
1119        // A thin sample should show a transmission dip there.
1120        let data = u238_single_resonance();
1121        let thickness = 0.001; // atoms/barn (thin)
1122
1123        // Evaluate at a few energies
1124        let energies = [1.0, 3.0, 6.674, 10.0, 20.0];
1125        let xs: Vec<f64> = energies
1126            .iter()
1127            .map(|&e| reich_moore::cross_sections_at_energy(&data, e).total)
1128            .collect();
1129        let trans = beer_lambert(&xs, thickness);
1130
1131        // At 6.674 eV (on resonance), transmission should be much lower
1132        let t_on_res = trans[2];
1133        let t_off_res = trans[0]; // 1 eV, off resonance
1134
1135        assert!(
1136            t_on_res < t_off_res,
1137            "On-resonance T ({}) should be < off-resonance T ({})",
1138            t_on_res,
1139            t_off_res
1140        );
1141
1142        // On-resonance with huge σ (~25000 barns), T ≈ exp(-25) ≈ 0
1143        assert!(
1144            t_on_res < 0.01,
1145            "On-resonance T ({}) should be very small",
1146            t_on_res
1147        );
1148    }
1149
1150    #[test]
1151    fn test_forward_model_no_broadening() {
1152        // Forward model at T=0 with no resolution should give
1153        // the same result as direct Beer-Lambert on unbroadened σ.
1154        let data = u238_single_resonance();
1155        let thickness = 0.001;
1156
1157        let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
1158
1159        // Direct calculation
1160        let xs: Vec<f64> = energies
1161            .iter()
1162            .map(|&e| reich_moore::cross_sections_at_energy(&data, e).total)
1163            .collect();
1164        let t_direct = beer_lambert(&xs, thickness);
1165
1166        // Forward model
1167        let sample = SampleParams::new(0.0, vec![(data, thickness)]).unwrap();
1168        let t_forward = forward_model(&energies, &sample, None).unwrap();
1169
1170        for i in 0..energies.len() {
1171            assert!(
1172                (t_direct[i] - t_forward[i]).abs() < 1e-10,
1173                "Mismatch at E={}: direct={}, forward={}",
1174                energies[i],
1175                t_direct[i],
1176                t_forward[i]
1177            );
1178        }
1179    }
1180
1181    #[test]
1182    fn test_forward_model_with_broadening() {
1183        // Forward model with Doppler broadening should smooth out the
1184        // transmission dip, making it wider and shallower.
1185        let data = u238_single_resonance();
1186        let thickness = 0.0001; // Very thin (to avoid total absorption)
1187
1188        let energies: Vec<f64> = (0..401).map(|i| 5.0 + (i as f64) * 0.01).collect();
1189
1190        // Cold (no broadening)
1191        let sample_cold = SampleParams::new(0.0, vec![(data.clone(), thickness)]).unwrap();
1192        let t_cold = forward_model(&energies, &sample_cold, None).unwrap();
1193
1194        // Hot (300 K Doppler)
1195        let sample_hot = SampleParams::new(300.0, vec![(data, thickness)]).unwrap();
1196        let t_hot = forward_model(&energies, &sample_hot, None).unwrap();
1197
1198        // Find minima
1199        let min_cold = t_cold.iter().cloned().fold(f64::MAX, f64::min);
1200        let min_hot = t_hot.iter().cloned().fold(f64::MAX, f64::min);
1201
1202        // Broadened dip should be shallower (higher minimum transmission)
1203        assert!(
1204            min_hot > min_cold,
1205            "Broadened min T ({}) should be > unbroadened min T ({})",
1206            min_hot,
1207            min_cold
1208        );
1209    }
1210
1211    #[test]
1212    fn test_forward_model_multi_isotope() {
1213        // Two isotopes with different resonances should create two dips.
1214        let u238 = u238_single_resonance();
1215
1216        // Create a fictitious second isotope with a resonance at 20 eV
1217        let other = ResonanceData {
1218            isotope: Isotope::new(1, 10).unwrap(),
1219            za: 1010,
1220            awr: 10.0,
1221            ranges: vec![ResonanceRange {
1222                energy_low: 0.0,
1223                energy_high: 100.0,
1224                resolved: true,
1225                formalism: ResonanceFormalism::ReichMoore,
1226                target_spin: 0.0,
1227                scattering_radius: 5.0,
1228                naps: 1,
1229                l_groups: vec![LGroup {
1230                    l: 0,
1231                    awr: 10.0,
1232                    apl: 5.0,
1233                    qx: 0.0,
1234                    lrx: 0,
1235                    resonances: vec![Resonance {
1236                        energy: 20.0,
1237                        j: 0.5,
1238                        gn: 0.1,
1239                        gg: 0.05,
1240                        gfa: 0.0,
1241                        gfb: 0.0,
1242                    }],
1243                }],
1244                rml: None,
1245                urr: None,
1246                ap_table: None,
1247                r_external: vec![],
1248            }],
1249        };
1250
1251        let energies: Vec<f64> = (0..301).map(|i| 1.0 + (i as f64) * 0.1).collect();
1252
1253        let sample = SampleParams::new(0.0, vec![(u238, 0.0001), (other, 0.0001)]).unwrap();
1254        let t = forward_model(&energies, &sample, None).unwrap();
1255
1256        // Find the transmission near 6.674 eV (U-238 resonance)
1257        let idx_u238 = energies
1258            .iter()
1259            .position(|&e| (e - 6.7).abs() < 0.05)
1260            .unwrap();
1261        // Find the transmission near 20 eV (other resonance)
1262        let idx_other = energies
1263            .iter()
1264            .position(|&e| (e - 20.0).abs() < 0.05)
1265            .unwrap();
1266        // Off-resonance
1267        let idx_off = energies
1268            .iter()
1269            .position(|&e| (e - 15.0).abs() < 0.05)
1270            .unwrap();
1271
1272        // Both dips should be visible
1273        assert!(
1274            t[idx_u238] < t[idx_off],
1275            "U-238 dip at 6.7 eV: T={}, off-res: T={}",
1276            t[idx_u238],
1277            t[idx_off]
1278        );
1279        assert!(
1280            t[idx_other] < t[idx_off],
1281            "Other dip at 20 eV: T={}, off-res: T={}",
1282            t[idx_other],
1283            t[idx_off]
1284        );
1285    }
1286
1287    #[test]
1288    fn test_broadened_xs_analytical_derivative() {
1289        // Verify analytical ∂σ/∂T against a manual FD at a larger step (1 K)
1290        // and check they agree to reasonable tolerance.
1291        let data = u238_single_resonance();
1292        let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1293        let temperature = 300.0;
1294
1295        let base_xs =
1296            unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1297        let (xs, dxs_dt) = broadened_cross_sections_with_analytical_derivative_from_base(
1298            &energies,
1299            &base_xs,
1300            std::slice::from_ref(&data),
1301            temperature,
1302            None,
1303        )
1304        .unwrap();
1305
1306        // Basic shape checks
1307        assert_eq!(xs.len(), 1, "one isotope");
1308        assert_eq!(dxs_dt.len(), 1, "one isotope derivative");
1309        assert_eq!(xs[0].len(), energies.len());
1310        assert_eq!(dxs_dt[0].len(), energies.len());
1311
1312        // The derivative should be non-zero near the resonance at 6.674 eV
1313        // where Doppler broadening has a strong effect.
1314        let idx_res = energies
1315            .iter()
1316            .position(|&e| (e - 6.674).abs() < 0.05)
1317            .unwrap();
1318        assert!(
1319            dxs_dt[0][idx_res].abs() > 0.0,
1320            "dσ/dT should be non-zero near resonance, got {}",
1321            dxs_dt[0][idx_res]
1322        );
1323
1324        // Cross-check: compute a manual FD at a larger step and verify
1325        // the analytical derivative is consistent (within ~5% relative error).
1326        let big_dt = 1.0;
1327        let xs_up = broadened_cross_sections(
1328            &energies,
1329            std::slice::from_ref(&data),
1330            temperature + big_dt,
1331            None,
1332            None,
1333        )
1334        .unwrap();
1335        let xs_down =
1336            broadened_cross_sections(&energies, &[data], temperature - big_dt, None, None).unwrap();
1337
1338        let manual_deriv: Vec<f64> = xs_up[0]
1339            .iter()
1340            .zip(xs_down[0].iter())
1341            .map(|(&u, &d)| (u - d) / (2.0 * big_dt))
1342            .collect();
1343
1344        // Compare near the resonance where the derivative is large.
1345        let deriv_analytical = dxs_dt[0][idx_res];
1346        let deriv_coarse = manual_deriv[idx_res];
1347        let rel_err = (deriv_analytical - deriv_coarse).abs()
1348            / deriv_analytical.abs().max(deriv_coarse.abs()).max(1e-30);
1349        assert!(
1350            rel_err < 0.05,
1351            "Analytical vs FD derivatives disagree: analytical={}, coarse={}, rel_err={}",
1352            deriv_analytical,
1353            deriv_coarse,
1354            rel_err,
1355        );
1356    }
1357
1358    #[test]
1359    fn test_broadened_xs_analytical_derivative_low_temperature() {
1360        // Regression test: derivative must have correct sign at low temperature.
1361        let data = u238_single_resonance();
1362        let energies: Vec<f64> = (0..51).map(|i| 5.0 + (i as f64) * 0.1).collect();
1363
1364        let base_xs =
1365            unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1366
1367        // T = 0.05 K
1368        let (xs_low, dxs_low) = broadened_cross_sections_with_analytical_derivative_from_base(
1369            &energies,
1370            &base_xs,
1371            std::slice::from_ref(&data),
1372            0.05,
1373            None,
1374        )
1375        .unwrap();
1376        assert!(!xs_low.is_empty());
1377        // Derivative should be finite and mostly positive (Doppler broadening
1378        // increases with temperature for narrow resonances).
1379        for deriv_vec in &dxs_low {
1380            for &d in deriv_vec {
1381                assert!(d.is_finite(), "derivative must be finite at T=0.05 K");
1382            }
1383        }
1384
1385        // T = 0.0 K: edge case
1386        let (xs_zero, dxs_zero) = broadened_cross_sections_with_analytical_derivative_from_base(
1387            &energies,
1388            &base_xs,
1389            std::slice::from_ref(&data),
1390            0.0,
1391            None,
1392        )
1393        .unwrap();
1394        assert!(!xs_zero.is_empty());
1395        for deriv_vec in &dxs_zero {
1396            for &d in deriv_vec {
1397                assert!(d.is_finite(), "derivative must be finite at T=0.0 K");
1398            }
1399        }
1400    }
1401
1402    // --- SampleParams validation tests ---
1403
1404    #[test]
1405    fn test_sample_params_valid() {
1406        let sample = SampleParams::new(300.0, vec![]).unwrap();
1407        assert!((sample.temperature_k() - 300.0).abs() < 1e-15);
1408        assert!(sample.isotopes().is_empty());
1409    }
1410
1411    #[test]
1412    fn test_sample_params_zero_temperature() {
1413        let sample = SampleParams::new(0.0, vec![]).unwrap();
1414        assert!((sample.temperature_k()).abs() < 1e-15);
1415    }
1416
1417    #[test]
1418    fn test_sample_params_rejects_negative_temperature() {
1419        let err = SampleParams::new(-1.0, vec![]).unwrap_err();
1420        assert_eq!(err, SampleParamsError::NegativeTemperature(-1.0));
1421    }
1422
1423    #[test]
1424    fn test_sample_params_rejects_nan_temperature() {
1425        let err = SampleParams::new(f64::NAN, vec![]).unwrap_err();
1426        assert!(matches!(err, SampleParamsError::NonFiniteTemperature(_)));
1427    }
1428
1429    #[test]
1430    fn test_sample_params_rejects_infinite_temperature() {
1431        let err = SampleParams::new(f64::INFINITY, vec![]).unwrap_err();
1432        assert!(matches!(err, SampleParamsError::NonFiniteTemperature(_)));
1433    }
1434
1435    #[test]
1436    fn test_sample_params_rejects_neg_infinite_temperature() {
1437        let err = SampleParams::new(f64::NEG_INFINITY, vec![]).unwrap_err();
1438        assert!(matches!(err, SampleParamsError::NonFiniteTemperature(_)));
1439    }
1440
1441    // --- Base XS caching tests ---
1442
1443    #[test]
1444    fn test_forward_model_from_base_xs_matches_forward_model() {
1445        let data = u238_single_resonance();
1446        let thickness = 0.0005;
1447        let temperature = 300.0;
1448        let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1449
1450        // Reference: full forward model
1451        let sample = SampleParams::new(temperature, vec![(data.clone(), thickness)]).unwrap();
1452        let t_ref = forward_model(&energies, &sample, None).unwrap();
1453
1454        // Cached path: unbroadened XS → forward_model_from_base_xs
1455        let base_xs =
1456            unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1457        let t_cached = forward_model_from_base_xs(
1458            &energies,
1459            &base_xs,
1460            std::slice::from_ref(&data),
1461            &[thickness],
1462            temperature,
1463            None,
1464        )
1465        .unwrap();
1466
1467        for (i, (&r, &c)) in t_ref.iter().zip(t_cached.iter()).enumerate() {
1468            assert!(
1469                (r - c).abs() < 1e-12,
1470                "Mismatch at E[{}]={}: ref={}, cached={}",
1471                i,
1472                energies[i],
1473                r,
1474                c
1475            );
1476        }
1477    }
1478
1479    #[test]
1480    fn test_broadened_from_base_matches_broadened() {
1481        let data = u238_single_resonance();
1482        let temperature = 300.0;
1483        let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1484
1485        let xs_ref = broadened_cross_sections(
1486            &energies,
1487            std::slice::from_ref(&data),
1488            temperature,
1489            None,
1490            None,
1491        )
1492        .unwrap();
1493        let base_xs =
1494            unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1495        let xs_cached = broadened_cross_sections_from_base(
1496            &energies,
1497            &base_xs,
1498            std::slice::from_ref(&data),
1499            temperature,
1500            None,
1501        )
1502        .unwrap();
1503
1504        assert_eq!(xs_ref.len(), xs_cached.len());
1505        for (r, c) in xs_ref[0].iter().zip(xs_cached[0].iter()) {
1506            assert!(
1507                (r - c).abs() < 1e-12,
1508                "broadened_from_base mismatch: ref={}, cached={}",
1509                r,
1510                c
1511            );
1512        }
1513    }
1514
1515    #[test]
1516    fn test_analytical_derivative_from_base_shape_and_finiteness() {
1517        // Verify the analytical derivative from base returns correct shapes
1518        // and finite values.
1519        let data = u238_single_resonance();
1520        let temperature = 300.0;
1521        let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1522
1523        let base_xs =
1524            unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1525        let (xs, dxs_dt) = broadened_cross_sections_with_analytical_derivative_from_base(
1526            &energies,
1527            &base_xs,
1528            std::slice::from_ref(&data),
1529            temperature,
1530            None,
1531        )
1532        .unwrap();
1533
1534        assert_eq!(xs.len(), 1);
1535        assert_eq!(dxs_dt.len(), 1);
1536        assert_eq!(xs[0].len(), energies.len());
1537        assert_eq!(dxs_dt[0].len(), energies.len());
1538
1539        // All values should be finite.
1540        for &v in &xs[0] {
1541            assert!(v.is_finite(), "XS must be finite, got {v}");
1542        }
1543        for &v in &dxs_dt[0] {
1544            assert!(v.is_finite(), "dXS/dT must be finite, got {v}");
1545        }
1546
1547        // XS from base should match XS from full broadening.
1548        let xs_full = broadened_cross_sections(
1549            &energies,
1550            std::slice::from_ref(&data),
1551            temperature,
1552            None,
1553            None,
1554        )
1555        .unwrap();
1556        for (r, c) in xs_full[0].iter().zip(xs[0].iter()) {
1557            assert!(
1558                (r - c).abs() < 1e-12,
1559                "XS mismatch: full={}, from_base={}",
1560                r,
1561                c
1562            );
1563        }
1564    }
1565
1566    /// Regression test for issue #442: resolution broadening must be applied
1567    /// to the total transmission T(E) AFTER Beer-Lambert, not to σ(E) before.
1568    ///
1569    /// This test constructs the expected result from first principles:
1570    ///
1571    ///   1. Doppler-broaden σ
1572    ///   2. Beer-Lambert: T = exp(−n·σ_D)
1573    ///   3. Resolution-broaden T
1574    ///
1575    /// and asserts that `forward_model()` matches.
1576    #[test]
1577    fn test_forward_model_resolution_after_beer_lambert() {
1578        let data = u238_single_resonance();
1579        let thickness = 0.0005; // atoms/barn
1580        let temperature = 300.0;
1581
1582        // Energy grid around the 6.674 eV resonance.
1583        let energies: Vec<f64> = (0..401).map(|i| 4.0 + (i as f64) * 0.015).collect();
1584
1585        let inst = InstrumentParams {
1586            resolution: resolution::ResolutionFunction::Gaussian(
1587                resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
1588            ),
1589        };
1590
1591        // --- Build expected from first principles ---
1592
1593        // Step 1: Doppler-broadened σ on the data grid.
1594        let unbroadened: Vec<f64> = energies
1595            .iter()
1596            .map(|&e| reich_moore::cross_sections_at_energy(&data, e).total)
1597            .collect();
1598        let doppler_params = doppler::DopplerParams::new(temperature, data.awr).unwrap();
1599        let sigma_d = doppler::doppler_broaden(&energies, &unbroadened, &doppler_params).unwrap();
1600
1601        // Step 2: Beer-Lambert on total transmission.
1602        let transmission: Vec<f64> = sigma_d.iter().map(|&s| (-thickness * s).exp()).collect();
1603
1604        // Step 3: Resolution-broaden the transmission.
1605        let t_expected =
1606            resolution::apply_resolution(&energies, &transmission, &inst.resolution).unwrap();
1607
1608        // --- Wrong ordering for comparison: Resolution(σ) then Beer-Lambert ---
1609        let sigma_broadened =
1610            resolution::apply_resolution(&energies, &sigma_d, &inst.resolution).unwrap();
1611        let t_wrong: Vec<f64> = sigma_broadened
1612            .iter()
1613            .map(|&s| (-thickness * s).exp())
1614            .collect();
1615
1616        // --- forward_model() output ---
1617        let sample = SampleParams::new(temperature, vec![(data, thickness)]).unwrap();
1618        let t_forward = forward_model(&energies, &sample, Some(&inst)).unwrap();
1619
1620        // forward_model should match the correct ordering (resolution after Beer-Lambert).
1621        // The extended grid in forward_model adds boundary points, so the match
1622        // is approximate — but should be very close on the interior grid.
1623        let interior = 20..energies.len() - 20; // skip boundary region
1624        let mut max_err_correct = 0.0f64;
1625        let mut max_err_wrong = 0.0f64;
1626        for i in interior.clone() {
1627            let err_correct = (t_forward[i] - t_expected[i]).abs();
1628            let err_wrong = (t_forward[i] - t_wrong[i]).abs();
1629            max_err_correct = max_err_correct.max(err_correct);
1630            max_err_wrong = max_err_wrong.max(err_wrong);
1631        }
1632
1633        // The key discriminant: forward_model must be much closer to the
1634        // correct ordering (resolution after Beer-Lambert) than to the wrong
1635        // ordering (resolution before Beer-Lambert).
1636        //
1637        // Small absolute differences (~1%) between forward_model and the
1638        // data-grid reference are expected because forward_model uses an
1639        // extended grid for boundary handling.
1640        assert!(
1641            max_err_correct < max_err_wrong,
1642            "forward_model is closer to the WRONG ordering than the correct one. \
1643             Error vs correct = {max_err_correct}, error vs wrong = {max_err_wrong}"
1644        );
1645
1646        // The error against the correct ordering should be at least 5× smaller
1647        // than the error against the wrong ordering.
1648        assert!(
1649            max_err_correct < max_err_wrong * 0.5,
1650            "forward_model should be clearly closer to the correct ordering. \
1651             Error vs correct = {max_err_correct}, error vs wrong = {max_err_wrong}, \
1652             ratio = {:.2}",
1653            max_err_correct / max_err_wrong
1654        );
1655
1656        // Verify the two orderings actually differ — if they don't, the test
1657        // is not exercising the bug.
1658        let ordering_diff: f64 = interior
1659            .map(|i| (t_expected[i] - t_wrong[i]).abs())
1660            .fold(0.0f64, f64::max);
1661        assert!(
1662            ordering_diff > 1e-4,
1663            "The two orderings should differ measurably at the resonance dip, \
1664             but max diff = {ordering_diff}. Test parameters may be too weak."
1665        );
1666    }
1667
1668    /// Issue #442 containment: `broadened_cross_sections()` must return
1669    /// Doppler-only σ even when `instrument` is `Some`.  Resolution
1670    /// broadening must NOT be applied inside this function.
1671    #[test]
1672    fn test_broadened_xs_is_doppler_only_with_instrument() {
1673        let data = u238_single_resonance();
1674        let temperature = 300.0;
1675        let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1676
1677        let inst = InstrumentParams {
1678            resolution: resolution::ResolutionFunction::Gaussian(
1679                resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
1680            ),
1681        };
1682
1683        // With instrument (used for aux grid, but NOT for resolution broadening).
1684        let xs_with_inst = broadened_cross_sections(
1685            &energies,
1686            std::slice::from_ref(&data),
1687            temperature,
1688            Some(&inst),
1689            None,
1690        )
1691        .unwrap();
1692
1693        // Without instrument (pure Doppler on data grid).
1694        let xs_no_inst = broadened_cross_sections(
1695            &energies,
1696            std::slice::from_ref(&data),
1697            temperature,
1698            None,
1699            None,
1700        )
1701        .unwrap();
1702
1703        // Both should return Doppler-only σ.  The with-instrument path uses
1704        // the extended grid for Doppler which may produce slightly different
1705        // values, but they must be close (no resolution smoothing).
1706        assert_eq!(xs_with_inst.len(), 1);
1707        assert_eq!(xs_no_inst.len(), 1);
1708        assert_eq!(xs_with_inst[0].len(), energies.len());
1709
1710        // Compute what resolution-broadened σ would look like.
1711        let sigma_resolved =
1712            resolution::apply_resolution(&energies, &xs_no_inst[0], &inst.resolution).unwrap();
1713
1714        // The with-instrument result must NOT match the resolution-broadened version.
1715        // Near the resonance dip, resolution broadening smooths the peak — the
1716        // Doppler-only result should have a deeper dip than the resolved one.
1717        let idx_dip = energies
1718            .iter()
1719            .position(|&e| (e - 6.674).abs() < 0.05)
1720            .unwrap();
1721        let diff_doppler = (xs_with_inst[0][idx_dip] - xs_no_inst[0][idx_dip]).abs();
1722        let diff_resolved = (xs_with_inst[0][idx_dip] - sigma_resolved[idx_dip]).abs();
1723
1724        // The Doppler-only values from both paths should be closer to each
1725        // other than to the resolution-broadened value.
1726        assert!(
1727            diff_doppler < diff_resolved,
1728            "broadened_cross_sections with instrument should return Doppler-only σ, \
1729             not resolution-broadened σ.  \
1730             diff(with_inst, no_inst) = {diff_doppler}, \
1731             diff(with_inst, resolved) = {diff_resolved}"
1732        );
1733    }
1734
1735    /// Issue #442 containment: `broadened_cross_sections_from_base()` must
1736    /// return Doppler-only σ even when `instrument` is `Some`.
1737    #[test]
1738    fn test_broadened_from_base_is_doppler_only_with_instrument() {
1739        let data = u238_single_resonance();
1740        let temperature = 300.0;
1741        let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1742
1743        let inst = InstrumentParams {
1744            resolution: resolution::ResolutionFunction::Gaussian(
1745                resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
1746            ),
1747        };
1748
1749        let base_xs =
1750            unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1751
1752        // With instrument.
1753        let xs_with_inst = broadened_cross_sections_from_base(
1754            &energies,
1755            &base_xs,
1756            std::slice::from_ref(&data),
1757            temperature,
1758            Some(&inst),
1759        )
1760        .unwrap();
1761
1762        // Without instrument.
1763        let xs_no_inst = broadened_cross_sections_from_base(
1764            &energies,
1765            &base_xs,
1766            std::slice::from_ref(&data),
1767            temperature,
1768            None,
1769        )
1770        .unwrap();
1771
1772        // Both should produce Doppler-only σ.  With-instrument may use
1773        // extended grid, but the extracted data-grid values should be close.
1774        let sigma_resolved =
1775            resolution::apply_resolution(&energies, &xs_no_inst[0], &inst.resolution).unwrap();
1776
1777        let idx_dip = energies
1778            .iter()
1779            .position(|&e| (e - 6.674).abs() < 0.05)
1780            .unwrap();
1781        let diff_doppler = (xs_with_inst[0][idx_dip] - xs_no_inst[0][idx_dip]).abs();
1782        let diff_resolved = (xs_with_inst[0][idx_dip] - sigma_resolved[idx_dip]).abs();
1783
1784        assert!(
1785            diff_doppler < diff_resolved,
1786            "broadened_cross_sections_from_base with instrument should return Doppler-only σ. \
1787             diff(with_inst, no_inst) = {diff_doppler}, \
1788             diff(with_inst, resolved) = {diff_resolved}"
1789        );
1790    }
1791
1792    // ── Issue #442 Step 5: forward_model_from_base_xs resolution ordering ──
1793
1794    /// Issue #442 Step 5: `forward_model_from_base_xs()` with resolution must
1795    /// match `forward_model()` with resolution for the same sample.
1796    #[test]
1797    fn test_forward_model_from_base_xs_matches_forward_model_with_resolution() {
1798        let data = u238_single_resonance();
1799        let thickness = 0.0005;
1800        let temperature = 300.0;
1801        let energies: Vec<f64> = (0..401).map(|i| 4.0 + (i as f64) * 0.015).collect();
1802
1803        let inst = InstrumentParams {
1804            resolution: resolution::ResolutionFunction::Gaussian(
1805                resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
1806            ),
1807        };
1808
1809        // Reference: forward_model() (fixed in Step 1).
1810        let sample = SampleParams::new(temperature, vec![(data.clone(), thickness)]).unwrap();
1811        let t_ref = forward_model(&energies, &sample, Some(&inst)).unwrap();
1812
1813        // Base-XS path: unbroadened → forward_model_from_base_xs with resolution.
1814        let base_xs =
1815            unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1816        let t_base = forward_model_from_base_xs(
1817            &energies,
1818            &base_xs,
1819            std::slice::from_ref(&data),
1820            &[thickness],
1821            temperature,
1822            Some(&inst),
1823        )
1824        .unwrap();
1825
1826        // Both use resolution after Beer-Lambert but may differ slightly
1827        // due to extended-grid Doppler in forward_model vs data-grid Doppler
1828        // in broadened_cross_sections_from_base.
1829        let interior = 20..energies.len() - 20;
1830        let mut max_err = 0.0f64;
1831        for i in interior.clone() {
1832            max_err = max_err.max((t_ref[i] - t_base[i]).abs());
1833        }
1834        assert!(
1835            max_err < 0.02,
1836            "forward_model_from_base_xs with resolution should match \
1837             forward_model.  Max error = {max_err}"
1838        );
1839
1840        // Verify resolution actually made a difference (not a vacuous test).
1841        let t_no_res = forward_model_from_base_xs(
1842            &energies,
1843            &base_xs,
1844            std::slice::from_ref(&data),
1845            &[thickness],
1846            temperature,
1847            None,
1848        )
1849        .unwrap();
1850        let res_diff: f64 = interior
1851            .map(|i| (t_base[i] - t_no_res[i]).abs())
1852            .fold(0.0f64, f64::max);
1853        assert!(
1854            res_diff > 1e-4,
1855            "Resolution should make a measurable difference, but max diff = {res_diff}"
1856        );
1857    }
1858
1859    /// Issue #442 Step 5: `forward_model_from_base_xs()` without resolution
1860    /// must remain unchanged (matches existing no-resolution test).
1861    #[test]
1862    fn test_forward_model_from_base_xs_no_resolution_unchanged() {
1863        let data = u238_single_resonance();
1864        let thickness = 0.0005;
1865        let temperature = 300.0;
1866        let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1867
1868        let sample = SampleParams::new(temperature, vec![(data.clone(), thickness)]).unwrap();
1869        let t_ref = forward_model(&energies, &sample, None).unwrap();
1870
1871        let base_xs =
1872            unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1873        let t_base = forward_model_from_base_xs(
1874            &energies,
1875            &base_xs,
1876            std::slice::from_ref(&data),
1877            &[thickness],
1878            temperature,
1879            None,
1880        )
1881        .unwrap();
1882
1883        for (i, (&r, &b)) in t_ref.iter().zip(t_base.iter()).enumerate() {
1884            assert!(
1885                (r - b).abs() < 1e-12,
1886                "No-resolution mismatch at E[{i}]={}: ref={r}, base={b}",
1887                energies[i]
1888            );
1889        }
1890    }
1891
1892    // ── Issue #442 Step 7: derivative helper containment ───────────────────
1893
1894    /// Issue #442 Step 7: `broadened_cross_sections_with_analytical_derivative_from_base()`
1895    /// must return Doppler-only σ and Doppler-only ∂σ/∂T even when instrument
1896    /// is present.  Resolution broadening must NOT be applied inside.
1897    #[test]
1898    fn test_derivative_helper_is_doppler_only_with_instrument() {
1899        let data = u238_single_resonance();
1900        let temperature = 300.0;
1901        let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1902
1903        let inst = InstrumentParams {
1904            resolution: resolution::ResolutionFunction::Gaussian(
1905                resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
1906            ),
1907        };
1908
1909        let base_xs =
1910            unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1911
1912        // With instrument.
1913        let (xs_inst, dxs_inst) = broadened_cross_sections_with_analytical_derivative_from_base(
1914            &energies,
1915            &base_xs,
1916            std::slice::from_ref(&data),
1917            temperature,
1918            Some(&inst),
1919        )
1920        .unwrap();
1921
1922        // Without instrument.
1923        let (xs_none, dxs_none) = broadened_cross_sections_with_analytical_derivative_from_base(
1924            &energies,
1925            &base_xs,
1926            std::slice::from_ref(&data),
1927            temperature,
1928            None,
1929        )
1930        .unwrap();
1931
1932        assert_eq!(xs_inst.len(), 1);
1933        assert_eq!(dxs_inst.len(), 1);
1934
1935        // Both should return Doppler-only.  Compute what resolution-broadened
1936        // σ would look like, and verify the with-instrument result is closer
1937        // to the no-instrument result than to the resolved version.
1938        let sigma_resolved =
1939            resolution::apply_resolution(&energies, &xs_none[0], &inst.resolution).unwrap();
1940
1941        let idx_dip = energies
1942            .iter()
1943            .position(|&e| (e - 6.674).abs() < 0.05)
1944            .unwrap();
1945
1946        // σ check: with-instrument should be close to no-instrument (Doppler-only),
1947        // not to the resolution-broadened version.
1948        let diff_doppler = (xs_inst[0][idx_dip] - xs_none[0][idx_dip]).abs();
1949        let diff_resolved = (xs_inst[0][idx_dip] - sigma_resolved[idx_dip]).abs();
1950        assert!(
1951            diff_doppler < diff_resolved,
1952            "derivative helper σ with instrument should be Doppler-only. \
1953             diff(inst, none) = {diff_doppler}, diff(inst, resolved) = {diff_resolved}"
1954        );
1955
1956        // ∂σ/∂T check: same pattern — should be Doppler-only derivative.
1957        let dxs_resolved =
1958            resolution::apply_resolution(&energies, &dxs_none[0], &inst.resolution).unwrap();
1959        let ddiff_doppler = (dxs_inst[0][idx_dip] - dxs_none[0][idx_dip]).abs();
1960        let ddiff_resolved = (dxs_inst[0][idx_dip] - dxs_resolved[idx_dip]).abs();
1961        assert!(
1962            ddiff_doppler < ddiff_resolved,
1963            "derivative helper ∂σ/∂T with instrument should be Doppler-only. \
1964             diff(inst, none) = {ddiff_doppler}, diff(inst, resolved) = {ddiff_resolved}"
1965        );
1966    }
1967
1968    /// Issue #442 Step 7: derivative helper without resolution must be
1969    /// unchanged — same σ and ∂σ/∂T as before.
1970    #[test]
1971    fn test_derivative_helper_no_resolution_unchanged() {
1972        let data = u238_single_resonance();
1973        let temperature = 300.0;
1974        let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
1975
1976        let base_xs =
1977            unbroadened_cross_sections(&energies, std::slice::from_ref(&data), None).unwrap();
1978
1979        let (xs, dxs) = broadened_cross_sections_with_analytical_derivative_from_base(
1980            &energies,
1981            &base_xs,
1982            std::slice::from_ref(&data),
1983            temperature,
1984            None,
1985        )
1986        .unwrap();
1987
1988        // σ should match broadened_cross_sections (no instrument).
1989        let xs_ref = broadened_cross_sections(
1990            &energies,
1991            std::slice::from_ref(&data),
1992            temperature,
1993            None,
1994            None,
1995        )
1996        .unwrap();
1997
1998        for (i, (&a, &b)) in xs[0].iter().zip(xs_ref[0].iter()).enumerate() {
1999            assert!(
2000                (a - b).abs() < 1e-12,
2001                "σ mismatch at E[{i}]: derivative_helper={a}, broadened={b}"
2002            );
2003        }
2004
2005        // ∂σ/∂T should be finite and non-trivial near resonance.
2006        assert_eq!(dxs.len(), 1);
2007        assert_eq!(dxs[0].len(), energies.len());
2008        let idx_res = energies
2009            .iter()
2010            .position(|&e| (e - 6.674).abs() < 0.05)
2011            .unwrap();
2012        assert!(
2013            dxs[0][idx_res].abs() > 0.0,
2014            "∂σ/∂T should be non-zero near resonance"
2015        );
2016        for &d in &dxs[0] {
2017            assert!(d.is_finite(), "∂σ/∂T must be finite");
2018        }
2019    }
2020}