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