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//! - `dop/` module (Leal-Hwang implementation)
11//!
12//! ## Method
13//!
14//! We implement the exact FGM integral in velocity space (SAMMY Eq. III B1.7):
15//!
16//!   v·σ_D(v²) = (1/(u√π)) ∫ exp(-(v-w)²/u²) · w · s(w) dw
17//!
18//! where v = √E, u = √(k_B·T / AWR), and:
19//!   s(w) =  σ(w²)  for w > 0
20//!   s(w) = -σ(w²)  for w < 0
21//!
22//! The key advantage of the velocity-space formulation is that u is
23//! independent of energy, making it a true convolution.
24//!
25//! ## Doppler Width
26//!
27//! The SAMMY Doppler width at energy E is:
28//!   Δ_D(E) = √(4·k_B·T·E / AWR)
29
30use std::fmt;
31
32use nereids_core::constants::{self, DIVISION_FLOOR, NEAR_ZERO_FLOOR};
33
34use crate::resolution::exerfc;
35
36/// Number of standard deviations beyond the velocity range for the FGM
37/// integration window.  The Gaussian kernel exp(-arg²) contributes less
38/// than exp(-36) ≈ 2.3e-16 outside this window, which is below f64
39/// machine epsilon.
40const DOPPLER_N_SIGMA: f64 = 6.0;
41
42/// Floor for distinguishing negative-velocity grid points from zero.
43///
44/// When building the extended velocity grid for the FGM integral, we
45/// generate points from `v_neg_limit` up to (but not including) zero.
46/// This threshold prevents the last negative-velocity point from being
47/// so close to zero that it is numerically indistinguishable, which would
48/// create a near-duplicate of the explicit v = 0 anchor point.
49const NEGATIVE_VELOCITY_FLOOR: f64 = 1e-15;
50
51/// Errors from `DopplerParams` construction.
52#[derive(Debug, PartialEq)]
53pub enum DopplerParamsError {
54    /// AWR must be strictly positive.
55    InvalidAwr(f64),
56    /// Temperature must be finite (may be zero for "no broadening").
57    NonFiniteTemperature(f64),
58    /// Temperature must be non-negative (negative Kelvin is physically meaningless).
59    NegativeTemperature(f64),
60}
61
62impl fmt::Display for DopplerParamsError {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        match self {
65            Self::InvalidAwr(v) => write!(f, "AWR must be positive, got {v}"),
66            Self::NonFiniteTemperature(v) => write!(f, "temperature must be finite, got {v}"),
67            Self::NegativeTemperature(v) => {
68                write!(f, "temperature must be non-negative, got {v}")
69            }
70        }
71    }
72}
73
74impl std::error::Error for DopplerParamsError {}
75
76/// Errors from Doppler broadening computation (not parameter construction).
77#[derive(Debug)]
78pub enum DopplerError {
79    /// Energy and cross-section arrays have different lengths.
80    LengthMismatch {
81        /// Number of energy points.
82        energies: usize,
83        /// Number of cross-section values.
84        cross_sections: usize,
85    },
86}
87
88impl fmt::Display for DopplerError {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        match self {
91            Self::LengthMismatch {
92                energies,
93                cross_sections,
94            } => write!(
95                f,
96                "energies length ({energies}) must match cross_sections length ({cross_sections})"
97            ),
98        }
99    }
100}
101
102impl std::error::Error for DopplerError {}
103
104/// Doppler broadening parameters.
105#[derive(Debug, Clone, Copy)]
106pub struct DopplerParams {
107    /// Effective sample temperature in Kelvin.
108    temperature_k: f64,
109    /// Atomic weight ratio (target mass / neutron mass) from ENDF.
110    awr: f64,
111}
112
113impl DopplerParams {
114    /// Create validated Doppler parameters.
115    ///
116    /// # Errors
117    /// Returns `DopplerParamsError::InvalidAwr` if `awr <= 0.0` or is NaN.
118    /// Returns `DopplerParamsError::NonFiniteTemperature` if `temperature_k`
119    /// is NaN or infinity.
120    /// Returns `DopplerParamsError::NegativeTemperature` if `temperature_k < 0.0`.
121    /// Zero temperature is allowed — it means "no broadening".
122    pub fn new(temperature_k: f64, awr: f64) -> Result<Self, DopplerParamsError> {
123        if !awr.is_finite() || awr <= 0.0 {
124            return Err(DopplerParamsError::InvalidAwr(awr));
125        }
126        if !temperature_k.is_finite() {
127            return Err(DopplerParamsError::NonFiniteTemperature(temperature_k));
128        }
129        if temperature_k < 0.0 {
130            return Err(DopplerParamsError::NegativeTemperature(temperature_k));
131        }
132        Ok(Self { temperature_k, awr })
133    }
134
135    /// Returns the effective sample temperature in Kelvin.
136    #[must_use]
137    pub fn temperature_k(&self) -> f64 {
138        self.temperature_k
139    }
140
141    /// Returns the atomic weight ratio (target mass / neutron mass).
142    #[must_use]
143    pub fn awr(&self) -> f64 {
144        self.awr
145    }
146
147    /// Velocity-space Doppler width u = √(k_B·T / AWR).
148    ///
149    /// This is the standard deviation of the Gaussian kernel in √eV units.
150    #[must_use]
151    pub fn u(&self) -> f64 {
152        (constants::BOLTZMANN_EV_PER_K * self.temperature_k / self.awr).sqrt()
153    }
154
155    /// Energy-dependent Doppler width Δ_D(E) = √(4·k_B·T·E / AWR).
156    ///
157    /// This is the width that SAMMY reports in the .lpt file.
158    #[must_use]
159    pub fn doppler_width(&self, energy_ev: f64) -> f64 {
160        (4.0 * constants::BOLTZMANN_EV_PER_K * self.temperature_k * energy_ev / self.awr).sqrt()
161    }
162}
163
164/// √π constant for erfc computation.
165const SQRT_PI: f64 = 1.772_453_850_905_516;
166
167/// Complementary error function erfc(x) = 1 - erf(x).
168///
169/// For x ≥ 0: uses the scaled complementary error function `exerfc`
170/// (SAMMY `fnc/exerfc.f90`):
171///   erfc(x) = exp(-x²) · exerfc(x) / √π
172///
173/// For x < 0: uses the identity erfc(-|x|) = 2 - erfc(|x|) to avoid
174/// the `exerfc` negative-argument branch, which has a numerical issue
175/// for |x| > 5.01 (missing exp(x²) factor in the large-|x| path).
176fn erfc_val(x: f64) -> f64 {
177    if x >= 0.0 {
178        (-x * x).exp() * exerfc(x) / SQRT_PI
179    } else {
180        let xp = -x;
181        2.0 - (-xp * xp).exp() * exerfc(xp) / SQRT_PI
182    }
183}
184
185/// Apply FGM Doppler broadening to cross-section data.
186///
187/// The cross-sections are broadened in velocity space using the exact
188/// Free Gas Model integral from SAMMY manual Eq. III B1.7.
189///
190/// # Arguments
191/// * `energies` — Energy grid in eV (must be positive and sorted ascending).
192/// * `cross_sections` — Unbroadened cross-sections in barns at each energy point.
193/// * `params` — Doppler broadening parameters (temperature and AWR).
194///
195/// # Returns
196/// Doppler-broadened cross-sections in barns on the same energy grid.
197///
198/// # Algorithm
199/// 1. Convert energy grid to velocity space (v = √E).
200/// 2. Build extended grid including negative velocities for the FGM integral.
201/// 3. Compute the integrand Y(w) = w · s(w) on the extended grid.
202/// 4. For each output velocity, evaluate the Gaussian convolution integral.
203/// 5. Transform back: σ_D(E) = result / √E.
204pub fn doppler_broaden(
205    energies: &[f64],
206    cross_sections: &[f64],
207    params: &DopplerParams,
208) -> Result<Vec<f64>, DopplerError> {
209    if energies.len() != cross_sections.len() {
210        return Err(DopplerError::LengthMismatch {
211            energies: energies.len(),
212            cross_sections: cross_sections.len(),
213        });
214    }
215
216    if params.temperature_k() <= 0.0 || energies.is_empty() {
217        return Ok(cross_sections.to_vec());
218    }
219
220    let u = params.u();
221    if u < NEAR_ZERO_FLOOR {
222        return Ok(cross_sections.to_vec());
223    }
224
225    let n = energies.len();
226
227    // Convert to velocity grid: v_i = sqrt(E_i)
228    let velocities: Vec<f64> = energies.iter().map(|&e| e.sqrt()).collect();
229
230    // Build the integrand Y(w) = w * s(w) on the velocity grid.
231    // For positive v: Y(v) = v * σ(v²)
232    // We also need negative velocity points where Y(-v) = -v * s(-v) = -v * (-σ(v²)) = v * σ(v²)
233    // So Y(w) = |w| * σ(w²) for both positive and negative w.
234    // Actually from Eq. III B1.6: s(w) = σ(w²) for w>0, s(w) = -σ(w²) for w<0
235    // So Y(w) = w * s(w) = w * σ(w²) for w>0, Y(w) = w * (-σ(w²)) = -w * σ(w²) for w<0
236    // But since w<0, -w>0, so Y(w) = |w| * σ(w²) = |w| * σ(|w|²)
237    // This means Y(w) = |w| * σ(|w|²) for all w, i.e., Y is an even function.
238
239    // Determine how many negative velocity points we need.
240    // We need points down to v_min - N_sigma * u, which may go negative.
241    let v_min = velocities[0];
242    let v_neg_limit = v_min - DOPPLER_N_SIGMA * u;
243
244    // Build extended velocity grid: negative points (if needed) + positive points.
245    // Pre-compute total capacity: negative points + zero + positive + upper extension.
246    let dv_lo = if n > 1 {
247        (velocities[1] - velocities[0]).max(u * 0.1)
248    } else {
249        u * 0.5
250    };
251    let dv_hi = if n > 1 {
252        (velocities[n - 1] - velocities[n - 2]).max(u * 0.1)
253    } else {
254        u * 0.5
255    };
256    let n_neg = if v_neg_limit < 0.0 {
257        // Points from v_neg_limit to just below zero, plus the v=0 anchor.
258        (((-v_neg_limit - NEGATIVE_VELOCITY_FLOOR) / dv_lo).ceil() as usize).saturating_add(1)
259    } else {
260        0
261    };
262    let v_max = velocities[n - 1];
263    let v_max_limit = v_max + DOPPLER_N_SIGMA * u;
264    let n_hi = if v_max < v_max_limit {
265        ((v_max_limit - v_max) / dv_hi).ceil() as usize
266    } else {
267        0
268    };
269    let capacity = n_neg + n + n_hi;
270    let mut ext_v: Vec<f64> = Vec::with_capacity(capacity);
271    let mut ext_y: Vec<f64> = Vec::with_capacity(capacity);
272
273    if v_neg_limit < 0.0 {
274        // We need negative velocity points.
275        // Use the same spacing as the low-energy end of the positive grid,
276        // but in velocity space (uniform dv).
277        let mut v = v_neg_limit;
278        while v < -NEGATIVE_VELOCITY_FLOOR {
279            ext_v.push(v);
280            // Y(w) = |w| * σ(|w|²) for negative w
281            // σ at E = w² — interpolate from the positive grid
282            let e = v * v;
283            let sigma = interpolate_cross_section(energies, cross_sections, e);
284            ext_y.push(v.abs() * sigma); // Y is even
285            v += dv_lo;
286        }
287
288        // Add v = 0 point
289        ext_v.push(0.0);
290        ext_y.push(0.0);
291    }
292
293    // Add the positive velocity points
294    for i in 0..n {
295        ext_v.push(velocities[i]);
296        ext_y.push(velocities[i] * cross_sections[i]);
297    }
298
299    // Add points beyond the highest velocity if needed
300    if v_max < v_max_limit {
301        let mut v = v_max + dv_hi;
302        while v <= v_max_limit {
303            ext_v.push(v);
304            let e = v * v;
305            let sigma = interpolate_cross_section(energies, cross_sections, e);
306            ext_y.push(v * sigma);
307            v += dv_hi;
308        }
309    }
310
311    let n_ext = ext_v.len();
312
313    // The extended velocity grid must be sorted ascending (negative → 0 → positive)
314    // for the partition_point binary searches below to work correctly.
315    debug_assert!(
316        ext_v.windows(2).all(|w| w[0] <= w[1]),
317        "ext_v must be sorted ascending for partition_point"
318    );
319
320    // For each output energy point, compute the broadened cross-section
321    // using piecewise-linear interpolation of Y(w) = |w|×σ(w²) combined
322    // with exact Gaussian integration over each segment.
323    //
324    // SAMMY Ref: `fgm/mfgm2.f90` Modsmp (linear), Modfpl (4-point Lagrange).
325    // Our PW-linear approach matches Modsmp's 2-point interpolation with
326    // analytical Gaussian integration via Abcerf/Abcexp.
327    //
328    // For each segment [w_j, w_{j+1}], the integrand Y is approximated as:
329    //   Y(w) ≈ Y_j + slope × (w − w_j)
330    //
331    // The exact integral of G(v,w) × Y_linear(w) dw over the segment is:
332    //   u × [C_j × J₀ − u × slope × J₁]
333    //
334    // where C_j = Y_j + slope × (v − w_j), and:
335    //   J₀ = ∫ exp(−t²) dt = (√π/2)(erfc(b_{j+1}) − erfc(b_j))
336    //   J₁ = ∫ t·exp(−t²) dt = [exp(−b_{j+1}²) − exp(−b_j²)] / 2
337    //   b_j = (v − w_j) / u
338    //
339    // This provides second-order accuracy (error ∝ h²) compared to the
340    // zeroth-order Voronoi cell approach (error ∝ h).
341
342    let mut broadened = vec![0.0f64; n];
343
344    for i in 0..n {
345        let v = velocities[i];
346        let e = energies[i];
347        if v < NEAR_ZERO_FLOOR || e < NEAR_ZERO_FLOOR {
348            broadened[i] = cross_sections[i];
349            continue;
350        }
351
352        // O(N×W) optimisation: binary search restricts the inner loop to the
353        // Gaussian window [v − n_sigma·u, v + n_sigma·u].
354        let v_lo = v - DOPPLER_N_SIGMA * u;
355        let v_hi = v + DOPPLER_N_SIGMA * u;
356        let j_lo = ext_v.partition_point(|&w| w < v_lo);
357        let j_hi = ext_v.partition_point(|&w| w <= v_hi);
358
359        if j_lo >= j_hi {
360            broadened[i] = cross_sections[i];
361            continue;
362        }
363
364        // PW-linear FGM integral: segment-by-segment exact integration.
365        //
366        // v × σ_D(v²) = Σ [C_j × J₀_j − u × slope_j × J₁_j] / Σ J₀_j
367        // σ_D(E) = (v × σ_D(v²)) / v² = Σ[…] / (Σ J₀ × v)
368        //
369        // SAMMY Ref: `fgm/mfgm2.f90` Modsmp lines 80-87 (linear weights
370        // with Abcerf B-coefficient = first moment correction).
371        let mut sum_y = 0.0f64; // Numerator: Σ [C × J₀ − u × slope × J₁]
372        let mut sum_g = 0.0f64; // Denominator: Σ J₀
373
374        // Process segments [j, j+1] that overlap the Gaussian window.
375        let seg_lo = if j_lo > 0 { j_lo - 1 } else { j_lo };
376        let seg_hi = j_hi.min(n_ext - 1);
377
378        for j in seg_lo..seg_hi {
379            let w_j = ext_v[j];
380            let w_j1 = ext_v[j + 1];
381            let h_w = w_j1 - w_j;
382            if h_w < NEAR_ZERO_FLOOR {
383                continue;
384            }
385
386            // Scaled distances from target velocity.
387            let b_j = (v - w_j) / u;
388            let b_j1 = (v - w_j1) / u;
389
390            // J₀ = ∫_{b_{j+1}}^{b_j} exp(−t²) dt
391            //     = (√π/2)(erfc(b_{j+1}) − erfc(b_j))
392            let erfc_bj = erfc_val(b_j);
393            let erfc_bj1 = erfc_val(b_j1);
394            let j0 = SQRT_PI * 0.5 * (erfc_bj1 - erfc_bj);
395
396            if j0 < NEAR_ZERO_FLOOR {
397                continue;
398            }
399
400            // J₁ = ∫_{b_{j+1}}^{b_j} t·exp(−t²) dt
401            //     = [exp(−b_{j+1}²) − exp(−b_j²)] / 2
402            let j1 = ((-b_j1 * b_j1).exp() - (-b_j * b_j).exp()) * 0.5;
403
404            let y_j = ext_y[j];
405            let y_j1 = ext_y[j + 1];
406            let slope = (y_j1 - y_j) / h_w;
407
408            // C_j = Y_j + slope × (v − w_j) = Y_j + slope × u × b_j
409            let c_j = y_j + slope * u * b_j;
410
411            // Contribution: C × J₀ − u × slope × J₁
412            sum_y += c_j * j0 - u * slope * j1;
413            sum_g += j0;
414        }
415
416        if sum_g < DIVISION_FLOOR {
417            broadened[i] = cross_sections[i];
418            continue;
419        }
420
421        // σ_D(E) = Σ(C × J₀ − u × slope × J₁) / (Σ J₀ × v)
422        broadened[i] = sum_y / (sum_g * v);
423
424        // Ensure non-negative
425        if broadened[i] < 0.0 {
426            broadened[i] = 0.0;
427        }
428    }
429
430    Ok(broadened)
431}
432
433/// Doppler-broaden cross-sections AND compute the analytical temperature
434/// derivative ∂σ_D/∂T in a single pass.
435///
436/// This computes the exact derivative by differentiating the FGM integral
437/// with respect to the Doppler width parameter u = √(k_B·T / AWR), then
438/// applying the chain rule: ∂σ_D/∂T = (∂σ_D/∂u) · u/(2T).
439///
440/// The derivative uses intermediate quantities already computed in the
441/// forward pass (b_k, exp(-b_k²), J₀, J₁, C_j, slope), adding only
442/// ~10 FLOPs per segment with NO extra broadening evaluations.
443///
444/// ## Mathematical Derivation
445///
446/// Per segment [w_j, w_{j+1}]:
447///   M₀_j = b_{j+1}·exp(-b_{j+1}²) - b_j·exp(-b_j²)
448///   M₁_j = b_{j+1}²·exp(-b_{j+1}²) - b_j²·exp(-b_j²)
449///   ∂I_j/∂u = (C_j/u)·M₀_j - slope_j·J₁_j - slope_j·M₁_j
450///
451/// Full result (quotient rule on sum_y / (sum_g · v)):
452///   ∂σ_D/∂T = u/(2T·v) · (dsum_y·sum_g - sum_y·dsum_g) / sum_g²
453///
454/// SAMMY uses finite differences for this (mfgm4.f90 Xdofgm, Del=0.02).
455/// Our analytical approach is exact and avoids the 3× broadening cost.
456pub fn doppler_broaden_with_derivative(
457    energies: &[f64],
458    cross_sections: &[f64],
459    params: &DopplerParams,
460) -> Result<(Vec<f64>, Vec<f64>), DopplerError> {
461    // First, compute the broadened values using the SAME code path as
462    // doppler_broaden to guarantee identical forward-pass results.
463    let broadened = doppler_broaden(energies, cross_sections, params)?;
464
465    let n = energies.len();
466    if n == 0 {
467        return Ok((broadened, vec![]));
468    }
469    if params.temperature_k < NEAR_ZERO_FLOOR {
470        return Ok((broadened, vec![0.0; n]));
471    }
472
473    let u = params.u();
474    let temperature_k = params.temperature_k;
475
476    // Rebuild the same extended grid as doppler_broaden.
477    // This duplicates the grid construction, but guarantees consistency
478    // with the forward pass. The cost is O(n) — negligible compared to
479    // the O(n × n_segments) integration.
480    let velocities: Vec<f64> = energies.iter().map(|&e| e.sqrt()).collect();
481    let v_min = velocities[0];
482    let v_neg_limit = v_min - DOPPLER_N_SIGMA * u;
483    let dv_lo = if n > 1 {
484        (velocities[1] - velocities[0]).max(u * 0.1)
485    } else {
486        u * 0.5
487    };
488    let dv_hi = if n > 1 {
489        (velocities[n - 1] - velocities[n - 2]).max(u * 0.1)
490    } else {
491        u * 0.5
492    };
493    let v_max = velocities[n - 1];
494    let v_max_limit = v_max + DOPPLER_N_SIGMA * u;
495
496    let mut ext_v: Vec<f64> = Vec::new();
497    let mut ext_y: Vec<f64> = Vec::new();
498
499    if v_neg_limit < 0.0 {
500        let mut v = v_neg_limit;
501        while v < -NEGATIVE_VELOCITY_FLOOR {
502            ext_v.push(v);
503            let e = v * v;
504            let sigma = interpolate_cross_section(energies, cross_sections, e);
505            ext_y.push(v.abs() * sigma);
506            v += dv_lo;
507        }
508        ext_v.push(0.0);
509        ext_y.push(0.0);
510    }
511
512    for i in 0..n {
513        ext_v.push(velocities[i]);
514        ext_y.push(velocities[i] * cross_sections[i]);
515    }
516
517    if v_max < v_max_limit {
518        let mut v = v_max + dv_hi;
519        while v <= v_max_limit {
520            ext_v.push(v);
521            let e = v * v;
522            let sigma = interpolate_cross_section(energies, cross_sections, e);
523            ext_y.push(v * sigma);
524            v += dv_hi;
525        }
526    }
527
528    let n_ext = ext_v.len();
529
530    // Compute the derivative in a second pass over the same grid.
531    let mut derivative = vec![0.0f64; n];
532
533    for i in 0..n {
534        let v = velocities[i];
535        let e = energies[i];
536        if v < NEAR_ZERO_FLOOR || e < NEAR_ZERO_FLOOR {
537            derivative[i] = 0.0;
538            continue;
539        }
540
541        let v_lo = v - DOPPLER_N_SIGMA * u;
542        let v_hi = v + DOPPLER_N_SIGMA * u;
543        let j_lo = ext_v.partition_point(|&w| w < v_lo);
544        let j_hi = ext_v.partition_point(|&w| w <= v_hi);
545
546        if j_lo >= j_hi {
547            derivative[i] = 0.0;
548            continue;
549        }
550
551        // Re-integrate to get sum_y and sum_g (needed for quotient rule).
552        // Also accumulate derivative terms in the same loop.
553        let mut sum_y = 0.0f64;
554        let mut sum_g = 0.0f64;
555        let mut dsum_y = 0.0f64;
556        let mut sum_m0 = 0.0f64;
557
558        let seg_lo = if j_lo > 0 { j_lo - 1 } else { j_lo };
559        let seg_hi = j_hi.min(n_ext - 1);
560
561        for j in seg_lo..seg_hi {
562            let w_j = ext_v[j];
563            let w_j1 = ext_v[j + 1];
564            let h_w = w_j1 - w_j;
565            if h_w < NEAR_ZERO_FLOOR {
566                continue;
567            }
568
569            let b_j = (v - w_j) / u;
570            let b_j1 = (v - w_j1) / u;
571
572            let erfc_bj = erfc_val(b_j);
573            let erfc_bj1 = erfc_val(b_j1);
574            let j0 = SQRT_PI * 0.5 * (erfc_bj1 - erfc_bj);
575
576            if j0 < NEAR_ZERO_FLOOR {
577                continue;
578            }
579
580            let exp_bj = (-b_j * b_j).exp();
581            let exp_bj1 = (-b_j1 * b_j1).exp();
582            let j1 = (exp_bj1 - exp_bj) * 0.5;
583
584            let y_j = ext_y[j];
585            let y_j1 = ext_y[j + 1];
586            let slope = (y_j1 - y_j) / h_w;
587            let c_j = y_j + slope * (v - w_j);
588
589            // Forward accumulators (for quotient rule denominator).
590            sum_y += c_j * j0 - u * slope * j1;
591            sum_g += j0;
592
593            // Derivative terms.
594            let m0 = b_j1 * exp_bj1 - b_j * exp_bj;
595            let m1 = b_j1 * b_j1 * exp_bj1 - b_j * b_j * exp_bj;
596            dsum_y += (c_j / u) * m0 - slope * j1 - slope * m1;
597            sum_m0 += m0;
598        }
599
600        if sum_g < DIVISION_FLOOR {
601            derivative[i] = 0.0;
602            continue;
603        }
604
605        // ∂σ_D/∂T = (u · dsum_y · sum_g - sum_y · sum_m0) / (2T · v · sum_g²)
606        let numerator = u * dsum_y * sum_g - sum_y * sum_m0;
607        let denominator = 2.0 * temperature_k * v * sum_g * sum_g;
608        if denominator.abs() > NEAR_ZERO_FLOOR {
609            derivative[i] = numerator / denominator;
610        } else {
611            derivative[i] = 0.0;
612        }
613    }
614
615    Ok((broadened, derivative))
616}
617
618/// Linear interpolation of cross-section at an arbitrary energy.
619///
620/// Unlike `resolution::interp_spectrum` (which returns `None` for off-grid
621/// queries), this function extrapolates using the 1/v law.  A future
622/// consolidation could unify both behind a shared trait or closure-based
623/// extrapolation strategy; for now they remain separate to avoid coupling
624/// the two broadening modules.
625fn interpolate_cross_section(energies: &[f64], cross_sections: &[f64], energy: f64) -> f64 {
626    if energies.is_empty() {
627        return 0.0;
628    }
629
630    // Guard against NaN energy: NaN comparisons are always false, so the
631    // boundary checks below would both be skipped.  The binary search would
632    // then return Err(0), and `idx = 0 - 1` would underflow on usize.
633    if energy.is_nan() {
634        return 0.0;
635    }
636
637    if energy <= energies[0] {
638        // Extrapolate using 1/v law: σ ∝ 1/√E.
639        // Guard: if energy <= 0, the ratio energies[0]/energy would be negative
640        // or infinite, producing NaN from sqrt.  Return the boundary value directly.
641        if energy <= 0.0 {
642            return cross_sections[0];
643        }
644        if energies[0] > NEAR_ZERO_FLOOR {
645            return cross_sections[0] * (energies[0] / energy).sqrt();
646        }
647        return cross_sections[0];
648    }
649
650    if energy >= energies[energies.len() - 1] {
651        // Extrapolate using 1/v law
652        let last = energies.len() - 1;
653        if energy > NEAR_ZERO_FLOOR {
654            return cross_sections[last] * (energies[last] / energy).sqrt();
655        }
656        return cross_sections[last];
657    }
658
659    // Binary search for the interval.
660    // Use total_cmp-style fallback to avoid panic on NaN comparisons.
661    // With the current comparator (NaNs treated as Ordering::Less), NaN
662    // values in the energy grid are pushed to the right, so Err(0) should
663    // not occur in normal operation. The Err(0) arm is kept as a
664    // defense-in-depth guard: if the NaN guard on `energy` is ever removed
665    // or the comparator behavior changes and Err(0) becomes possible, we
666    // avoid `0 - 1` underflow on usize by returning the first cross-section.
667    let idx = match energies
668        .binary_search_by(|e| e.partial_cmp(&energy).unwrap_or(std::cmp::Ordering::Less))
669    {
670        Ok(i) => return cross_sections[i],
671        Err(0) => return cross_sections[0],
672        Err(i) => i - 1,
673    };
674
675    // Linear interpolation.
676    // Guard against duplicate energy grid points: if e0 == e1 (or nearly so),
677    // no interpolation is needed — use the value at that point directly.
678    // Use a combined relative+absolute threshold that works across the full
679    // energy range (meV to MeV): |de| < |e0|·ε_mach + NEAR_ZERO_FLOOR.
680    // The relative part handles large energies where f64::EPSILON alone would
681    // miss near-duplicates; the absolute part handles energies near zero.
682    // This is consistent with resolution.rs interp_spectrum.
683    let e0 = energies[idx];
684    let e1 = energies[idx + 1];
685    let s0 = cross_sections[idx];
686    let s1 = cross_sections[idx + 1];
687    let de = e1 - e0;
688    if de.abs() < e0.abs() * f64::EPSILON + NEAR_ZERO_FLOOR {
689        return s0;
690    }
691    let t = (energy - e0) / de;
692    s0 + t * (s1 - s0)
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698
699    // --- DopplerParams::new() validation tests ---
700
701    #[test]
702    fn test_new_negative_temperature_rejected() {
703        assert_eq!(
704            DopplerParams::new(-1.0, 238.0).unwrap_err(),
705            DopplerParamsError::NegativeTemperature(-1.0)
706        );
707    }
708
709    #[test]
710    fn test_new_nan_temperature_rejected() {
711        let err = DopplerParams::new(f64::NAN, 238.0).unwrap_err();
712        assert!(
713            matches!(err, DopplerParamsError::NonFiniteTemperature(v) if v.is_nan()),
714            "NaN temperature should return NonFiniteTemperature"
715        );
716    }
717
718    #[test]
719    fn test_new_infinity_temperature_rejected() {
720        assert_eq!(
721            DopplerParams::new(f64::INFINITY, 238.0).unwrap_err(),
722            DopplerParamsError::NonFiniteTemperature(f64::INFINITY)
723        );
724    }
725
726    #[test]
727    fn test_new_negative_awr_rejected() {
728        assert_eq!(
729            DopplerParams::new(300.0, -1.0).unwrap_err(),
730            DopplerParamsError::InvalidAwr(-1.0)
731        );
732    }
733
734    #[test]
735    fn test_new_zero_awr_rejected() {
736        assert_eq!(
737            DopplerParams::new(300.0, 0.0).unwrap_err(),
738            DopplerParamsError::InvalidAwr(0.0)
739        );
740    }
741
742    #[test]
743    fn test_new_nan_awr_rejected() {
744        let err = DopplerParams::new(300.0, f64::NAN).unwrap_err();
745        assert!(
746            matches!(err, DopplerParamsError::InvalidAwr(v) if v.is_nan()),
747            "NaN AWR should return InvalidAwr"
748        );
749    }
750
751    #[test]
752    fn test_new_zero_temperature_allowed() {
753        let params = DopplerParams::new(0.0, 238.0);
754        assert!(params.is_ok(), "zero temperature should be allowed");
755        let p = params.unwrap();
756        assert_eq!(p.temperature_k(), 0.0);
757        assert_eq!(p.awr(), 238.0);
758    }
759
760    #[test]
761    fn test_new_valid_params() {
762        let params = DopplerParams::new(300.0, 238.0);
763        assert!(params.is_ok(), "valid params should succeed");
764        let p = params.unwrap();
765        assert_eq!(p.temperature_k(), 300.0);
766        assert_eq!(p.awr(), 238.0);
767    }
768
769    // --- End validation tests ---
770
771    #[test]
772    fn test_doppler_width_u238() {
773        // SAMMY reports: Doppler width at 6.075 eV = 0.05159437 eV for U-238 at 300K
774        // AWR = 238.050972, T = 300 K
775        let params = DopplerParams::new(300.0, 238.050972).unwrap();
776        let dw = params.doppler_width(6.075);
777        // SAMMY uses kB = 0.000086173420 eV/K (slightly different from CODATA 2018)
778        // Our kB = 8.617333262e-5. The difference is ~0.003%.
779        // So we expect close but not exact match.
780        assert!(
781            (dw - 0.05159437).abs() < 5e-4,
782            "Doppler width = {}, expected ~0.05159",
783            dw
784        );
785    }
786
787    #[test]
788    fn test_doppler_width_fictitious() {
789        // ex001: A=10, T=300K. Δ_D at 10 eV = √(4kBTE/AWR).
790        // SAMMY reports Δ_D = 0.3216 eV, FWHM = 2√(ln2) × Δ_D = 0.5355 eV.
791        // (SAMMY lpt uses slightly different kB, giving FWHM = 0.5378 eV.)
792        let params = DopplerParams::new(300.0, 10.0).unwrap();
793        let dw = params.doppler_width(10.0);
794        // Δ_D = √(4 × 8.617e-5 × 300 × 10 / 10) = √(0.10341) ≈ 0.3216 eV
795        assert!(
796            (dw - 0.3216).abs() < 0.01,
797            "Doppler width = {}, expected ~0.32",
798            dw
799        );
800    }
801
802    #[test]
803    fn test_zero_temperature() {
804        // At T=0, broadening should return the original cross-sections.
805        let energies = vec![1.0, 2.0, 3.0, 4.0, 5.0];
806        let xs = vec![10.0, 20.0, 30.0, 20.0, 10.0];
807        let params = DopplerParams::new(0.0, 238.0).unwrap();
808        let broadened = doppler_broaden(&energies, &xs, &params).unwrap();
809        assert_eq!(broadened, xs);
810    }
811
812    #[test]
813    fn test_broadening_reduces_peak() {
814        // Doppler broadening should reduce the peak height and spread it out.
815        // Create a sharp resonance peak.
816        let n = 201;
817        let energies: Vec<f64> = (0..n).map(|i| 5.0 + (i as f64) * 0.05).collect();
818        let center = 10.0;
819        let gamma: f64 = 0.02; // narrow resonance
820        let xs: Vec<f64> = energies
821            .iter()
822            .map(|&e| {
823                let de = e - center;
824                100.0 * (gamma / 2.0).powi(2) / (de * de + (gamma / 2.0).powi(2))
825            })
826            .collect();
827
828        let params = DopplerParams::new(300.0, 238.0).unwrap();
829        let broadened = doppler_broaden(&energies, &xs, &params).unwrap();
830
831        // Find peaks
832        let orig_peak = xs.iter().cloned().fold(0.0_f64, f64::max);
833        let broad_peak = broadened.iter().cloned().fold(0.0_f64, f64::max);
834
835        assert!(
836            broad_peak < orig_peak,
837            "Broadened peak ({}) should be less than original ({})",
838            broad_peak,
839            orig_peak
840        );
841
842        // The broadened peak should still be substantial (not wiped out)
843        assert!(
844            broad_peak > 0.1,
845            "Broadened peak ({}) should still be positive",
846            broad_peak
847        );
848    }
849
850    /// SAMMY ex001 validation: single resonance, A=10, T=300K, FGM Doppler.
851    ///
852    /// Reference: ex001a.lst (column 4 = theoretical Doppler-broadened capture σ)
853    /// Par file: E₀ = 10 eV, Γγ = 1.0 meV, Γn = 0.5 meV
854    /// SAMMY par file widths are in meV; we convert to eV (×0.001) for our code.
855    /// AWR = 10.0, radius = 2.908 fm, T = 300 K
856    #[test]
857    fn test_sammy_ex001_fgm_doppler() {
858        use nereids_core::types::Isotope;
859        use nereids_endf::resonance::{
860            LGroup, Resonance, ResonanceData, ResonanceFormalism, ResonanceRange,
861        };
862
863        // Build the ex001 resonance data: single resonance at 10 eV.
864        // SAMMY par file widths are in meV — convert to eV (×0.001).
865        let data = ResonanceData {
866            isotope: Isotope::new(1, 10).unwrap(),
867            za: 1010,
868            awr: 10.0,
869            ranges: vec![ResonanceRange {
870                energy_low: 0.0,
871                energy_high: 100.0,
872                resolved: true,
873                formalism: ResonanceFormalism::SLBW,
874                target_spin: 0.0,
875                scattering_radius: 2.908,
876                naps: 1,
877                l_groups: vec![LGroup {
878                    l: 0,
879                    awr: 10.0,
880                    apl: 2.908,
881                    qx: 0.0,
882                    lrx: 0,
883                    resonances: vec![Resonance {
884                        energy: 10.0,
885                        j: 0.5,
886                        gn: 0.5e-3, // 0.5 meV → eV
887                        gg: 1.0e-3, // 1.0 meV → eV
888                        gfa: 0.0,
889                        gfb: 0.0,
890                    }],
891                }],
892                rml: None,
893                urr: None,
894                ap_table: None,
895                r_external: vec![],
896            }],
897        };
898
899        // Generate unbroadened cross-sections on a non-uniform grid.
900        // The resonance is very narrow (Γ ≈ 1.5 meV) — we need fine spacing
901        // near E₀ = 10 eV and coarser spacing in the wings.
902        let mut energies: Vec<f64> = Vec::new();
903        // Wings: 6.0 to 9.95 and 10.05 to 14.0 with 0.005 eV spacing
904        let mut e = 6.0;
905        while e < 9.95 {
906            energies.push(e);
907            e += 0.005;
908        }
909        // Core: 9.95 to 10.05 with 0.00005 eV spacing (resolves 1.5 meV resonance)
910        while e < 10.05 {
911            energies.push(e);
912            e += 0.00005;
913        }
914        // Upper wing: 10.05 to 14.0
915        while e <= 14.0 {
916            energies.push(e);
917            e += 0.005;
918        }
919        energies.sort_by(|a, b| a.partial_cmp(b).unwrap());
920        energies.dedup();
921        let unbroadened: Vec<f64> = energies
922            .iter()
923            .map(|&e| crate::slbw::slbw_cross_sections(&data, e).capture)
924            .collect();
925
926        // Apply FGM Doppler broadening.
927        let params = DopplerParams::new(300.0, 10.0).unwrap();
928        let broadened = doppler_broaden(&energies, &unbroadened, &params).unwrap();
929
930        // SAMMY ex001a.lst reference points: (energy, broadened capture σ in barns).
931        // Focus on the core region where our grid has good coverage.
932        let sammy_ref = [
933            (9.3594, 5.4125807788),    // lower shoulder
934            (9.8572, 238.1729827317),  // near peak
935            (9.9869, 285.6111456228),  // peak
936            (10.0092, 285.2175881633), // just past peak
937            (10.1282, 241.3304410052), // upper shoulder
938            (10.3430, 91.4783098707),  // falling slope
939            (10.5382, 18.3744223751),  // upper wing
940        ];
941
942        // Interpolate our broadened result onto SAMMY energy points and compare.
943        let mut max_rel_err = 0.0f64;
944        for &(e_ref, sigma_ref) in &sammy_ref {
945            let sigma_us = interpolate_cross_section(&energies, &broadened, e_ref);
946            let rel_err = (sigma_us - sigma_ref).abs() / sigma_ref;
947            max_rel_err = max_rel_err.max(rel_err);
948        }
949        // Allow up to 6% relative error.  PW-linear segment integration is
950        // generally more accurate than Voronoi-cell weighting, but can differ
951        // at grid-spacing transitions (wing region).  Measured: 5.55%.
952        assert!(
953            max_rel_err < 0.06,
954            "Max relative error = {:.2}% (exceeds 6%)",
955            max_rel_err * 100.0
956        );
957
958        // Check peak height specifically (should be close to 285.6 barns).
959        let peak_idx = broadened
960            .iter()
961            .enumerate()
962            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
963            .unwrap()
964            .0;
965        let peak_energy = energies[peak_idx];
966        let peak_sigma = broadened[peak_idx];
967
968        // Peak should be near 10 eV (slight shift to lower E due to 1/v weighting).
969        assert!(
970            (peak_energy - 9.99).abs() < 0.1,
971            "Peak energy = {:.4}, expected near 9.99",
972            peak_energy
973        );
974        assert!(
975            (peak_sigma - 285.6).abs() < 30.0,
976            "Peak σ = {:.2}, expected ~285.6",
977            peak_sigma
978        );
979    }
980
981    #[test]
982    fn test_broadening_conserves_area() {
983        // Doppler broadening should approximately conserve the area under
984        // the cross-section curve (energy × cross-section is conserved).
985        let n = 401;
986        let energies: Vec<f64> = (0..n).map(|i| 1.0 + (i as f64) * 0.05).collect();
987        let center = 10.0;
988        let gamma: f64 = 0.5;
989        let xs: Vec<f64> = energies
990            .iter()
991            .map(|&e| {
992                let de = e - center;
993                1000.0 * (gamma / 2.0).powi(2) / (de * de + (gamma / 2.0).powi(2))
994            })
995            .collect();
996
997        let params = DopplerParams::new(300.0, 100.0).unwrap();
998        let broadened = doppler_broaden(&energies, &xs, &params).unwrap();
999
1000        // Compute area (trapezoidal) for both
1001        let area_orig: f64 = (0..n - 1)
1002            .map(|i| 0.5 * (xs[i] + xs[i + 1]) * (energies[i + 1] - energies[i]))
1003            .sum();
1004        let area_broad: f64 = (0..n - 1)
1005            .map(|i| 0.5 * (broadened[i] + broadened[i + 1]) * (energies[i + 1] - energies[i]))
1006            .sum();
1007
1008        let rel_diff = (area_orig - area_broad).abs() / area_orig;
1009        assert!(
1010            rel_diff < 0.05,
1011            "Area not conserved: orig={}, broad={}, rel_diff={:.4}",
1012            area_orig,
1013            area_broad,
1014            rel_diff
1015        );
1016    }
1017
1018    /// NaN query energy: interpolate_cross_section must return 0.0 without
1019    /// panicking (the NaN guard at line 282 catches this).
1020    #[test]
1021    fn test_interpolate_nan_energy() {
1022        let energies = vec![1.0, 2.0, 3.0];
1023        let xs = vec![10.0, 20.0, 30.0];
1024        let result = interpolate_cross_section(&energies, &xs, f64::NAN);
1025        assert_eq!(result, 0.0, "NaN energy should return 0.0");
1026    }
1027
1028    /// Err(0) guard in binary search: if the binary search were to return
1029    /// Err(0) (insertion point = 0), `i - 1` would underflow on usize.
1030    /// The guard returns cross_sections[0] instead.
1031    ///
1032    /// This path is hard to trigger with well-formed grids (the boundary
1033    /// check `energy <= energies[0]` catches it first), but can occur if
1034    /// the grid or the comparison function behaves unexpectedly (e.g.
1035    /// NaN contamination with a different comparison strategy).  The guard
1036    /// is cheap defense-in-depth against arithmetic underflow.
1037    ///
1038    /// NOTE: This test exercises the `energy <= energies[0]` boundary path
1039    /// (1/v extrapolation), *not* the `Err(0)` binary-search guard itself.
1040    ///
1041    /// We test the NaN query guard separately (`test_interpolate_nan_energy`),
1042    /// the NaN grid guard separately (`test_interpolate_nan_grid_no_panic`),
1043    /// and the duplicate-point guard separately (`test_interpolate_duplicate_grid_points`).
1044    ///
1045    /// The `Err(0)` binary-search guard is primarily a defense-in-depth
1046    /// safety net against unexpected grid or comparison behavior.
1047    #[test]
1048    fn test_interpolate_below_grid_minimum() {
1049        let energies = vec![5.0, 10.0, 15.0];
1050        let xs = vec![50.0, 100.0, 150.0];
1051        // Energy below the grid minimum: hits the `energy <= energies[0]` guard
1052        // and returns via 1/v extrapolation, not the binary search.
1053        let result = interpolate_cross_section(&energies, &xs, 2.0);
1054        assert!(
1055            result.is_finite() && result > 0.0,
1056            "Below-grid query should return a finite positive value via 1/v extrapolation, got {result}"
1057        );
1058        // Check 1/v scaling: σ(2) ≈ σ(5) × √(5/2)
1059        let expected = 50.0 * (5.0 / 2.0_f64).sqrt();
1060        assert!(
1061            (result - expected).abs() < 1e-10,
1062            "Expected 1/v extrapolation: {expected}, got {result}"
1063        );
1064    }
1065
1066    /// Duplicate grid points: two adjacent energies are identical.
1067    /// The combined relative+absolute threshold must detect this and
1068    /// return the value at the duplicate point without division by zero.
1069    #[test]
1070    fn test_interpolate_duplicate_grid_points() {
1071        let energies = vec![1.0, 2.0, 2.0, 3.0];
1072        let xs = vec![10.0, 20.0, 25.0, 30.0];
1073        // Query at exactly 2.0 should hit the Ok(i) branch.
1074        let result = interpolate_cross_section(&energies, &xs, 2.0);
1075        assert!(
1076            (result - 20.0).abs() < 1e-10 || (result - 25.0).abs() < 1e-10,
1077            "At duplicate point 2.0, should return one of the boundary values, got {result}"
1078        );
1079        // Query at 2.0 + tiny epsilon should trigger the duplicate guard.
1080        let result2 = interpolate_cross_section(&energies, &xs, 2.0 + 1e-16);
1081        assert!(
1082            result2.is_finite(),
1083            "Near-duplicate query should return finite result, got {result2}"
1084        );
1085
1086        // Exercise the `de.abs() < |e0|*EPS + NEAR_ZERO_FLOOR` threshold
1087        // with near-zero adjacent energies where de is essentially zero.
1088        // With e0 = 1e-50, the relative term |e0|*EPS ≈ 2e-66 is smaller
1089        // than NEAR_ZERO_FLOOR (1e-60), so the absolute floor dominates.
1090        let tiny_energies = vec![1e-50, 1e-50 + 1e-105, 1.0];
1091        let tiny_xs = vec![100.0, 200.0, 300.0];
1092        // Query between the two near-zero points: de ≈ 1e-105 which is
1093        // far below the absolute threshold NEAR_ZERO_FLOOR (1e-60),
1094        // and the relative term (|1e-50| * EPS ≈ 2e-66) is even smaller,
1095        // so the absolute floor is the binding constraint.
1096        let result3 = interpolate_cross_section(&tiny_energies, &tiny_xs, 1e-50 + 5e-106);
1097        assert!(
1098            result3.is_finite(),
1099            "Near-zero de should be caught by the absolute threshold, got {result3}"
1100        );
1101        // Should return s0 (100.0) since the guard short-circuits.
1102        assert!(
1103            (result3 - 100.0).abs() < 1e-10,
1104            "Expected s0=100.0 from the de threshold guard, got {result3}"
1105        );
1106    }
1107
1108    /// NaN-contaminated energy grid: verify no panic occurs and the NaN
1109    /// query guard (line 282) protects against the `Err(0)` binary search
1110    /// underflow path (line 317).
1111    ///
1112    /// With the current comparator (`unwrap_or(Ordering::Less)`), NaN grid
1113    /// entries are treated as "less than" any query, pushing the binary
1114    /// search rightward.  This means NaN *in the grid* alone cannot produce
1115    /// `Err(0)` — it always produces `Err(k)` with k > 0.  However, a NaN
1116    /// *query* bypasses comparisons entirely and could reach `Err(0)` if the
1117    /// earlier NaN guard (line 282) were removed.  That guard returns 0.0
1118    /// before the binary search, making `Err(0)` unreachable in practice.
1119    ///
1120    /// The `Err(0)` match arm is therefore pure defense-in-depth against
1121    /// future comparator changes.  This test verifies:
1122    ///   1. NaN query → returns 0.0 (guard fires, `Err(0)` never reached).
1123    ///   2. NaN in grid → no panic (does not underflow).
1124    #[test]
1125    fn test_interpolate_nan_grid_no_panic() {
1126        let xs = vec![10.0, 20.0, 30.0];
1127
1128        // Case 1: NaN query on a clean grid — the NaN guard at line 282
1129        // returns 0.0 before reaching the binary search.  This is the only
1130        // code path that *would* hit Err(0) if the guard were absent.
1131        let clean_grid = vec![1.0, 2.0, 3.0];
1132        let result = interpolate_cross_section(&clean_grid, &xs, f64::NAN);
1133        assert_eq!(result, 0.0, "NaN query should return 0.0 via the guard");
1134
1135        // Case 2: NaN in the grid at position 0 — the boundary check
1136        // `energy <= energies[0]` is false (NaN comparison), so we fall
1137        // through to the binary search.  The search treats NaN as Less,
1138        // returning Err(k>0), so the Err(0) arm is NOT reached.  The
1139        // function should not panic.
1140        let nan_grid = vec![f64::NAN, 2.0, 3.0];
1141        let result2 = interpolate_cross_section(&nan_grid, &xs, 1.5);
1142        // Result may be NaN (interpolating with a NaN grid point), but
1143        // the important thing is no panic from usize underflow.
1144        let _ = result2; // just verify no panic
1145    }
1146
1147    // ── Milestone A: Analytical derivative validation ──
1148
1149    /// Helper: generate a simple resonance-like cross-section for testing.
1150    fn test_resonance_xs(energies: &[f64], e_res: f64, gamma: f64, peak: f64) -> Vec<f64> {
1151        energies
1152            .iter()
1153            .map(|&e| {
1154                let x = (e - e_res) / gamma;
1155                peak / (1.0 + x * x) + 10.0 // Breit-Wigner + constant
1156            })
1157            .collect()
1158    }
1159
1160    /// A1: Analytical derivative vs central FD for U-238 at 293.6K.
1161    #[test]
1162    fn test_analytical_derivative_vs_fd_u238_293k() {
1163        let energies: Vec<f64> = (0..200).map(|i| 1.0 + i as f64 * 0.05).collect();
1164        let xs = test_resonance_xs(&energies, 6.67, 0.025, 5000.0);
1165        let params = DopplerParams::new(293.6, 238.051).unwrap();
1166
1167        // Analytical derivative
1168        let (broadened, dxs_dt) = doppler_broaden_with_derivative(&energies, &xs, &params).unwrap();
1169
1170        // Central FD derivative
1171        let dt = 1e-4 * (1.0 + params.temperature_k);
1172        let params_up = DopplerParams::new(params.temperature_k + dt, params.awr).unwrap();
1173        let params_down =
1174            DopplerParams::new((params.temperature_k - dt).max(0.1), params.awr).unwrap();
1175        let actual_2dt = (params.temperature_k + dt) - (params.temperature_k - dt).max(0.1);
1176
1177        let xs_up = doppler_broaden(&energies, &xs, &params_up).unwrap();
1178        let xs_down = doppler_broaden(&energies, &xs, &params_down).unwrap();
1179
1180        // Use combined error metric: relative where derivative is significant,
1181        // absolute where derivative is small (avoiding catastrophic cancellation
1182        // in flat regions far from resonances — a known limitation of the
1183        // quotient-rule formulation when sum_y and dsum_g nearly cancel).
1184        let max_deriv: f64 = (0..energies.len())
1185            .map(|i| ((xs_up[i] - xs_down[i]) / actual_2dt).abs())
1186            .fold(0.0f64, f64::max);
1187        let abs_tol = max_deriv * 1e-4;
1188
1189        let mut max_rel_err = 0.0f64;
1190        let mut n_significant = 0;
1191        for i in 0..energies.len() {
1192            let fd = (xs_up[i] - xs_down[i]) / actual_2dt;
1193            if fd.abs() < 1e-15 {
1194                continue;
1195            }
1196            // For significant derivatives (> 1% of peak), check relative error.
1197            if fd.abs() > max_deriv * 0.01 {
1198                let rel_err = ((dxs_dt[i] - fd) / fd).abs();
1199                max_rel_err = max_rel_err.max(rel_err);
1200                n_significant += 1;
1201            } else {
1202                // For small derivatives, check absolute error.
1203                let abs_err = (dxs_dt[i] - fd).abs();
1204                assert!(
1205                    abs_err < abs_tol,
1206                    "E={:.3}: abs error {:.2e} exceeds tol {:.2e} (analytical={:.2e}, FD={:.2e})",
1207                    energies[i],
1208                    abs_err,
1209                    abs_tol,
1210                    dxs_dt[i],
1211                    fd
1212                );
1213            }
1214        }
1215        assert!(
1216            n_significant > 5,
1217            "need at least 5 significant derivative points, got {n_significant}"
1218        );
1219        assert!(
1220            max_rel_err < 1e-6,
1221            "analytical vs FD relative error (significant bins) = {max_rel_err:.2e}, expected < 1e-6"
1222        );
1223
1224        // Verify forward pass matches standalone doppler_broaden
1225        let broadened_ref = doppler_broaden(&energies, &xs, &params).unwrap();
1226        for i in 0..energies.len() {
1227            assert!(
1228                (broadened[i] - broadened_ref[i]).abs() < 1e-14,
1229                "forward pass mismatch at bin {i}: {} vs {}",
1230                broadened[i],
1231                broadened_ref[i]
1232            );
1233        }
1234    }
1235
1236    /// A2: Stability across temperature range (100K, 500K, 1000K).
1237    #[test]
1238    fn test_analytical_derivative_temperature_range() {
1239        let energies: Vec<f64> = (0..200).map(|i| 1.0 + i as f64 * 0.05).collect();
1240        let xs = test_resonance_xs(&energies, 6.67, 0.025, 5000.0);
1241
1242        for &temp in &[100.0, 500.0, 1000.0] {
1243            let params = DopplerParams::new(temp, 238.051).unwrap();
1244            let (_broadened, dxs_dt) =
1245                doppler_broaden_with_derivative(&energies, &xs, &params).unwrap();
1246
1247            // FD reference
1248            let dt = 1e-4 * (1.0 + temp);
1249            let p_up = DopplerParams::new(temp + dt, 238.051).unwrap();
1250            let p_down = DopplerParams::new((temp - dt).max(0.1), 238.051).unwrap();
1251            let actual_2dt = (temp + dt) - (temp - dt).max(0.1);
1252            let xs_up = doppler_broaden(&energies, &xs, &p_up).unwrap();
1253            let xs_down = doppler_broaden(&energies, &xs, &p_down).unwrap();
1254
1255            // Same combined metric as A1: relative for significant, absolute for small.
1256            let max_deriv: f64 = (0..energies.len())
1257                .map(|i| ((xs_up[i] - xs_down[i]) / actual_2dt).abs())
1258                .fold(0.0f64, f64::max);
1259            let mut max_rel_err = 0.0f64;
1260            for i in 0..energies.len() {
1261                let fd = (xs_up[i] - xs_down[i]) / actual_2dt;
1262                if fd.abs() < max_deriv * 0.01 {
1263                    continue; // skip small derivatives
1264                }
1265                max_rel_err = max_rel_err.max(((dxs_dt[i] - fd) / fd).abs());
1266            }
1267            assert!(
1268                max_rel_err < 1e-6,
1269                "T={temp}K: analytical vs FD max rel error = {max_rel_err:.2e}"
1270            );
1271        }
1272    }
1273
1274    /// A3: Different AWR (Hf-178, heavier nucleus).
1275    #[test]
1276    fn test_analytical_derivative_hf178() {
1277        let energies: Vec<f64> = (0..100).map(|i| 1.0 + i as f64 * 0.1).collect();
1278        let xs = test_resonance_xs(&energies, 7.8, 0.05, 3000.0);
1279        let params = DopplerParams::new(293.6, 177.95).unwrap();
1280
1281        let (_broadened, dxs_dt) =
1282            doppler_broaden_with_derivative(&energies, &xs, &params).unwrap();
1283
1284        let dt = 1e-4 * (1.0 + 293.6);
1285        let p_up = DopplerParams::new(293.6 + dt, 177.95).unwrap();
1286        let p_down = DopplerParams::new(293.6 - dt, 177.95).unwrap();
1287        let xs_up = doppler_broaden(&energies, &xs, &p_up).unwrap();
1288        let xs_down = doppler_broaden(&energies, &xs, &p_down).unwrap();
1289
1290        let max_deriv: f64 = (0..energies.len())
1291            .map(|i| ((xs_up[i] - xs_down[i]) / (2.0 * dt)).abs())
1292            .fold(0.0f64, f64::max);
1293        let mut max_rel_err = 0.0f64;
1294        for i in 0..energies.len() {
1295            let fd = (xs_up[i] - xs_down[i]) / (2.0 * dt);
1296            if fd.abs() < max_deriv * 0.01 {
1297                continue;
1298            }
1299            max_rel_err = max_rel_err.max(((dxs_dt[i] - fd) / fd).abs());
1300        }
1301        assert!(
1302            max_rel_err < 1e-6,
1303            "Hf-178: analytical vs FD max rel error = {max_rel_err:.2e}"
1304        );
1305    }
1306
1307    /// A4: Compare against SAMMY-style FD (±2% Doppler width perturbation).
1308    #[test]
1309    fn test_analytical_derivative_vs_sammy_style_fd() {
1310        let energies: Vec<f64> = (0..200).map(|i| 1.0 + i as f64 * 0.05).collect();
1311        let xs = test_resonance_xs(&energies, 6.67, 0.025, 5000.0);
1312        let params = DopplerParams::new(293.6, 238.051).unwrap();
1313
1314        let (_broadened, dxs_dt) =
1315            doppler_broaden_with_derivative(&energies, &xs, &params).unwrap();
1316
1317        // SAMMY-style: perturb Doppler width by ±2%
1318        let del = 0.02;
1319        let _u = params.u(); // retained for documentation; T_up/T_down use (1±del)²
1320        // D_up = u * (1 + del), corresponds to T_up such that √(kT_up/AWR) = u*(1+del)
1321        // T_up = T * (1+del)²
1322        let t_up = params.temperature_k * (1.0 + del) * (1.0 + del);
1323        let t_down = params.temperature_k * (1.0 - del) * (1.0 - del);
1324        let p_up = DopplerParams::new(t_up, params.awr).unwrap();
1325        let p_down = DopplerParams::new(t_down, params.awr).unwrap();
1326        let xs_up = doppler_broaden(&energies, &xs, &p_up).unwrap();
1327        let xs_down = doppler_broaden(&energies, &xs, &p_down).unwrap();
1328
1329        // SAMMY: ∂σ/∂D = (σ(1.02·D) - σ(0.98·D)) / (0.04·D)
1330        // ∂σ/∂T = ∂σ/∂D · D/(2T)
1331        // Combined: ∂σ/∂T ≈ (σ(T_up) - σ(T_down)) / (T_up - T_down)
1332        let actual_dt = t_up - t_down;
1333
1334        // SAMMY FD has O(del²) = O(4e-4) truncation error, so we allow
1335        // slightly looser tolerance. Use same combined metric.
1336        let max_deriv: f64 = (0..energies.len())
1337            .map(|i| ((xs_up[i] - xs_down[i]) / actual_dt).abs())
1338            .fold(0.0f64, f64::max);
1339        let mut max_rel_err = 0.0f64;
1340        for i in 0..energies.len() {
1341            let sammy_fd = (xs_up[i] - xs_down[i]) / actual_dt;
1342            if sammy_fd.abs() < max_deriv * 0.01 {
1343                continue; // skip small derivatives
1344            }
1345            let rel_err = ((dxs_dt[i] - sammy_fd) / sammy_fd).abs();
1346            max_rel_err = max_rel_err.max(rel_err);
1347        }
1348        assert!(
1349            max_rel_err < 1e-3,
1350            "analytical vs SAMMY-style FD max rel error = {max_rel_err:.2e}, expected < 1e-3"
1351        );
1352    }
1353}