Skip to main content

nereids_physics/
doppler.rs

1//! Doppler broadening via the Free Gas Model (FGM).
2//!
3//! The FGM treats target atoms as a free ideal gas at temperature T.
4//! The Doppler-broadened cross-section is obtained by averaging the
5//! unbroadened cross-section over the Maxwell-Boltzmann velocity
6//! distribution of the target atoms.
7//!
8//! ## SAMMY Reference
9//! - Manual Section III.B.1 (Free-Gas Model of Doppler Broadening)
10//! - `fgm/mfgm1.f90` subroutine `Dopfgm` (quadrature in `mfgm2.f90`
11//!   Modsmp/Modfpl)
12//!
13//! ## Method
14//!
15//! We implement the exact FGM integral in velocity space (SAMMY
16//! Eq. III B1.7), including its w/v integrand weight:
17//!
18//!   v²·σ_D(v²) = (1/(u√π)) ∫ exp(-(v-w)²/u²) · w² · s(w) dw
19//!
20//! where v = √E, u = √(k_B·T / AWR), and:
21//!   s(w) =  σ(w²)  for w > 0
22//!   s(w) = -σ(w²)  for w < 0
23//!
24//! This is the same kernel weighting as SAMMY's `Dopfgm`, which multiplies
25//! the normalized Gaussian quadrature weights by w² and divides the
26//! integral by E = v² (`fgm/mfgm2.f90` Modsmp/Modfpl `Wts·Velcty**2`,
27//! `mfgm4.f90` `val/Em`).  The quadrature itself differs: NEREIDS
28//! integrates the Gaussian exactly over piecewise-linear segments of Y,
29//! while SAMMY uses the Modsmp/Modfpl point rules — both discretize the
30//! same Eq. III B1.7 integral.
31//! Two analytic consequences (both pinned by
32//! `kernel_error_scales_pinned_vs_full_fgm_reference`): a constant σ is
33//! broadened to σ·(1 + u²/2v²) — the physical low-energy upturn — and a
34//! 1/v cross-section is preserved exactly.  (An earlier revision omitted
35//! the w/v weight, which skewed Doppler-broadened resonance flanks by a
36//! first-order ~u/v; the pinning test fails loudly on any regression to
37//! that kernel.)
38//!
39//! The key advantage of the velocity-space formulation is that u is
40//! independent of energy, making it a true convolution.
41//!
42//! ## Doppler Width
43//!
44//! The SAMMY Doppler width at energy E is:
45//!   Δ_D(E) = √(4·k_B·T·E / AWR)
46
47use std::fmt;
48
49use nereids_core::constants::{self, DIVISION_FLOOR, NEAR_ZERO_FLOOR};
50
51use crate::resolution::exerfc;
52
53/// Number of standard deviations beyond the velocity range for the FGM
54/// integration window.  The Gaussian kernel exp(-arg²) contributes less
55/// than exp(-36) ≈ 2.3e-16 outside this window, which is below f64
56/// machine epsilon.
57const DOPPLER_N_SIGMA: f64 = 6.0;
58
59/// Floor for distinguishing negative-velocity grid points from zero.
60///
61/// When building the extended velocity grid for the FGM integral, we
62/// generate points from `v_neg_limit` up to (but not including) zero.
63/// This threshold prevents the last negative-velocity point from being
64/// so close to zero that it is numerically indistinguishable, which would
65/// create a near-duplicate of the explicit v = 0 anchor point.
66const NEGATIVE_VELOCITY_FLOOR: f64 = 1e-15;
67
68/// Errors from `DopplerParams` construction.
69#[derive(Debug, PartialEq)]
70pub enum DopplerParamsError {
71    /// AWR must be strictly positive.
72    InvalidAwr(f64),
73    /// Temperature must be finite (may be zero for "no broadening").
74    NonFiniteTemperature(f64),
75    /// Temperature must be non-negative (negative Kelvin is physically meaningless).
76    NegativeTemperature(f64),
77}
78
79impl fmt::Display for DopplerParamsError {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        match self {
82            Self::InvalidAwr(v) => write!(f, "AWR must be positive, got {v}"),
83            Self::NonFiniteTemperature(v) => write!(f, "temperature must be finite, got {v}"),
84            Self::NegativeTemperature(v) => {
85                write!(f, "temperature must be non-negative, got {v}")
86            }
87        }
88    }
89}
90
91impl std::error::Error for DopplerParamsError {}
92
93/// Errors from Doppler broadening computation (not parameter construction).
94///
95/// Marked `#[non_exhaustive]` because this enum is publicly exported from
96/// `nereids-physics` and may grow new validation variants over time (e.g. if
97/// future contracts add bounds on AWR/energy combinations). Without the
98/// attribute, adding a variant would be a SemVer-breaking change for any
99/// downstream crate that exhaustively matches on `DopplerError`.
100#[derive(Debug)]
101#[non_exhaustive]
102pub enum DopplerError {
103    /// Energy and cross-section arrays have different lengths.
104    LengthMismatch {
105        /// Number of energy points.
106        energies: usize,
107        /// Number of cross-section values.
108        cross_sections: usize,
109    },
110    /// An energy value is non-finite (NaN/±∞) or non-positive (≤ 0).
111    ///
112    /// The FGM velocity transform computes `v = √E`, so non-positive or
113    /// non-finite energies produce NaN velocities that silently propagate
114    /// through the convolution. Per-point guards in the convolution loop
115    /// rely on `v < FLOOR` comparisons which evaluate to `false` for NaN
116    /// (see "NaN bypasses guards" project convention), so the function
117    /// would return wrong outputs rather than erroring. The contract is
118    /// "every energy is finite and strictly positive."
119    InvalidEnergy {
120        /// Position in the energy array where the bad value was found.
121        index: usize,
122        /// The offending energy value.
123        value: f64,
124    },
125    /// The energy grid is not strictly increasing at `index`.
126    ///
127    /// `doppler_broaden` uses `partition_point` over the extended velocity
128    /// grid (built from `energies` via `v = √E`), which has an unspecified
129    /// return value on an unsorted slice and therefore would silently
130    /// produce garbage indices in release builds. The contract is
131    /// "energies are strictly ascending"; duplicate points are also rejected.
132    UnsortedEnergies {
133        /// Position where the strict-ascending invariant was first violated.
134        index: usize,
135        /// The previous (smaller-index) energy value.
136        previous: f64,
137        /// The current (larger-index) energy value that broke the invariant.
138        current: f64,
139    },
140}
141
142impl fmt::Display for DopplerError {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        match self {
145            Self::LengthMismatch {
146                energies,
147                cross_sections,
148            } => write!(
149                f,
150                "energies length ({energies}) must match cross_sections length ({cross_sections})"
151            ),
152            Self::InvalidEnergy { index, value } => write!(
153                f,
154                "energies[{index}] = {value} is not finite or not strictly positive (Doppler broadening requires every energy to satisfy is_finite() && > 0)"
155            ),
156            Self::UnsortedEnergies {
157                index,
158                previous,
159                current,
160            } => write!(
161                f,
162                "energies[{index}] = {current} is not strictly greater than energies[{}] = {previous} (Doppler broadening requires the energy grid to be strictly ascending)",
163                index.saturating_sub(1)
164            ),
165        }
166    }
167}
168
169impl std::error::Error for DopplerError {}
170
171/// Validate that `energies` satisfies the Doppler-broadening grid contract:
172/// every entry is finite, strictly positive, and strictly greater than the
173/// previous entry. An empty slice is permitted (the caller has its own
174/// length-handling fast path).
175///
176/// The check is O(n) and is run unconditionally on every entry to
177/// `doppler_broaden` and `doppler_broaden_with_derivative` so that
178/// malformed grids surface as a typed `Err` rather than silent NaN
179/// propagation or unspecified `partition_point` behaviour.
180fn validate_doppler_grid(energies: &[f64]) -> Result<(), DopplerError> {
181    for (i, &e) in energies.iter().enumerate() {
182        if !e.is_finite() || e <= 0.0 {
183            return Err(DopplerError::InvalidEnergy { index: i, value: e });
184        }
185        if i > 0 {
186            // Safe to use `e <= prev` here: the `is_finite()` check above
187            // already rejected any NaN entries, so the partial-ord comparison
188            // is total. (NaN comparisons returning false would otherwise
189            // silently let NaN through this branch.)
190            let prev = energies[i - 1];
191            if e <= prev {
192                return Err(DopplerError::UnsortedEnergies {
193                    index: i,
194                    previous: prev,
195                    current: e,
196                });
197            }
198        }
199    }
200    Ok(())
201}
202
203/// Doppler broadening parameters.
204#[derive(Debug, Clone, Copy)]
205pub struct DopplerParams {
206    /// Effective sample temperature in Kelvin.
207    temperature_k: f64,
208    /// Atomic weight ratio (target mass / neutron mass) from ENDF.
209    awr: f64,
210}
211
212impl DopplerParams {
213    /// Create validated Doppler parameters.
214    ///
215    /// # Errors
216    /// Returns `DopplerParamsError::InvalidAwr` if `awr <= 0.0` or is NaN.
217    /// Returns `DopplerParamsError::NonFiniteTemperature` if `temperature_k`
218    /// is NaN or infinity.
219    /// Returns `DopplerParamsError::NegativeTemperature` if `temperature_k < 0.0`.
220    /// Zero temperature is allowed — it means "no broadening".
221    pub fn new(temperature_k: f64, awr: f64) -> Result<Self, DopplerParamsError> {
222        if !awr.is_finite() || awr <= 0.0 {
223            return Err(DopplerParamsError::InvalidAwr(awr));
224        }
225        if !temperature_k.is_finite() {
226            return Err(DopplerParamsError::NonFiniteTemperature(temperature_k));
227        }
228        if temperature_k < 0.0 {
229            return Err(DopplerParamsError::NegativeTemperature(temperature_k));
230        }
231        Ok(Self { temperature_k, awr })
232    }
233
234    /// Returns the effective sample temperature in Kelvin.
235    #[must_use]
236    pub fn temperature_k(&self) -> f64 {
237        self.temperature_k
238    }
239
240    /// Returns the atomic weight ratio (target mass / neutron mass).
241    #[must_use]
242    pub fn awr(&self) -> f64 {
243        self.awr
244    }
245
246    /// Velocity-space Doppler width u = √(k_B·T / AWR).
247    ///
248    /// This is the standard deviation of the Gaussian kernel in √eV units.
249    #[must_use]
250    pub fn u(&self) -> f64 {
251        (constants::BOLTZMANN_EV_PER_K * self.temperature_k / self.awr).sqrt()
252    }
253
254    /// Energy-dependent Doppler width Δ_D(E) = √(4·k_B·T·E / AWR).
255    ///
256    /// This is the width that SAMMY reports in the .lpt file.
257    #[must_use]
258    pub fn doppler_width(&self, energy_ev: f64) -> f64 {
259        (4.0 * constants::BOLTZMANN_EV_PER_K * self.temperature_k * energy_ev / self.awr).sqrt()
260    }
261}
262
263/// √π constant for erfc computation.
264const SQRT_PI: f64 = 1.772_453_850_905_516;
265
266/// Complementary error function erfc(x) = 1 - erf(x).
267///
268/// For x ≥ 0: uses the scaled complementary error function `exerfc`
269/// (SAMMY `fnc/exerfc.f90`):
270///   erfc(x) = exp(-x²) · exerfc(x) / √π
271///
272/// For x < 0: uses the identity erfc(-|x|) = 2 - erfc(|x|) to avoid
273/// the `exerfc` negative-argument branch, which has a numerical issue
274/// for |x| > 5.01 (missing exp(x²) factor in the large-|x| path).
275fn erfc_val(x: f64) -> f64 {
276    if x >= 0.0 {
277        (-x * x).exp() * exerfc(x) / SQRT_PI
278    } else {
279        let xp = -x;
280        2.0 - (-xp * xp).exp() * exerfc(xp) / SQRT_PI
281    }
282}
283
284/// Build the extended velocity grid and the FGM integrand Y(w) = w²·s(w)
285/// shared by [`doppler_broaden`] and [`doppler_broaden_with_derivative`] —
286/// one implementation so the forward and derivative paths cannot diverge.
287///
288/// From Eq. III B1.6: s(w) = σ(w²) for w > 0 and −σ(w²) for w < 0, so Y is
289/// an ODD function passing smoothly through Y(0) = 0.  The grid is the
290/// caller's velocity nodes plus:
291///
292/// - the negative-velocity image branch (Y(w) = −w²·σ(w²)) and the v = 0
293///   anchor, when the Doppler window crosses zero;
294/// - a low-side positive extension over (max(v_min − 6u, 0), v_min) with
295///   any dv_lo-spaced nodes that fit, so the lowest output windows are not
296///   truncated at the data edge.  SAMMY's FGM grid is likewise padded
297///   below the data range (manual Sec. III.B.1: "Negative velocities are
298///   included as needed, in order to properly evaluate the integral at low
299///   values of E"; `dat/mdat4.f90` Escale — the bType==2 velocity-spaced
300///   grid — with `Vstart` building the negative-velocity nodes);
301/// - a high-side extension up to v_max + 6u.
302///
303/// σ beyond the data grid follows `interpolate_cross_section`'s 1/v
304/// extrapolation, which keeps physical 1/v-like edges exact
305/// (Y(w) = w²·(c/w) = c·w stays linear).  On grids so sparse that no
306/// padding node fits (dv ≥ 6u), the convolution loops pass the affected
307/// points through unbroadened instead — see the sparse-grid guard there.
308fn build_extended_fgm_grid(
309    energies: &[f64],
310    cross_sections: &[f64],
311    velocities: &[f64],
312    u: f64,
313) -> (Vec<f64>, Vec<f64>) {
314    let n = velocities.len();
315    let v_min = velocities[0];
316    let v_neg_limit = v_min - DOPPLER_N_SIGMA * u;
317
318    let dv_lo = if n > 1 {
319        (velocities[1] - velocities[0]).max(u * 0.1)
320    } else {
321        u * 0.5
322    };
323    let dv_hi = if n > 1 {
324        (velocities[n - 1] - velocities[n - 2]).max(u * 0.1)
325    } else {
326        u * 0.5
327    };
328    let n_neg = if v_neg_limit < 0.0 {
329        // Points from v_neg_limit to just below zero, plus the v=0 anchor.
330        (((-v_neg_limit - NEGATIVE_VELOCITY_FLOOR) / dv_lo).ceil() as usize).saturating_add(1)
331    } else {
332        0
333    };
334    let v_max = velocities[n - 1];
335    let v_max_limit = v_max + DOPPLER_N_SIGMA * u;
336    let n_hi = if v_max < v_max_limit {
337        ((v_max_limit - v_max) / dv_hi).ceil() as usize
338    } else {
339        0
340    };
341    let n_low = (((v_min - v_neg_limit.max(0.0)).max(0.0) / dv_lo).ceil() as usize) + 1;
342    let capacity = n_neg + n_low + n + n_hi;
343    let mut ext_v: Vec<f64> = Vec::with_capacity(capacity);
344    let mut ext_y: Vec<f64> = Vec::with_capacity(capacity);
345
346    if v_neg_limit < 0.0 {
347        // Negative-velocity image branch, in the same spacing as the
348        // low-energy end of the positive grid (uniform dv in velocity).
349        let mut v = v_neg_limit;
350        while v < -NEGATIVE_VELOCITY_FLOOR {
351            ext_v.push(v);
352            // Y(w) = -w² * σ(w²) for negative w (odd integrand);
353            // σ at E = w² — interpolate from the positive grid.
354            let e = v * v;
355            let sigma = interpolate_cross_section(energies, cross_sections, e);
356            ext_y.push(-(v * v) * sigma);
357            v += dv_lo;
358        }
359
360        // Add v = 0 point
361        ext_v.push(0.0);
362        ext_y.push(0.0);
363    }
364
365    // Low-side positive extension (see the fn doc).
366    {
367        let lower_bound = v_neg_limit.max(NEGATIVE_VELOCITY_FLOOR);
368        let mut low_nodes: Vec<f64> = Vec::new();
369        let mut k = 1usize;
370        loop {
371            let v = v_min - (k as f64) * dv_lo;
372            if v <= lower_bound {
373                break;
374            }
375            low_nodes.push(v);
376            k += 1;
377        }
378        for &v in low_nodes.iter().rev() {
379            let e = v * v;
380            let sigma = interpolate_cross_section(energies, cross_sections, e);
381            ext_v.push(v);
382            ext_y.push(v * v * sigma);
383        }
384    }
385
386    // The caller's positive velocity points.
387    for i in 0..n {
388        ext_v.push(velocities[i]);
389        ext_y.push(velocities[i] * velocities[i] * cross_sections[i]);
390    }
391
392    // High-side extension beyond the highest velocity.
393    if v_max < v_max_limit {
394        let mut v = v_max + dv_hi;
395        while v <= v_max_limit {
396            ext_v.push(v);
397            let e = v * v;
398            let sigma = interpolate_cross_section(energies, cross_sections, e);
399            ext_y.push(v * v * sigma);
400            v += dv_hi;
401        }
402    }
403
404    (ext_v, ext_y)
405}
406
407/// Apply FGM Doppler broadening to cross-section data.
408///
409/// The cross-sections are broadened in velocity space using the exact
410/// Free Gas Model integral from SAMMY manual Eq. III B1.7 (w²-weighted
411/// integrand; see the module docs).
412///
413/// # Edge behavior
414/// Within ~6u (in velocity, u = √(k_B·T/AWR)) of either end of the grid,
415/// the convolution depends on σ beyond the supplied grid, which is
416/// extrapolated by the 1/v law — exact for physical 1/v-like tails; a
417/// constant σ deviates by the extrapolation mismatch (≲ u/v relative) at
418/// the outermost points.  Edge points whose window is both truncated by
419/// the grid AND under-resolved (fewer than 3 nodes inside the 6u window)
420/// are returned unbroadened, matching SAMMY (`fgm/mfgm1.f90`: "IF too few
421/// points, do not broaden"); interior under-resolved points broaden
422/// normally (the kernel degenerates smoothly toward a delta).
423///
424/// # Arguments
425/// * `energies` — Energy grid in eV. Every entry must satisfy
426///   `is_finite() && > 0.0`, and the grid must be **strictly ascending**
427///   (duplicates are rejected). The contract is enforced at the public
428///   boundary by `validate_doppler_grid`.
429/// * `cross_sections` — Unbroadened cross-sections in barns at each energy point.
430/// * `params` — Doppler broadening parameters (temperature and AWR).
431///
432/// # Returns
433/// Doppler-broadened cross-sections in barns on the same energy grid.
434///
435/// # Errors
436/// * `DopplerError::LengthMismatch` if `energies.len() != cross_sections.len()`.
437/// * `DopplerError::InvalidEnergy` if any energy is non-finite or ≤ 0.
438/// * `DopplerError::UnsortedEnergies` if the grid is not strictly ascending.
439///
440/// # Algorithm
441/// 1. Convert energy grid to velocity space (v = √E).
442/// 2. Build extended grid including negative velocities for the FGM integral.
443/// 3. Compute the integrand Y(w) = w² · s(w) on the extended grid.
444/// 4. For each output velocity, evaluate the Gaussian convolution integral.
445/// 5. Transform back: σ_D(E) = result / E.
446pub fn doppler_broaden(
447    energies: &[f64],
448    cross_sections: &[f64],
449    params: &DopplerParams,
450) -> Result<Vec<f64>, DopplerError> {
451    if energies.len() != cross_sections.len() {
452        return Err(DopplerError::LengthMismatch {
453            energies: energies.len(),
454            cross_sections: cross_sections.len(),
455        });
456    }
457
458    // Validate the energy-grid contract before any sqrt / partition_point /
459    // interpolation work. Without this guard, NaN energies would silently
460    // produce NaN velocities (and the per-point `v < FLOOR` check evaluates
461    // to false for NaN, allowing NaN to enter the convolution kernel), and
462    // unsorted grids would give unspecified `partition_point` indices.
463    validate_doppler_grid(energies)?;
464
465    if params.temperature_k() <= 0.0 || energies.is_empty() {
466        return Ok(cross_sections.to_vec());
467    }
468
469    let u = params.u();
470    if u < NEAR_ZERO_FLOOR {
471        return Ok(cross_sections.to_vec());
472    }
473
474    let n = energies.len();
475
476    // Convert to velocity grid: v_i = sqrt(E_i)
477    let velocities: Vec<f64> = energies.iter().map(|&e| e.sqrt()).collect();
478
479    // Integrand Y(w) = w² · s(w) (Eq. III B1.7 with the w/v weight folded
480    // in; the 1/v is applied at the end as the 1/E division) on the shared
481    // extended grid.
482    let (ext_v, ext_y) = build_extended_fgm_grid(energies, cross_sections, &velocities, u);
483
484    let n_ext = ext_v.len();
485    let mut n_passthrough = 0usize;
486
487    // The extended velocity grid must be sorted ascending (negative → 0 → positive)
488    // for the partition_point binary searches below to work correctly.
489    debug_assert!(
490        ext_v.windows(2).all(|w| w[0] <= w[1]),
491        "ext_v must be sorted ascending for partition_point"
492    );
493
494    // For each output energy point, compute the broadened cross-section
495    // using piecewise-linear interpolation of Y(w) = w²·s(w) combined
496    // with exact Gaussian integration over each segment.
497    //
498    // SAMMY Ref: `fgm/mfgm2.f90` Modsmp (linear), Modfpl (4-point Lagrange).
499    // Our PW-linear approach matches Modsmp's 2-point interpolation with
500    // analytical Gaussian integration via Abcerf/Abcexp.
501    //
502    // For each segment [w_j, w_{j+1}], the integrand Y is approximated as:
503    //   Y(w) ≈ Y_j + slope × (w − w_j)
504    //
505    // The exact integral of G(v,w) × Y_linear(w) dw over the segment is:
506    //   u × [C_j × J₀ − u × slope × J₁]
507    //
508    // where C_j = Y_j + slope × (v − w_j), and:
509    //   J₀ = ∫ exp(−t²) dt = (√π/2)(erfc(b_{j+1}) − erfc(b_j))
510    //   J₁ = ∫ t·exp(−t²) dt = [exp(−b_{j+1}²) − exp(−b_j²)] / 2
511    //   b_j = (v − w_j) / u
512    //
513    // This provides second-order accuracy (error ∝ h²) compared to the
514    // zeroth-order Voronoi cell approach (error ∝ h).
515
516    let mut broadened = vec![0.0f64; n];
517
518    for i in 0..n {
519        let v = velocities[i];
520        let e = energies[i];
521        if v < NEAR_ZERO_FLOOR || e < NEAR_ZERO_FLOOR {
522            broadened[i] = cross_sections[i];
523            continue;
524        }
525
526        // O(N×W) optimisation: binary search restricts the inner loop to the
527        // Gaussian window [v − n_sigma·u, v + n_sigma·u].
528        let v_lo = v - DOPPLER_N_SIGMA * u;
529        let v_hi = v + DOPPLER_N_SIGMA * u;
530        let j_lo = ext_v.partition_point(|&w| w < v_lo);
531        let j_hi = ext_v.partition_point(|&w| w <= v_hi);
532
533        // Sparse EDGE passthrough (SAMMY `fgm/mfgm1.f90`: "IF too few
534        // points, do not broaden"): when the Gaussian window is truncated
535        // by the end of the extended grid AND holds fewer than 3 nodes,
536        // the one-sided J₁ slope term integrates a single coarse chord of
537        // w²·σ with no cancellation — up to ~2× error at a sparse low
538        // edge — so transfer the unbroadened value instead.  INTERIOR
539        // under-resolved windows (kernel narrower than the grid, u ≪ dv)
540        // must keep broadening: there the output sits on a node where
541        // C_j = Y_j exactly and the two-sided J₁ contributions cancel, so
542        // the result smoothly approaches the unbroadened value as u → 0 —
543        // and the temperature derivative stays well-defined for T-fits.
544        let window_truncated = v_lo < ext_v[0] || v_hi > ext_v[n_ext - 1];
545        if window_truncated && j_hi - j_lo < 3 {
546            broadened[i] = cross_sections[i];
547            n_passthrough += 1;
548            continue;
549        }
550
551        // PW-linear FGM integral: segment-by-segment exact integration.
552        //
553        // v² × σ_D(v²) = Σ [C_j × J₀_j − u × slope_j × J₁_j] / Σ J₀_j
554        // σ_D(E) = Σ[…] / (Σ J₀ × E)        (E = v²)
555        //
556        // SAMMY Ref: `fgm/mfgm2.f90` Modsmp lines 80-87 (linear weights
557        // with Abcerf B-coefficient = first moment correction; final
558        // weights carry the w² factor, lines 101/203) and `mfgm4.f90`
559        // (division by Em).
560        let mut sum_y = 0.0f64; // Numerator: Σ [C × J₀ − u × slope × J₁]
561        let mut sum_g = 0.0f64; // Denominator: Σ J₀
562
563        // Process segments [j, j+1] that overlap the Gaussian window.
564        let seg_lo = if j_lo > 0 { j_lo - 1 } else { j_lo };
565        let seg_hi = j_hi.min(n_ext - 1);
566
567        for j in seg_lo..seg_hi {
568            let w_j = ext_v[j];
569            let w_j1 = ext_v[j + 1];
570            let h_w = w_j1 - w_j;
571            if h_w < NEAR_ZERO_FLOOR {
572                continue;
573            }
574
575            // Scaled distances from target velocity.
576            let b_j = (v - w_j) / u;
577            let b_j1 = (v - w_j1) / u;
578
579            // J₀ = ∫_{b_{j+1}}^{b_j} exp(−t²) dt
580            //     = (√π/2)(erfc(b_{j+1}) − erfc(b_j))
581            let erfc_bj = erfc_val(b_j);
582            let erfc_bj1 = erfc_val(b_j1);
583            let j0 = SQRT_PI * 0.5 * (erfc_bj1 - erfc_bj);
584
585            if j0 < NEAR_ZERO_FLOOR {
586                continue;
587            }
588
589            // J₁ = ∫_{b_{j+1}}^{b_j} t·exp(−t²) dt
590            //     = [exp(−b_{j+1}²) − exp(−b_j²)] / 2
591            let j1 = ((-b_j1 * b_j1).exp() - (-b_j * b_j).exp()) * 0.5;
592
593            let y_j = ext_y[j];
594            let y_j1 = ext_y[j + 1];
595            let slope = (y_j1 - y_j) / h_w;
596
597            // C_j = Y_j + slope × (v − w_j) = Y_j + slope × u × b_j
598            let c_j = y_j + slope * u * b_j;
599
600            // Contribution: C × J₀ − u × slope × J₁
601            sum_y += c_j * j0 - u * slope * j1;
602            sum_g += j0;
603        }
604
605        if sum_g < DIVISION_FLOOR {
606            broadened[i] = cross_sections[i];
607            continue;
608        }
609
610        // σ_D(E) = Σ(C × J₀ − u × slope × J₁) / (Σ J₀ × E)
611        broadened[i] = sum_y / (sum_g * e);
612
613        // Ensure non-negative
614        if broadened[i] < 0.0 {
615            broadened[i] = 0.0;
616        }
617    }
618
619    // SAMMY parity diagnostic (`fgm/mfgm1.f90:240`: "No Doppler broadening
620    // occured [sic] N times of a possible M" — spelling verbatim from the
621    // Fortran FORMAT statement): notify when the sparse-edge passthrough
622    // fired, ONCE per process — this function is hot under per-pixel
623    // spatial fits, so a per-call notice could flood stderr.  Dense
624    // production grids never trigger it.
625    if n_passthrough > 0 {
626        static SPARSE_PASSTHROUGH_NOTICE: std::sync::Once = std::sync::Once::new();
627        SPARSE_PASSTHROUGH_NOTICE.call_once(|| {
628            eprintln!(
629                "note: Doppler sparse-edge passthrough — {n_passthrough} of {n} point(s) \
630                 returned unbroadened (grid coarser than the Doppler window at the edge; \
631                 further occurrences in this process are not repeated)"
632            );
633        });
634    }
635
636    Ok(broadened)
637}
638
639/// Doppler-broaden cross-sections AND compute the analytical temperature
640/// derivative ∂σ_D/∂T in a single pass.
641///
642/// This computes the exact derivative by differentiating the FGM integral
643/// with respect to the Doppler width parameter u = √(k_B·T / AWR), then
644/// applying the chain rule: ∂σ_D/∂T = (∂σ_D/∂u) · u/(2T).
645///
646/// The derivative uses intermediate quantities already computed in the
647/// forward pass (b_k, exp(-b_k²), J₀, J₁, C_j, slope), adding only
648/// ~10 FLOPs per segment with NO extra broadening evaluations.
649///
650/// ## Mathematical Derivation
651///
652/// Per segment [w_j, w_{j+1}]:
653///   M₀_j = b_{j+1}·exp(-b_{j+1}²) - b_j·exp(-b_j²)
654///   M₁_j = b_{j+1}²·exp(-b_{j+1}²) - b_j²·exp(-b_j²)
655///   ∂I_j/∂u = (C_j/u)·M₀_j - slope_j·J₁_j - slope_j·M₁_j
656///
657/// Full result (quotient rule on sum_y / (sum_g · E), E = v² being
658/// temperature-independent):
659///   ∂σ_D/∂T = u/(2T·E) · (dsum_y·sum_g - sum_y·dsum_g) / sum_g²
660///
661/// SAMMY uses finite differences for this (mfgm4.f90 Xdofgm, Del=0.02).
662/// Our analytical approach is exact and avoids the 3× broadening cost.
663///
664/// # Arguments
665/// * `energies` — Energy grid in eV. Same contract as [`doppler_broaden`]:
666///   every entry must be finite and strictly positive, and the grid must
667///   be strictly ascending. The first `doppler_broaden` call below
668///   propagates the validation error through the `?` operator.
669/// * `cross_sections` — Unbroadened cross-sections in barns at each energy point.
670/// * `params` — Doppler broadening parameters (temperature and AWR).
671///
672/// # Errors
673/// Returns the same `DopplerError` variants as [`doppler_broaden`].
674pub fn doppler_broaden_with_derivative(
675    energies: &[f64],
676    cross_sections: &[f64],
677    params: &DopplerParams,
678) -> Result<(Vec<f64>, Vec<f64>), DopplerError> {
679    // First, compute the broadened values using the SAME code path as
680    // doppler_broaden to guarantee identical forward-pass results.
681    let broadened = doppler_broaden(energies, cross_sections, params)?;
682
683    let n = energies.len();
684    if n == 0 {
685        return Ok((broadened, vec![]));
686    }
687    if params.temperature_k < NEAR_ZERO_FLOOR {
688        return Ok((broadened, vec![0.0; n]));
689    }
690
691    let u = params.u();
692    let temperature_k = params.temperature_k;
693
694    // The same extended grid as doppler_broaden — built by the shared
695    // helper, so the forward and derivative paths cannot diverge.  The
696    // cost is O(n) — negligible compared to the O(n × n_segments)
697    // integration.
698    let velocities: Vec<f64> = energies.iter().map(|&e| e.sqrt()).collect();
699    let (ext_v, ext_y) = build_extended_fgm_grid(energies, cross_sections, &velocities, u);
700
701    let n_ext = ext_v.len();
702
703    // Compute the derivative in a second pass over the same grid.
704    let mut derivative = vec![0.0f64; n];
705
706    for i in 0..n {
707        let v = velocities[i];
708        let e = energies[i];
709        if v < NEAR_ZERO_FLOOR || e < NEAR_ZERO_FLOOR {
710            derivative[i] = 0.0;
711            continue;
712        }
713
714        let v_lo = v - DOPPLER_N_SIGMA * u;
715        let v_hi = v + DOPPLER_N_SIGMA * u;
716        let j_lo = ext_v.partition_point(|&w| w < v_lo);
717        let j_hi = ext_v.partition_point(|&w| w <= v_hi);
718
719        // Sparse EDGE passthrough — same guard as doppler_broaden: points
720        // returned unbroadened are temperature-independent, so their
721        // derivative is exactly zero.
722        let window_truncated = v_lo < ext_v[0] || v_hi > ext_v[n_ext - 1];
723        if window_truncated && j_hi - j_lo < 3 {
724            derivative[i] = 0.0;
725            continue;
726        }
727
728        // Re-integrate to get sum_y and sum_g (needed for quotient rule).
729        // Also accumulate derivative terms in the same loop.
730        let mut sum_y = 0.0f64;
731        let mut sum_g = 0.0f64;
732        let mut dsum_y = 0.0f64;
733        let mut sum_m0 = 0.0f64;
734
735        let seg_lo = if j_lo > 0 { j_lo - 1 } else { j_lo };
736        let seg_hi = j_hi.min(n_ext - 1);
737
738        for j in seg_lo..seg_hi {
739            let w_j = ext_v[j];
740            let w_j1 = ext_v[j + 1];
741            let h_w = w_j1 - w_j;
742            if h_w < NEAR_ZERO_FLOOR {
743                continue;
744            }
745
746            let b_j = (v - w_j) / u;
747            let b_j1 = (v - w_j1) / u;
748
749            let erfc_bj = erfc_val(b_j);
750            let erfc_bj1 = erfc_val(b_j1);
751            let j0 = SQRT_PI * 0.5 * (erfc_bj1 - erfc_bj);
752
753            if j0 < NEAR_ZERO_FLOOR {
754                continue;
755            }
756
757            let exp_bj = (-b_j * b_j).exp();
758            let exp_bj1 = (-b_j1 * b_j1).exp();
759            let j1 = (exp_bj1 - exp_bj) * 0.5;
760
761            let y_j = ext_y[j];
762            let y_j1 = ext_y[j + 1];
763            let slope = (y_j1 - y_j) / h_w;
764            let c_j = y_j + slope * (v - w_j);
765
766            // Forward accumulators (for quotient rule denominator).
767            sum_y += c_j * j0 - u * slope * j1;
768            sum_g += j0;
769
770            // Derivative terms.
771            let m0 = b_j1 * exp_bj1 - b_j * exp_bj;
772            let m1 = b_j1 * b_j1 * exp_bj1 - b_j * b_j * exp_bj;
773            dsum_y += (c_j / u) * m0 - slope * j1 - slope * m1;
774            sum_m0 += m0;
775        }
776
777        if sum_g < DIVISION_FLOOR {
778            derivative[i] = 0.0;
779            continue;
780        }
781
782        // ∂σ_D/∂T = (u · dsum_y · sum_g - sum_y · sum_m0) / (2T · E · sum_g²)
783        let numerator = u * dsum_y * sum_g - sum_y * sum_m0;
784        let denominator = 2.0 * temperature_k * e * sum_g * sum_g;
785        if denominator.abs() > NEAR_ZERO_FLOOR {
786            derivative[i] = numerator / denominator;
787        } else {
788            derivative[i] = 0.0;
789        }
790    }
791
792    Ok((broadened, derivative))
793}
794
795/// Linear interpolation of cross-section at an arbitrary energy.
796///
797/// Unlike `resolution::interp_spectrum` (which returns `None` for off-grid
798/// queries), this function extrapolates using the 1/v law.  A future
799/// consolidation could unify both behind a shared trait or closure-based
800/// extrapolation strategy; for now they remain separate to avoid coupling
801/// the two broadening modules.
802fn interpolate_cross_section(energies: &[f64], cross_sections: &[f64], energy: f64) -> f64 {
803    if energies.is_empty() {
804        return 0.0;
805    }
806
807    // Guard against NaN energy: NaN comparisons are always false, so the
808    // boundary checks below would both be skipped.  The binary search would
809    // then return Err(0), and `idx = 0 - 1` would underflow on usize.
810    if energy.is_nan() {
811        return 0.0;
812    }
813
814    if energy <= energies[0] {
815        // Extrapolate using 1/v law: σ ∝ 1/√E.
816        // Guard: if energy <= 0, the ratio energies[0]/energy would be negative
817        // or infinite, producing NaN from sqrt.  Return the boundary value directly.
818        if energy <= 0.0 {
819            return cross_sections[0];
820        }
821        if energies[0] > NEAR_ZERO_FLOOR {
822            return cross_sections[0] * (energies[0] / energy).sqrt();
823        }
824        return cross_sections[0];
825    }
826
827    if energy >= energies[energies.len() - 1] {
828        // Extrapolate using 1/v law
829        let last = energies.len() - 1;
830        if energy > NEAR_ZERO_FLOOR {
831            return cross_sections[last] * (energies[last] / energy).sqrt();
832        }
833        return cross_sections[last];
834    }
835
836    // Binary search for the interval.
837    // Use total_cmp-style fallback to avoid panic on NaN comparisons.
838    // With the current comparator (NaNs treated as Ordering::Less), NaN
839    // values in the energy grid are pushed to the right, so Err(0) should
840    // not occur in normal operation. The Err(0) arm is kept as a
841    // defense-in-depth guard: if the NaN guard on `energy` is ever removed
842    // or the comparator behavior changes and Err(0) becomes possible, we
843    // avoid `0 - 1` underflow on usize by returning the first cross-section.
844    let idx = match energies
845        .binary_search_by(|e| e.partial_cmp(&energy).unwrap_or(std::cmp::Ordering::Less))
846    {
847        Ok(i) => return cross_sections[i],
848        Err(0) => return cross_sections[0],
849        Err(i) => i - 1,
850    };
851
852    // Linear interpolation.
853    // Guard against duplicate energy grid points: if e0 == e1 (or nearly so),
854    // no interpolation is needed — use the value at that point directly.
855    // Use a combined relative+absolute threshold that works across the full
856    // energy range (meV to MeV): |de| < |e0|·ε_mach + NEAR_ZERO_FLOOR.
857    // The relative part handles large energies where f64::EPSILON alone would
858    // miss near-duplicates; the absolute part handles energies near zero.
859    // This is consistent with resolution.rs interp_spectrum.
860    let e0 = energies[idx];
861    let e1 = energies[idx + 1];
862    let s0 = cross_sections[idx];
863    let s1 = cross_sections[idx + 1];
864    let de = e1 - e0;
865    if de.abs() < e0.abs() * f64::EPSILON + NEAR_ZERO_FLOOR {
866        return s0;
867    }
868    let t = (energy - e0) / de;
869    s0 + t * (s1 - s0)
870}
871
872#[cfg(test)]
873mod tests {
874    use super::*;
875
876    // --- DopplerError Display rendering tests ---
877    //
878    // The Display impls use single-line format-string literals to avoid
879    // embedding indentation into the rendered error messages. These tests
880    // pin that contract: a stray `\<newline>    ` continuation in the
881    // literal would silently inject a run of spaces into the user-facing
882    // string and would only be caught by eyeballing log output.
883
884    #[test]
885    fn test_doppler_error_display_no_embedded_indentation() {
886        let e = DopplerError::InvalidEnergy {
887            index: 1,
888            value: f64::NAN,
889        };
890        let rendered = format!("{e}");
891        assert!(
892            !rendered.contains("  "),
893            "InvalidEnergy Display contains double-space (embedded indentation?): {rendered:?}"
894        );
895
896        let e = DopplerError::UnsortedEnergies {
897            index: 3,
898            previous: 4.0,
899            current: 2.5,
900        };
901        let rendered = format!("{e}");
902        assert!(
903            !rendered.contains("  "),
904            "UnsortedEnergies Display contains double-space (embedded indentation?): {rendered:?}"
905        );
906
907        let e = DopplerError::LengthMismatch {
908            energies: 5,
909            cross_sections: 4,
910        };
911        let rendered = format!("{e}");
912        assert!(
913            !rendered.contains("  "),
914            "LengthMismatch Display contains double-space (embedded indentation?): {rendered:?}"
915        );
916    }
917
918    // --- DopplerParams::new() validation tests ---
919
920    #[test]
921    fn test_new_negative_temperature_rejected() {
922        assert_eq!(
923            DopplerParams::new(-1.0, 238.0).unwrap_err(),
924            DopplerParamsError::NegativeTemperature(-1.0)
925        );
926    }
927
928    #[test]
929    fn test_new_nan_temperature_rejected() {
930        let err = DopplerParams::new(f64::NAN, 238.0).unwrap_err();
931        assert!(
932            matches!(err, DopplerParamsError::NonFiniteTemperature(v) if v.is_nan()),
933            "NaN temperature should return NonFiniteTemperature"
934        );
935    }
936
937    #[test]
938    fn test_new_infinity_temperature_rejected() {
939        assert_eq!(
940            DopplerParams::new(f64::INFINITY, 238.0).unwrap_err(),
941            DopplerParamsError::NonFiniteTemperature(f64::INFINITY)
942        );
943    }
944
945    #[test]
946    fn test_new_negative_awr_rejected() {
947        assert_eq!(
948            DopplerParams::new(300.0, -1.0).unwrap_err(),
949            DopplerParamsError::InvalidAwr(-1.0)
950        );
951    }
952
953    #[test]
954    fn test_new_zero_awr_rejected() {
955        assert_eq!(
956            DopplerParams::new(300.0, 0.0).unwrap_err(),
957            DopplerParamsError::InvalidAwr(0.0)
958        );
959    }
960
961    #[test]
962    fn test_new_nan_awr_rejected() {
963        let err = DopplerParams::new(300.0, f64::NAN).unwrap_err();
964        assert!(
965            matches!(err, DopplerParamsError::InvalidAwr(v) if v.is_nan()),
966            "NaN AWR should return InvalidAwr"
967        );
968    }
969
970    #[test]
971    fn test_new_zero_temperature_allowed() {
972        let params = DopplerParams::new(0.0, 238.0);
973        assert!(params.is_ok(), "zero temperature should be allowed");
974        let p = params.unwrap();
975        assert_eq!(p.temperature_k(), 0.0);
976        assert_eq!(p.awr(), 238.0);
977    }
978
979    #[test]
980    fn test_new_valid_params() {
981        let params = DopplerParams::new(300.0, 238.0);
982        assert!(params.is_ok(), "valid params should succeed");
983        let p = params.unwrap();
984        assert_eq!(p.temperature_k(), 300.0);
985        assert_eq!(p.awr(), 238.0);
986    }
987
988    // --- End validation tests ---
989
990    #[test]
991    fn test_doppler_width_u238() {
992        // SAMMY reports: Doppler width at 6.075 eV = 0.05159437 eV for U-238 at 300K
993        // AWR = 238.050972, T = 300 K
994        let params = DopplerParams::new(300.0, 238.050972).unwrap();
995        let dw = params.doppler_width(6.075);
996        // SAMMY uses kB = 0.000086173420 eV/K (slightly different from CODATA 2018)
997        // Our kB = 8.617333262e-5. The difference is ~0.003%.
998        // So we expect close but not exact match.
999        assert!(
1000            (dw - 0.05159437).abs() < 5e-4,
1001            "Doppler width = {}, expected ~0.05159",
1002            dw
1003        );
1004    }
1005
1006    #[test]
1007    fn test_doppler_width_fictitious() {
1008        // ex001: A=10, T=300K. Δ_D at 10 eV = √(4kBTE/AWR).
1009        // SAMMY reports Δ_D = 0.3216 eV, FWHM = 2√(ln2) × Δ_D = 0.5355 eV.
1010        // (SAMMY lpt uses slightly different kB, giving FWHM = 0.5378 eV.)
1011        let params = DopplerParams::new(300.0, 10.0).unwrap();
1012        let dw = params.doppler_width(10.0);
1013        // Δ_D = √(4 × 8.617e-5 × 300 × 10 / 10) = √(0.10341) ≈ 0.3216 eV
1014        assert!(
1015            (dw - 0.3216).abs() < 0.01,
1016            "Doppler width = {}, expected ~0.32",
1017            dw
1018        );
1019    }
1020
1021    #[test]
1022    fn test_zero_temperature() {
1023        // At T=0, broadening should return the original cross-sections.
1024        let energies = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1025        let xs = vec![10.0, 20.0, 30.0, 20.0, 10.0];
1026        let params = DopplerParams::new(0.0, 238.0).unwrap();
1027        let broadened = doppler_broaden(&energies, &xs, &params).unwrap();
1028        assert_eq!(broadened, xs);
1029    }
1030
1031    #[test]
1032    fn test_length_mismatch_error() {
1033        // Input-validation contract: mismatched array lengths are rejected
1034        // with the actual lengths echoed back — by the forward API and by
1035        // the derivative twin (which inherits the check through its
1036        // internal doppler_broaden call).
1037        let energies = vec![1.0, 2.0, 3.0];
1038        let xs = vec![10.0, 20.0];
1039        let params = DopplerParams::new(300.0, 238.0).unwrap();
1040
1041        let err = doppler_broaden(&energies, &xs, &params).unwrap_err();
1042        assert!(
1043            matches!(
1044                err,
1045                DopplerError::LengthMismatch {
1046                    energies: 3,
1047                    cross_sections: 2,
1048                }
1049            ),
1050            "unexpected error: {err:?}"
1051        );
1052
1053        let err = doppler_broaden_with_derivative(&energies, &xs, &params).unwrap_err();
1054        assert!(
1055            matches!(
1056                err,
1057                DopplerError::LengthMismatch {
1058                    energies: 3,
1059                    cross_sections: 2,
1060                }
1061            ),
1062            "unexpected error: {err:?}"
1063        );
1064    }
1065
1066    #[test]
1067    fn test_below_floor_u_identity_and_zero_derivative() {
1068        // A positive temperature so small that u = √(k_B·T/AWR) falls below
1069        // NEAR_ZERO_FLOOR means "numerically no broadening": the forward
1070        // call returns the input unchanged (an exact passthrough, not a
1071        // degenerate integration) and the derivative twin reports
1072        // ∂σ_D/∂T = 0 everywhere.
1073        let energies = vec![1.0, 2.0, 3.0];
1074        let xs = vec![10.0, 20.0, 15.0];
1075        let params = DopplerParams::new(1e-118, 238.0).unwrap();
1076        assert!(
1077            params.u() < NEAR_ZERO_FLOOR,
1078            "precondition: u = {:e} must be below NEAR_ZERO_FLOOR = {NEAR_ZERO_FLOOR:e}",
1079            params.u()
1080        );
1081
1082        let broadened = doppler_broaden(&energies, &xs, &params).unwrap();
1083        assert_eq!(broadened, xs);
1084
1085        let (broadened, derivative) =
1086            doppler_broaden_with_derivative(&energies, &xs, &params).unwrap();
1087        assert_eq!(broadened, xs);
1088        assert_eq!(derivative, vec![0.0; xs.len()]);
1089
1090        // Empty input is equally degenerate: both APIs return empty
1091        // vectors rather than erroring or panicking.
1092        let params_300 = DopplerParams::new(300.0, 238.0).unwrap();
1093        let (broadened, derivative) =
1094            doppler_broaden_with_derivative(&[], &[], &params_300).unwrap();
1095        assert!(broadened.is_empty());
1096        assert!(derivative.is_empty());
1097    }
1098
1099    #[test]
1100    fn test_single_point_grid_preserves_one_over_v() {
1101        // A single-point grid takes the n == 1 fallback arms in
1102        // build_extended_fgm_grid (dv_lo = dv_hi = u/2): every other node
1103        // of the extended grid comes from interpolate_cross_section, whose
1104        // off-grid extrapolation is the 1/v law on both sides.  A pure 1/v
1105        // cross-section makes the integrand Y(w) = w²·σ(w²) = σ₀·v₀·w
1106        // globally linear and odd, for which two properties are analytic:
1107        //   * the FGM preserves 1/v: σ_D(v₀) = σ(v₀) (Eq. III B1.7 with
1108        //     Y linear — see the module docs),
1109        //   * ∂σ_D/∂T = 0: a 1/v shape is a temperature fixed point.
1110        // The PW-linear segment quadrature is exact for linear Y, so both
1111        // hold to roundoff; the ±6u window truncation contributes only
1112        // O(e⁻³⁰) relative.  Measured: σ_D = σ exactly in f64 (rel = 0),
1113        // ∂σ_D/∂T = 2.3e-19 b/K — the gates below leave ≥ 10⁸× headroom
1114        // while still catching any real quadrature or weighting defect.
1115        let energies = vec![1.0];
1116        let xs = vec![10.0];
1117        let params = DopplerParams::new(300.0, 10.0).unwrap();
1118
1119        let broadened = doppler_broaden(&energies, &xs, &params).unwrap();
1120        let rel = (broadened[0] - xs[0]).abs() / xs[0];
1121        assert!(
1122            rel < 1e-12,
1123            "1/v not preserved on a single-point grid: σ_D = {}, rel err {rel:e}",
1124            broadened[0]
1125        );
1126
1127        let (_broadened, derivative) =
1128            doppler_broaden_with_derivative(&energies, &xs, &params).unwrap();
1129        assert!(
1130            derivative[0].abs() < 1e-10,
1131            "∂σ_D/∂T must vanish for a 1/v cross-section, got {:e}",
1132            derivative[0]
1133        );
1134    }
1135
1136    #[test]
1137    fn test_sub_ulp_u_numerical_identity() {
1138        // u above NEAR_ZERO_FLOOR — so the integration path runs, not the
1139        // passthrough shortcut — but with 6u below one ulp of the velocity
1140        // grid: the window edges collapse onto the nodes (v ± 6u == v in
1141        // f64) and the extended grid gains no padding (v_max + 6u == v_max
1142        // exercises the n_hi = 0 arm).  The u → 0⁺ limit must stay
1143        // continuous: finite output, σ_D == σ to roundoff.  This is the
1144        // regime a fit drives the kernel into when the temperature
1145        // parameter runs to a very small bound.  Measured: σ_D = σ exactly
1146        // in f64 (rel = 0; the one-sided O(u·slope) residual ~ 1e-57 is
1147        // far below one ulp of σ).
1148        let energies = vec![1.0, 4.0];
1149        let xs = vec![10.0, 20.0];
1150        let params = DopplerParams::new(1e-110, 238.0).unwrap();
1151        let u = params.u();
1152        assert!(
1153            u >= NEAR_ZERO_FLOOR && DOPPLER_N_SIGMA * u < f64::EPSILON,
1154            "precondition: u = {u:e} must be ≥ NEAR_ZERO_FLOOR with 6u below one ulp"
1155        );
1156
1157        let broadened = doppler_broaden(&energies, &xs, &params).unwrap();
1158        for (i, (&b, &x)) in broadened.iter().zip(xs.iter()).enumerate() {
1159            let rel = (b - x).abs() / x;
1160            assert!(
1161                rel < 1e-12,
1162                "point {i}: σ_D = {b} vs σ = {x}, rel err {rel:e}"
1163            );
1164        }
1165    }
1166
1167    #[test]
1168    fn test_broadening_reduces_peak() {
1169        // Doppler broadening should reduce the peak height and spread it out.
1170        // Create a sharp resonance peak.
1171        let n = 201;
1172        let energies: Vec<f64> = (0..n).map(|i| 5.0 + (i as f64) * 0.05).collect();
1173        let center = 10.0;
1174        let gamma: f64 = 0.02; // narrow resonance
1175        let xs: Vec<f64> = energies
1176            .iter()
1177            .map(|&e| {
1178                let de = e - center;
1179                100.0 * (gamma / 2.0).powi(2) / (de * de + (gamma / 2.0).powi(2))
1180            })
1181            .collect();
1182
1183        let params = DopplerParams::new(300.0, 238.0).unwrap();
1184        let broadened = doppler_broaden(&energies, &xs, &params).unwrap();
1185
1186        // Find peaks
1187        let orig_peak = xs.iter().cloned().fold(0.0_f64, f64::max);
1188        let broad_peak = broadened.iter().cloned().fold(0.0_f64, f64::max);
1189
1190        assert!(
1191            broad_peak < orig_peak,
1192            "Broadened peak ({}) should be less than original ({})",
1193            broad_peak,
1194            orig_peak
1195        );
1196
1197        // The broadened peak should still be substantial (not wiped out)
1198        assert!(
1199            broad_peak > 0.1,
1200            "Broadened peak ({}) should still be positive",
1201            broad_peak
1202        );
1203    }
1204
1205    /// SAMMY ex001 validation: single resonance, A=10, T=300K, FGM Doppler.
1206    ///
1207    /// Reference: ex001a.lst (column 4 = theoretical Doppler-broadened capture σ)
1208    /// Par file: E₀ = 10 eV, Γγ = 1.0 meV, Γn = 0.5 meV
1209    /// SAMMY par file widths are in meV; we convert to eV (×0.001) for our code.
1210    /// AWR = 10.0, radius = 2.908 fm, T = 300 K
1211    #[test]
1212    fn test_sammy_ex001_fgm_doppler() {
1213        // Build the ex001 resonance data: single SLBW resonance at 10 eV,
1214        // ZA=1010, AWR=10.0, AP=2.908 fm (SAMMY par-file widths in meV are
1215        // pre-converted to eV inside `ex001_hydrogen_single_resonance`).
1216        let data = nereids_endf::resonance::test_support::ex001_hydrogen_single_resonance();
1217
1218        // Generate unbroadened cross-sections on a non-uniform grid.
1219        // The resonance is very narrow (Γ ≈ 1.5 meV) — we need fine spacing
1220        // near E₀ = 10 eV and coarser spacing in the wings.
1221        let mut energies: Vec<f64> = Vec::new();
1222        // Wings: 6.0 to 9.95 and 10.05 to 14.0 with 0.005 eV spacing
1223        let mut e = 6.0;
1224        while e < 9.95 {
1225            energies.push(e);
1226            e += 0.005;
1227        }
1228        // Core: 9.95 to 10.05 with 0.00005 eV spacing (resolves 1.5 meV resonance)
1229        while e < 10.05 {
1230            energies.push(e);
1231            e += 0.00005;
1232        }
1233        // Upper wing: 10.05 to 14.0
1234        while e <= 14.0 {
1235            energies.push(e);
1236            e += 0.005;
1237        }
1238        energies.sort_by(|a, b| a.partial_cmp(b).unwrap());
1239        energies.dedup();
1240        let unbroadened: Vec<f64> = energies
1241            .iter()
1242            .map(|&e| crate::slbw::slbw_cross_sections(&data, e).capture)
1243            .collect();
1244
1245        // Apply FGM Doppler broadening.
1246        let params = DopplerParams::new(300.0, 10.0).unwrap();
1247        let broadened = doppler_broaden(&energies, &unbroadened, &params).unwrap();
1248
1249        // SAMMY ex001a.lst reference points: (energy, broadened capture σ in barns).
1250        // Focus on the core region where our grid has good coverage.
1251        let sammy_ref = [
1252            (9.3594, 5.4125807788),    // lower shoulder
1253            (9.8572, 238.1729827317),  // near peak
1254            (9.9869, 285.6111456228),  // peak
1255            (10.0092, 285.2175881633), // just past peak
1256            (10.1282, 241.3304410052), // upper shoulder
1257            (10.3430, 91.4783098707),  // falling slope
1258            (10.5382, 18.3744223751),  // upper wing
1259        ];
1260
1261        // Interpolate our broadened result onto SAMMY energy points and compare.
1262        let mut max_rel_err = 0.0f64;
1263        for &(e_ref, sigma_ref) in &sammy_ref {
1264            let sigma_us = interpolate_cross_section(&energies, &broadened, e_ref);
1265            let rel_err = (sigma_us - sigma_ref).abs() / sigma_ref;
1266            max_rel_err = max_rel_err.max(rel_err);
1267        }
1268        eprintln!("ex001 FGM: max_rel_err={max_rel_err:.6}");
1269        // PW-linear segment integration differs from SAMMY's quadrature at
1270        // grid-spacing transitions (wing region).  Measured with the exact
1271        // w²-weighted kernel: 2.37%; the legacy w¹ kernel measured 5.48%
1272        // (the A=10 target makes u/v large, so the kernel's first-order
1273        // term was a visible part of the old error).
1274        assert!(
1275            max_rel_err < 0.03,
1276            "Max relative error = {:.2}% (exceeds 3%)",
1277            max_rel_err * 100.0
1278        );
1279
1280        // Check peak height specifically (should be close to 285.6 barns).
1281        let peak_idx = broadened
1282            .iter()
1283            .enumerate()
1284            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
1285            .unwrap()
1286            .0;
1287        let peak_energy = energies[peak_idx];
1288        let peak_sigma = broadened[peak_idx];
1289
1290        // Peak should be near 10 eV (slight shift to lower E due to 1/v weighting).
1291        assert!(
1292            (peak_energy - 9.99).abs() < 0.1,
1293            "Peak energy = {:.4}, expected near 9.99",
1294            peak_energy
1295        );
1296        assert!(
1297            (peak_sigma - 285.6).abs() < 30.0,
1298            "Peak σ = {:.2}, expected ~285.6",
1299            peak_sigma
1300        );
1301    }
1302
1303    #[test]
1304    fn test_broadening_conserves_area() {
1305        // Doppler broadening should approximately conserve the area under
1306        // the cross-section curve (energy × cross-section is conserved).
1307        let n = 401;
1308        let energies: Vec<f64> = (0..n).map(|i| 1.0 + (i as f64) * 0.05).collect();
1309        let center = 10.0;
1310        let gamma: f64 = 0.5;
1311        let xs: Vec<f64> = energies
1312            .iter()
1313            .map(|&e| {
1314                let de = e - center;
1315                1000.0 * (gamma / 2.0).powi(2) / (de * de + (gamma / 2.0).powi(2))
1316            })
1317            .collect();
1318
1319        let params = DopplerParams::new(300.0, 100.0).unwrap();
1320        let broadened = doppler_broaden(&energies, &xs, &params).unwrap();
1321
1322        // Compute area (trapezoidal) for both
1323        let area_orig: f64 = (0..n - 1)
1324            .map(|i| 0.5 * (xs[i] + xs[i + 1]) * (energies[i + 1] - energies[i]))
1325            .sum();
1326        let area_broad: f64 = (0..n - 1)
1327            .map(|i| 0.5 * (broadened[i] + broadened[i + 1]) * (energies[i + 1] - energies[i]))
1328            .sum();
1329
1330        let rel_diff = (area_orig - area_broad).abs() / area_orig;
1331        assert!(
1332            rel_diff < 0.05,
1333            "Area not conserved: orig={}, broad={}, rel_diff={:.4}",
1334            area_orig,
1335            area_broad,
1336            rel_diff
1337        );
1338    }
1339
1340    /// NaN query energy: interpolate_cross_section must return 0.0 without
1341    /// panicking (the NaN guard at line 282 catches this).
1342    #[test]
1343    fn test_interpolate_nan_energy() {
1344        let energies = vec![1.0, 2.0, 3.0];
1345        let xs = vec![10.0, 20.0, 30.0];
1346        let result = interpolate_cross_section(&energies, &xs, f64::NAN);
1347        assert_eq!(result, 0.0, "NaN energy should return 0.0");
1348    }
1349
1350    /// Err(0) guard in binary search: if the binary search were to return
1351    /// Err(0) (insertion point = 0), `i - 1` would underflow on usize.
1352    /// The guard returns cross_sections[0] instead.
1353    ///
1354    /// This path is hard to trigger with well-formed grids (the boundary
1355    /// check `energy <= energies[0]` catches it first), but can occur if
1356    /// the grid or the comparison function behaves unexpectedly (e.g.
1357    /// NaN contamination with a different comparison strategy).  The guard
1358    /// is cheap defense-in-depth against arithmetic underflow.
1359    ///
1360    /// NOTE: This test exercises the `energy <= energies[0]` boundary path
1361    /// (1/v extrapolation), *not* the `Err(0)` binary-search guard itself.
1362    ///
1363    /// We test the NaN query guard separately (`test_interpolate_nan_energy`),
1364    /// the NaN grid guard separately (`test_interpolate_nan_grid_no_panic`),
1365    /// and the duplicate-point guard separately (`test_interpolate_duplicate_grid_points`).
1366    ///
1367    /// The `Err(0)` binary-search guard is primarily a defense-in-depth
1368    /// safety net against unexpected grid or comparison behavior.
1369    #[test]
1370    fn test_interpolate_below_grid_minimum() {
1371        let energies = vec![5.0, 10.0, 15.0];
1372        let xs = vec![50.0, 100.0, 150.0];
1373        // Energy below the grid minimum: hits the `energy <= energies[0]` guard
1374        // and returns via 1/v extrapolation, not the binary search.
1375        let result = interpolate_cross_section(&energies, &xs, 2.0);
1376        assert!(
1377            result.is_finite() && result > 0.0,
1378            "Below-grid query should return a finite positive value via 1/v extrapolation, got {result}"
1379        );
1380        // Check 1/v scaling: σ(2) ≈ σ(5) × √(5/2)
1381        let expected = 50.0 * (5.0 / 2.0_f64).sqrt();
1382        assert!(
1383            (result - expected).abs() < 1e-10,
1384            "Expected 1/v extrapolation: {expected}, got {result}"
1385        );
1386    }
1387
1388    /// Duplicate grid points: two adjacent energies are identical.
1389    /// The combined relative+absolute threshold must detect this and
1390    /// return the value at the duplicate point without division by zero.
1391    #[test]
1392    fn test_interpolate_duplicate_grid_points() {
1393        let energies = vec![1.0, 2.0, 2.0, 3.0];
1394        let xs = vec![10.0, 20.0, 25.0, 30.0];
1395        // Query at exactly 2.0 should hit the Ok(i) branch.
1396        let result = interpolate_cross_section(&energies, &xs, 2.0);
1397        assert!(
1398            (result - 20.0).abs() < 1e-10 || (result - 25.0).abs() < 1e-10,
1399            "At duplicate point 2.0, should return one of the boundary values, got {result}"
1400        );
1401        // Query at 2.0 + tiny epsilon should trigger the duplicate guard.
1402        let result2 = interpolate_cross_section(&energies, &xs, 2.0 + 1e-16);
1403        assert!(
1404            result2.is_finite(),
1405            "Near-duplicate query should return finite result, got {result2}"
1406        );
1407
1408        // Exercise the `de.abs() < |e0|*EPS + NEAR_ZERO_FLOOR` threshold
1409        // with near-zero adjacent energies where de is essentially zero.
1410        // With e0 = 1e-50, the relative term |e0|*EPS ≈ 2e-66 is smaller
1411        // than NEAR_ZERO_FLOOR (1e-60), so the absolute floor dominates.
1412        let tiny_energies = vec![1e-50, 1e-50 + 1e-105, 1.0];
1413        let tiny_xs = vec![100.0, 200.0, 300.0];
1414        // Query between the two near-zero points: de ≈ 1e-105 which is
1415        // far below the absolute threshold NEAR_ZERO_FLOOR (1e-60),
1416        // and the relative term (|1e-50| * EPS ≈ 2e-66) is even smaller,
1417        // so the absolute floor is the binding constraint.
1418        let result3 = interpolate_cross_section(&tiny_energies, &tiny_xs, 1e-50 + 5e-106);
1419        assert!(
1420            result3.is_finite(),
1421            "Near-zero de should be caught by the absolute threshold, got {result3}"
1422        );
1423        // Should return s0 (100.0) since the guard short-circuits.
1424        assert!(
1425            (result3 - 100.0).abs() < 1e-10,
1426            "Expected s0=100.0 from the de threshold guard, got {result3}"
1427        );
1428    }
1429
1430    /// NaN-contaminated energy grid: verify no panic occurs and the NaN
1431    /// query guard (line 282) protects against the `Err(0)` binary search
1432    /// underflow path (line 317).
1433    ///
1434    /// With the current comparator (`unwrap_or(Ordering::Less)`), NaN grid
1435    /// entries are treated as "less than" any query, pushing the binary
1436    /// search rightward.  This means NaN *in the grid* alone cannot produce
1437    /// `Err(0)` — it always produces `Err(k)` with k > 0.  However, a NaN
1438    /// *query* bypasses comparisons entirely and could reach `Err(0)` if the
1439    /// earlier NaN guard (line 282) were removed.  That guard returns 0.0
1440    /// before the binary search, making `Err(0)` unreachable in practice.
1441    ///
1442    /// The `Err(0)` match arm is therefore pure defense-in-depth against
1443    /// future comparator changes.  This test verifies:
1444    ///   1. NaN query → returns 0.0 (guard fires, `Err(0)` never reached).
1445    ///   2. NaN in grid → no panic (does not underflow).
1446    #[test]
1447    fn test_interpolate_nan_grid_no_panic() {
1448        let xs = vec![10.0, 20.0, 30.0];
1449
1450        // Case 1: NaN query on a clean grid — the NaN guard at line 282
1451        // returns 0.0 before reaching the binary search.  This is the only
1452        // code path that *would* hit Err(0) if the guard were absent.
1453        let clean_grid = vec![1.0, 2.0, 3.0];
1454        let result = interpolate_cross_section(&clean_grid, &xs, f64::NAN);
1455        assert_eq!(result, 0.0, "NaN query should return 0.0 via the guard");
1456
1457        // Case 2: NaN in the grid at position 0 — the boundary check
1458        // `energy <= energies[0]` is false (NaN comparison), so we fall
1459        // through to the binary search.  The search treats NaN as Less,
1460        // returning Err(k>0), so the Err(0) arm is NOT reached.  The
1461        // function should not panic.
1462        let nan_grid = vec![f64::NAN, 2.0, 3.0];
1463        let result2 = interpolate_cross_section(&nan_grid, &xs, 1.5);
1464        // Result may be NaN (interpolating with a NaN grid point), but
1465        // the important thing is no panic from usize underflow.
1466        let _ = result2; // just verify no panic
1467    }
1468
1469    // ── Milestone A: Analytical derivative validation ──
1470
1471    /// Helper: generate a simple resonance-like cross-section for testing.
1472    fn test_resonance_xs(energies: &[f64], e_res: f64, gamma: f64, peak: f64) -> Vec<f64> {
1473        energies
1474            .iter()
1475            .map(|&e| {
1476                let x = (e - e_res) / gamma;
1477                peak / (1.0 + x * x) + 10.0 // Breit-Wigner + constant
1478            })
1479            .collect()
1480    }
1481
1482    /// A1: Analytical derivative vs central FD for U-238 at 293.6K.
1483    #[test]
1484    fn test_analytical_derivative_vs_fd_u238_293k() {
1485        let energies: Vec<f64> = (0..200).map(|i| 1.0 + i as f64 * 0.05).collect();
1486        let xs = test_resonance_xs(&energies, 6.67, 0.025, 5000.0);
1487        let params = DopplerParams::new(293.6, 238.051).unwrap();
1488
1489        // Analytical derivative
1490        let (broadened, dxs_dt) = doppler_broaden_with_derivative(&energies, &xs, &params).unwrap();
1491
1492        // Central FD derivative
1493        let dt = 1e-4 * (1.0 + params.temperature_k);
1494        let params_up = DopplerParams::new(params.temperature_k + dt, params.awr).unwrap();
1495        let params_down =
1496            DopplerParams::new((params.temperature_k - dt).max(0.1), params.awr).unwrap();
1497        let actual_2dt = (params.temperature_k + dt) - (params.temperature_k - dt).max(0.1);
1498
1499        let xs_up = doppler_broaden(&energies, &xs, &params_up).unwrap();
1500        let xs_down = doppler_broaden(&energies, &xs, &params_down).unwrap();
1501
1502        // Use combined error metric: relative where derivative is significant,
1503        // absolute where derivative is small (avoiding catastrophic cancellation
1504        // in flat regions far from resonances — a known limitation of the
1505        // quotient-rule formulation when sum_y and dsum_g nearly cancel).
1506        let max_deriv: f64 = (0..energies.len())
1507            .map(|i| ((xs_up[i] - xs_down[i]) / actual_2dt).abs())
1508            .fold(0.0f64, f64::max);
1509        let abs_tol = max_deriv * 1e-4;
1510
1511        let mut max_rel_err = 0.0f64;
1512        let mut n_significant = 0;
1513        for i in 0..energies.len() {
1514            let fd = (xs_up[i] - xs_down[i]) / actual_2dt;
1515            if fd.abs() < 1e-15 {
1516                continue;
1517            }
1518            // For significant derivatives (> 1% of peak), check relative error.
1519            if fd.abs() > max_deriv * 0.01 {
1520                let rel_err = ((dxs_dt[i] - fd) / fd).abs();
1521                max_rel_err = max_rel_err.max(rel_err);
1522                n_significant += 1;
1523            } else {
1524                // For small derivatives, check absolute error.
1525                let abs_err = (dxs_dt[i] - fd).abs();
1526                assert!(
1527                    abs_err < abs_tol,
1528                    "E={:.3}: abs error {:.2e} exceeds tol {:.2e} (analytical={:.2e}, FD={:.2e})",
1529                    energies[i],
1530                    abs_err,
1531                    abs_tol,
1532                    dxs_dt[i],
1533                    fd
1534                );
1535            }
1536        }
1537        assert!(
1538            n_significant > 5,
1539            "need at least 5 significant derivative points, got {n_significant}"
1540        );
1541        assert!(
1542            max_rel_err < 1e-6,
1543            "analytical vs FD relative error (significant bins) = {max_rel_err:.2e}, expected < 1e-6"
1544        );
1545
1546        // Verify forward pass matches standalone doppler_broaden
1547        let broadened_ref = doppler_broaden(&energies, &xs, &params).unwrap();
1548        for i in 0..energies.len() {
1549            assert!(
1550                (broadened[i] - broadened_ref[i]).abs() < 1e-14,
1551                "forward pass mismatch at bin {i}: {} vs {}",
1552                broadened[i],
1553                broadened_ref[i]
1554            );
1555        }
1556    }
1557
1558    /// A2: Stability across temperature range (100K, 500K, 1000K).
1559    #[test]
1560    fn test_analytical_derivative_temperature_range() {
1561        let energies: Vec<f64> = (0..200).map(|i| 1.0 + i as f64 * 0.05).collect();
1562        let xs = test_resonance_xs(&energies, 6.67, 0.025, 5000.0);
1563
1564        for &temp in &[100.0, 500.0, 1000.0] {
1565            let params = DopplerParams::new(temp, 238.051).unwrap();
1566            let (_broadened, dxs_dt) =
1567                doppler_broaden_with_derivative(&energies, &xs, &params).unwrap();
1568
1569            // FD reference
1570            let dt = 1e-4 * (1.0 + temp);
1571            let p_up = DopplerParams::new(temp + dt, 238.051).unwrap();
1572            let p_down = DopplerParams::new((temp - dt).max(0.1), 238.051).unwrap();
1573            let actual_2dt = (temp + dt) - (temp - dt).max(0.1);
1574            let xs_up = doppler_broaden(&energies, &xs, &p_up).unwrap();
1575            let xs_down = doppler_broaden(&energies, &xs, &p_down).unwrap();
1576
1577            // Same combined metric as A1: relative for significant, absolute for small.
1578            let max_deriv: f64 = (0..energies.len())
1579                .map(|i| ((xs_up[i] - xs_down[i]) / actual_2dt).abs())
1580                .fold(0.0f64, f64::max);
1581            let mut max_rel_err = 0.0f64;
1582            for i in 0..energies.len() {
1583                let fd = (xs_up[i] - xs_down[i]) / actual_2dt;
1584                if fd.abs() < max_deriv * 0.01 {
1585                    continue; // skip small derivatives
1586                }
1587                max_rel_err = max_rel_err.max(((dxs_dt[i] - fd) / fd).abs());
1588            }
1589            assert!(
1590                max_rel_err < 1e-6,
1591                "T={temp}K: analytical vs FD max rel error = {max_rel_err:.2e}"
1592            );
1593        }
1594    }
1595
1596    /// A3: Different AWR (Hf-178, heavier nucleus).
1597    #[test]
1598    fn test_analytical_derivative_hf178() {
1599        let energies: Vec<f64> = (0..100).map(|i| 1.0 + i as f64 * 0.1).collect();
1600        let xs = test_resonance_xs(&energies, 7.8, 0.05, 3000.0);
1601        let params = DopplerParams::new(293.6, 177.95).unwrap();
1602
1603        let (_broadened, dxs_dt) =
1604            doppler_broaden_with_derivative(&energies, &xs, &params).unwrap();
1605
1606        let dt = 1e-4 * (1.0 + 293.6);
1607        let p_up = DopplerParams::new(293.6 + dt, 177.95).unwrap();
1608        let p_down = DopplerParams::new(293.6 - dt, 177.95).unwrap();
1609        let xs_up = doppler_broaden(&energies, &xs, &p_up).unwrap();
1610        let xs_down = doppler_broaden(&energies, &xs, &p_down).unwrap();
1611
1612        let max_deriv: f64 = (0..energies.len())
1613            .map(|i| ((xs_up[i] - xs_down[i]) / (2.0 * dt)).abs())
1614            .fold(0.0f64, f64::max);
1615        let mut max_rel_err = 0.0f64;
1616        for i in 0..energies.len() {
1617            let fd = (xs_up[i] - xs_down[i]) / (2.0 * dt);
1618            if fd.abs() < max_deriv * 0.01 {
1619                continue;
1620            }
1621            max_rel_err = max_rel_err.max(((dxs_dt[i] - fd) / fd).abs());
1622        }
1623        assert!(
1624            max_rel_err < 1e-6,
1625            "Hf-178: analytical vs FD max rel error = {max_rel_err:.2e}"
1626        );
1627    }
1628
1629    /// A4: Compare against SAMMY-style FD (±2% Doppler width perturbation).
1630    #[test]
1631    fn test_analytical_derivative_vs_sammy_style_fd() {
1632        let energies: Vec<f64> = (0..200).map(|i| 1.0 + i as f64 * 0.05).collect();
1633        let xs = test_resonance_xs(&energies, 6.67, 0.025, 5000.0);
1634        let params = DopplerParams::new(293.6, 238.051).unwrap();
1635
1636        let (_broadened, dxs_dt) =
1637            doppler_broaden_with_derivative(&energies, &xs, &params).unwrap();
1638
1639        // SAMMY-style: perturb Doppler width by ±2%
1640        let del = 0.02;
1641        let _u = params.u(); // retained for documentation; T_up/T_down use (1±del)²
1642        // D_up = u * (1 + del), corresponds to T_up such that √(kT_up/AWR) = u*(1+del)
1643        // T_up = T * (1+del)²
1644        let t_up = params.temperature_k * (1.0 + del) * (1.0 + del);
1645        let t_down = params.temperature_k * (1.0 - del) * (1.0 - del);
1646        let p_up = DopplerParams::new(t_up, params.awr).unwrap();
1647        let p_down = DopplerParams::new(t_down, params.awr).unwrap();
1648        let xs_up = doppler_broaden(&energies, &xs, &p_up).unwrap();
1649        let xs_down = doppler_broaden(&energies, &xs, &p_down).unwrap();
1650
1651        // SAMMY: ∂σ/∂D = (σ(1.02·D) - σ(0.98·D)) / (0.04·D)
1652        // ∂σ/∂T = ∂σ/∂D · D/(2T)
1653        // Combined: ∂σ/∂T ≈ (σ(T_up) - σ(T_down)) / (T_up - T_down)
1654        let actual_dt = t_up - t_down;
1655
1656        // SAMMY FD has O(del²) = O(4e-4) truncation error, so we allow
1657        // slightly looser tolerance. Use same combined metric.
1658        let max_deriv: f64 = (0..energies.len())
1659            .map(|i| ((xs_up[i] - xs_down[i]) / actual_dt).abs())
1660            .fold(0.0f64, f64::max);
1661        let mut max_rel_err = 0.0f64;
1662        for i in 0..energies.len() {
1663            let sammy_fd = (xs_up[i] - xs_down[i]) / actual_dt;
1664            if sammy_fd.abs() < max_deriv * 0.01 {
1665                continue; // skip small derivatives
1666            }
1667            let rel_err = ((dxs_dt[i] - sammy_fd) / sammy_fd).abs();
1668            max_rel_err = max_rel_err.max(rel_err);
1669        }
1670        assert!(
1671            max_rel_err < 1e-3,
1672            "analytical vs SAMMY-style FD max rel error = {max_rel_err:.2e}, expected < 1e-3"
1673        );
1674    }
1675
1676    /// Kernel-discrimination pin: the production kernel must be the FULL
1677    /// FGM kernel (Eq. III B1.7, w²-weighted), verified against in-test
1678    /// Simpson references for BOTH kernels.  The SAMMY ex001 oracle alone
1679    /// is too loose (grid artifacts dominate) to detect a kernel-form
1680    /// regression; this test fails loudly on one.
1681    ///
1682    /// (a) Smooth limit: the w¹ (legacy) kernel preserves a constant σ
1683    ///     (quadrature-noise level), while the full kernel yields
1684    ///     σ·(1 + u²/2v²) — the kT/(2·AWR·E) physical low-energy upturn.
1685    /// (b) Resonance line shape (U-238-like Lorentzian: E_r = 6.674 eV,
1686    ///     Γ = 0.027 eV, AWR = 236.0058, 300 K): the w¹-vs-full deviation
1687    ///     at the ±Δ_D flanks is FIRST order — antisymmetric, within
1688    ///     [0.1%, 1%] — and second-order small at the peak.  These two
1689    ///     reference-vs-reference pins are kernel-independent analytics.
1690    /// (c) The production `doppler_broaden` agrees with the FULL-kernel
1691    ///     reference at those points (< 5e-4) AND differs from the legacy
1692    ///     w¹ reference by the first-order flank skew with the correct
1693    ///     signs — so a silent regression to the legacy kernel fails this
1694    ///     test in the discrimination direction.
1695    #[test]
1696    fn kernel_error_scales_pinned_vs_full_fgm_reference() {
1697        use std::f64::consts::PI;
1698
1699        let awr = 236.0058;
1700        let t_k = 300.0;
1701        let e_r = 6.674; // eV
1702        let gamma = 0.027; // eV (total width scale; Lorentzian discriminator)
1703        let params = DopplerParams::new(t_k, awr).unwrap();
1704        let u = params.u();
1705
1706        // Reference quadrature of the analytic integrand on [v−12u, v+12u]
1707        // (Simpson).  The negative-velocity image branch is omitted: it is
1708        // suppressed by exp(−(v/u)²) with v/u ≈ 247 here.  `full` selects
1709        // the full FGM kernel (w², divide by v² — the production kernel)
1710        // vs the legacy w¹ kernel (divide by v).
1711        let broadened_ref = |sigma: &dyn Fn(f64) -> f64, e: f64, full: bool| -> f64 {
1712            let v = e.sqrt();
1713            let (lo, hi) = (v - 12.0 * u, v + 12.0 * u);
1714            // Enforce the image-branch-omission precondition: the window must
1715            // stay in positive-w territory, which also bounds the omitted
1716            // image term at ≤ exp(−(v/u)²) ≤ exp(−144) — far below quadrature
1717            // noise.  If the test parameters (E, T, AWR) ever change such that
1718            // this fails, implement the negative-w branch instead.
1719            assert!(
1720                lo > 0.0,
1721                "reference quadrature window crosses w = 0 (v/u = {:.1} < 12); \
1722                 the omitted image branch is no longer negligible",
1723                v / u
1724            );
1725            let n = 4800usize; // even (Simpson); h = 0.005·u
1726            let h = (hi - lo) / n as f64;
1727            let f = |w: f64| -> f64 {
1728                let g = (-((v - w) / u).powi(2)).exp();
1729                let wp = if full { w * w } else { w };
1730                g * wp * sigma(w * w)
1731            };
1732            let mut s = f(lo) + f(hi);
1733            for i in 1..n {
1734                let w = lo + i as f64 * h;
1735                s += f(w) * if i % 2 == 1 { 4.0 } else { 2.0 };
1736            }
1737            let integral = s * h / 3.0;
1738            let norm = u * PI.sqrt() * if full { v * v } else { v };
1739            integral / norm
1740        };
1741
1742        // (a) Constant cross-section.
1743        let const_sigma = |_e: f64| 1.0_f64;
1744        let apx_const = broadened_ref(&const_sigma, e_r, false);
1745        let full_const = broadened_ref(&const_sigma, e_r, true);
1746        let u2_over_2v2 = u * u / (2.0 * e_r); // v² = E
1747        assert!(
1748            (apx_const - 1.0).abs() < 1e-8,
1749            "legacy w¹ kernel reference must preserve constant σ (got dev {:.3e})",
1750            apx_const - 1.0
1751        );
1752        assert!(
1753            ((full_const - 1.0) - u2_over_2v2).abs() < 0.05 * u2_over_2v2,
1754            "full kernel on constant σ must give 1 + u²/2v² = 1 + {:.3e} (got 1 + {:.3e})",
1755            u2_over_2v2,
1756            full_const - 1.0
1757        );
1758
1759        // (b) Lorentzian line shape: first-order antisymmetric flank skew.
1760        let lorentzian = |e: f64| {
1761            let x = (e - e_r) / (gamma / 2.0);
1762            1.0 / (1.0 + x * x)
1763        };
1764        let delta_d = params.doppler_width(e_r);
1765        let dev_at = |e: f64| -> f64 {
1766            let apx = broadened_ref(&lorentzian, e, false);
1767            let full = broadened_ref(&lorentzian, e, true);
1768            (full - apx) / full
1769        };
1770        let dev_lo = dev_at(e_r - delta_d);
1771        let dev_hi = dev_at(e_r + delta_d);
1772        let dev_peak = dev_at(e_r);
1773        assert!(
1774            dev_lo > 1.0e-3 && dev_lo < 1.0e-2,
1775            "low-flank deviation must be first-order positive (~0.3%), got {dev_lo:.3e}"
1776        );
1777        assert!(
1778            dev_hi < -1.0e-3 && dev_hi > -1.0e-2,
1779            "high-flank deviation must be first-order negative (~−0.3%), got {dev_hi:.3e}"
1780        );
1781        assert!(
1782            dev_peak.abs() < 5.0e-5,
1783            "peak deviation must be second-order small, got {dev_peak:.3e}"
1784        );
1785
1786        // (c) The shipping doppler_broaden matches the FULL-kernel reference
1787        // at the same energies (grid fine enough that production quadrature
1788        // error ≪ the 0.3% flank signal), and DIFFERS from the legacy w¹
1789        // reference by the first-order flank skew with the correct signs —
1790        // a silent regression to the legacy kernel trips the second check.
1791        let n_grid = 3001usize;
1792        let (e_lo, e_hi) = (e_r - 1.2, e_r + 1.2);
1793        let energies: Vec<f64> = (0..n_grid)
1794            .map(|i| e_lo + (e_hi - e_lo) * i as f64 / (n_grid - 1) as f64)
1795            .collect();
1796        let xs: Vec<f64> = energies.iter().map(|&e| lorentzian(e)).collect();
1797        let broadened = doppler_broaden(&energies, &xs, &params).unwrap();
1798        // expect_skew: Some(true) = low flank (production above the legacy
1799        // kernel), Some(false) = high flank (below), None = peak (no
1800        // first-order term).
1801        for (target, expect_skew) in [
1802            (e_r - delta_d, Some(true)),
1803            (e_r, None),
1804            (e_r + delta_d, Some(false)),
1805        ] {
1806            let idx = energies
1807                .iter()
1808                .enumerate()
1809                .min_by(|(_, a), (_, b)| (*a - target).abs().total_cmp(&(*b - target).abs()))
1810                .map(|(i, _)| i)
1811                .unwrap();
1812            let e_eval = energies[idx];
1813            let ref_full = broadened_ref(&lorentzian, e_eval, true);
1814            let rel_full = (broadened[idx] - ref_full).abs() / ref_full;
1815            assert!(
1816                rel_full < 5.0e-4,
1817                "production doppler_broaden vs FULL-kernel reference at \
1818                 E = {e_eval:.4} eV: rel dev {rel_full:.3e} (must be ≪ the 3e-3 flank signal)"
1819            );
1820            let ref_legacy = broadened_ref(&lorentzian, e_eval, false);
1821            let dev_legacy = (broadened[idx] - ref_legacy) / ref_legacy;
1822            match expect_skew {
1823                Some(true) => assert!(
1824                    dev_legacy > 1.0e-3 && dev_legacy < 1.0e-2,
1825                    "low flank: production must sit first-order ABOVE the \
1826                     legacy w¹ kernel (got {dev_legacy:.3e})"
1827                ),
1828                Some(false) => assert!(
1829                    dev_legacy < -1.0e-3 && dev_legacy > -1.0e-2,
1830                    "high flank: production must sit first-order BELOW the \
1831                     legacy w¹ kernel (got {dev_legacy:.3e})"
1832                ),
1833                None => assert!(
1834                    dev_legacy.abs() < 1.0e-3,
1835                    "peak: production-vs-legacy must have no first-order term \
1836                     (got {dev_legacy:.3e})"
1837                ),
1838            }
1839        }
1840
1841        // (d) Production-level pins on the two analytic full-kernel
1842        // signatures stated in the module docs — over the FULL grid
1843        // including both edges (the low-side extension keeps the lowest
1844        // output windows unpadded-truncation-free; see the grid-construction
1845        // comment in doppler_broaden).
1846        //
1847        // 1/v: Y₂(w) = w²·(c/w) = c·w is linear in w, so the PW-linear
1848        // quadrature integrates it exactly, and the grid extensions
1849        // extrapolate by exactly 1/v — both edges are exact.
1850        let inv_v_xs: Vec<f64> = energies.iter().map(|&e| 3.0 / e.sqrt()).collect();
1851        let inv_v_broad = doppler_broaden(&energies, &inv_v_xs, &params).unwrap();
1852        let mut inv_v_max_rel = 0.0f64;
1853        for i in 0..n_grid {
1854            let rel = (inv_v_broad[i] - inv_v_xs[i]).abs() / inv_v_xs[i];
1855            inv_v_max_rel = inv_v_max_rel.max(rel);
1856        }
1857        eprintln!(
1858            "pin(d) 1/v: edge0 rel={:.3e}, max rel={inv_v_max_rel:.3e}",
1859            (inv_v_broad[0] - inv_v_xs[0]).abs() / inv_v_xs[0]
1860        );
1861        assert!(
1862            inv_v_max_rel < 1.0e-9,
1863            "1/v cross-section must be preserved exactly over the FULL grid \
1864             including edges (got max rel dev {inv_v_max_rel:.3e})"
1865        );
1866        // Constant σ: the full kernel produces the physical low-energy
1867        // upturn σ·(1 + u²/2v²); at these parameters u²/2E ≈ 8.2e-6.
1868        // INTERIOR points match the analytic value at quadrature level; at
1869        // the two grid EDGES the extension extrapolates σ by 1/v (the
1870        // documented contract), so a constant σ — which violates that
1871        // asymptotic — picks up an extrapolation-mismatch deviation there.
1872        // Both are pinned at their measured values.
1873        let const_xs = vec![2.0f64; n_grid];
1874        let const_broad = doppler_broaden(&energies, &const_xs, &params).unwrap();
1875        for i in (n_grid / 10)..(9 * n_grid / 10) {
1876            let e_i = energies[i];
1877            let expected = 2.0 * (1.0 + u * u / (2.0 * e_i));
1878            let rel = (const_broad[i] - expected).abs() / expected;
1879            assert!(
1880                rel < 1.0e-7,
1881                "constant σ must broaden to σ·(1 + u²/2v²) at E = {:.4} eV \
1882                 (got rel dev {rel:.3e} from the expected upturn)",
1883                e_i
1884            );
1885        }
1886        let edge_dev = |i: usize| -> f64 {
1887            let expected = 2.0 * (1.0 + u * u / (2.0 * energies[i]));
1888            (const_broad[i] - expected).abs() / expected
1889        };
1890        let (lo_dev, hi_dev) = (edge_dev(0), edge_dev(n_grid - 1));
1891        eprintln!("pin(d) const: edge devs lo={lo_dev:.3e}, hi={hi_dev:.3e}");
1892        assert!(
1893            lo_dev < 2.0e-3 && hi_dev < 2.0e-3,
1894            "constant-σ edge deviations must stay at the 1/v-extrapolation \
1895             mismatch scale (got lo {lo_dev:.3e}, hi {hi_dev:.3e})"
1896        );
1897    }
1898
1899    /// Low-energy / light-target derivative check that EXERCISES the
1900    /// negative-velocity image branch through the DERIVATIVE path (every
1901    /// other derivative test runs at E ≥ 1 eV with AWR ≥ 177, where the
1902    /// branch is unreachable).  Both entry points now share
1903    /// `build_extended_fgm_grid`, so this test pins the odd image-branch
1904    /// integrand (Y = −w²·σ) end-to-end through the derivative machinery —
1905    /// the M₀/M₁ quotient-rule terms over negative-w segments, which no
1906    /// other test reaches.  The FD side anchors to `doppler_broaden`
1907    /// (whose image branch the tr165 SAMMY baseline validates at its
1908    /// lowest energies), so an integrand or normalization defect specific
1909    /// to the derivative assembly breaks the FD agreement here.
1910    #[test]
1911    fn test_analytical_derivative_vs_fd_low_energy_image_branch() {
1912        // AWR = 1, 300 K: u ≈ 0.161 √eV, so 6u ≈ 0.965 √eV and grids
1913        // starting below E = (6u)² ≈ 0.93 eV enter the image branch.
1914        let energies: Vec<f64> = (0..400).map(|i| 0.05 + i as f64 * 0.005).collect();
1915        let xs = test_resonance_xs(&energies, 1.0, 0.05, 100.0);
1916        let params = DopplerParams::new(300.0, 1.0).unwrap();
1917
1918        // Precondition: the extended grid must actually reach w < 0.
1919        assert!(
1920            energies[0].sqrt() < DOPPLER_N_SIGMA * params.u(),
1921            "grid must enter the negative-velocity image branch \
1922             (v_min = {:.4}, 6u = {:.4})",
1923            energies[0].sqrt(),
1924            DOPPLER_N_SIGMA * params.u()
1925        );
1926
1927        let (_broadened, dxs_dt) =
1928            doppler_broaden_with_derivative(&energies, &xs, &params).unwrap();
1929
1930        let dt = 1e-4 * (1.0 + params.temperature_k());
1931        let params_up = DopplerParams::new(params.temperature_k() + dt, params.awr()).unwrap();
1932        let params_down =
1933            DopplerParams::new((params.temperature_k() - dt).max(0.1), params.awr()).unwrap();
1934        let actual_2dt = (params.temperature_k() + dt) - (params.temperature_k() - dt).max(0.1);
1935
1936        let xs_up = doppler_broaden(&energies, &xs, &params_up).unwrap();
1937        let xs_down = doppler_broaden(&energies, &xs, &params_down).unwrap();
1938
1939        let max_deriv: f64 = (0..energies.len())
1940            .map(|i| ((xs_up[i] - xs_down[i]) / actual_2dt).abs())
1941            .fold(0.0f64, f64::max);
1942        let abs_tol = max_deriv * 1e-4;
1943
1944        let mut max_rel_err = 0.0f64;
1945        let mut n_significant = 0;
1946        for i in 0..energies.len() {
1947            let fd = (xs_up[i] - xs_down[i]) / actual_2dt;
1948            if fd.abs() < 1e-15 {
1949                continue;
1950            }
1951            if fd.abs() > max_deriv * 0.01 {
1952                let rel_err = ((dxs_dt[i] - fd) / fd).abs();
1953                max_rel_err = max_rel_err.max(rel_err);
1954                n_significant += 1;
1955            } else {
1956                let abs_err = (dxs_dt[i] - fd).abs();
1957                assert!(
1958                    abs_err < abs_tol,
1959                    "E={:.3}: abs error {:.2e} exceeds tol {:.2e}",
1960                    energies[i],
1961                    abs_err,
1962                    abs_tol
1963                );
1964            }
1965        }
1966        assert!(n_significant > 50, "too few significant-derivative points");
1967        // Tolerance is the FD noise floor on this grid, not 1e-6 as in the
1968        // high-energy tests: the extended velocity grid is itself
1969        // u-dependent (v_min − 6u start, u-scaled spacing, ceil()'d node
1970        // count), so the two FD evaluations at T ± dt integrate over
1971        // slightly different node sets — measured noise 4.0e-5 here, where
1972        // u/v reaches ~0.7.  A sign or weight defect in the image branch
1973        // would appear at ≥ 1e-3 (the negative-w contribution is
1974        // ~1e-3–1e-2 of σ_D on this grid), so the 1e-4 gate still
1975        // discriminates by ≥ 10×.
1976        assert!(
1977            max_rel_err < 1e-4,
1978            "analytical vs FD max rel error = {max_rel_err:.2e} on the \
1979             image-branch grid, expected < 1e-4"
1980        );
1981    }
1982
1983    /// Sparse EDGE passthrough (SAMMY `fgm/mfgm1.f90`: "IF too few points,
1984    /// do not broaden"): when the Gaussian window is truncated by the end
1985    /// of the extended grid AND holds fewer than 3 nodes, the point is
1986    /// returned unbroadened.  Before this guard the kernel chord-integrated
1987    /// across the gap at the edge: constant σ on a valid [1, 100] eV grid
1988    /// (AWR = 1, 300 K) returned 1.998 at the low edge (analytic
1989    /// full-kernel value 1.0129) — a silent +97% error.  INTERIOR
1990    /// under-resolved points keep broadening (exact at nodes; two-sided J₁
1991    /// cancellation) so the u → 0 limit — and the temperature derivative
1992    /// that T-fits rely on — stays smooth.
1993    #[test]
1994    fn test_sparse_grid_edge_passthrough_matches_sammy() {
1995        // Two-point grid: both output windows are edge-truncated with a
1996        // single node each → passthrough.
1997        let energies = vec![1.0, 100.0];
1998        let xs = vec![1.0, 1.0];
1999        let params = DopplerParams::new(300.0, 1.0).unwrap();
2000        let b = doppler_broaden(&energies, &xs, &params).unwrap();
2001        assert_eq!(b, xs, "sparse two-point grid must pass through unbroadened");
2002
2003        // Moderately coarse grid (AWR = 238): velocity spacing ≈ 0.22 √eV
2004        // ≫ 6u ≈ 0.063 √eV.  The two EDGE points are truncated+sparse →
2005        // passthrough; the three INTERIOR points broaden sub-resolution
2006        // (exact at the node up to the chord-curvature term, measured
2007        // ≤ 9.8e-4 here).
2008        let energies2 = vec![1.0, 1.5, 2.25, 3.375, 5.0];
2009        let xs2 = vec![1.0f64; 5];
2010        let params2 = DopplerParams::new(300.0, 238.0).unwrap();
2011        let b2 = doppler_broaden(&energies2, &xs2, &params2).unwrap();
2012        assert_eq!(b2[0], 1.0, "low edge must pass through");
2013        assert_eq!(b2[4], 1.0, "high edge must pass through");
2014        for (i, &v) in b2.iter().enumerate().take(4).skip(1) {
2015            assert!(
2016                (v - 1.0).abs() < 2.0e-3,
2017                "interior sub-resolution point {i} must stay near σ \
2018                 (chord-curvature scale): got {v}"
2019            );
2020        }
2021
2022        // Derivative twin shares the guard; passthrough points are
2023        // temperature-independent, so their derivative is exactly zero.
2024        let (b3, d3) = doppler_broaden_with_derivative(&energies, &xs, &params).unwrap();
2025        assert_eq!(b3, xs);
2026        assert_eq!(d3, vec![0.0, 0.0]);
2027    }
2028}