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, ¶ms).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, ¶ms).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, ¶ms).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, ¶ms).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, ¶ms).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, ¶ms_up).unwrap();
1178 let xs_down = doppler_broaden(&energies, &xs, ¶ms_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, ¶ms).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, ¶ms).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, ¶ms).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, ¶ms).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}