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