nereids_physics/slbw.rs
1//! Single-Level Breit-Wigner (SLBW) cross-section calculation.
2//!
3//! SLBW is the simplest resonance formalism. It treats each resonance
4//! independently (no interference between resonances). It is useful as:
5//! - A validation check against the more complex Reich-Moore calculation
6//! - A quick approximation for isolated resonances
7//! - An educational/debugging tool
8//!
9//! For isolated, well-separated resonances, SLBW and Reich-Moore should
10//! give nearly identical results.
11//!
12//! This module also provides true MLBW (Multi-Level Breit-Wigner) via
13//! `mlbw_evaluate_with_cached_jgroups`, which includes resonance-resonance
14//! interference in the elastic channel (see SAMMY `mlb/mmlb3.f90`).
15//! MLBW is dispatched through the `reich_moore` crate's unified
16//! precompute+evaluate pipeline (public entry points
17//! `cross_sections_at_energy` and `cross_sections_on_grid`) like every
18//! other formalism.
19//!
20//! ## SAMMY Reference
21//! - `mlb/mmlb3.f90` Elastc_Mlb subroutine (line 56, SLBW branch) — the elastic
22//! cross-section formula this module mirrors.
23//! - `mlb/mmlb4.f90` Abpart_Mlb subroutine — populates the Aaaone/Aaatwo/Aaathr
24//! accumulators consumed by Elastc_Mlb.
25//! - `xxx/mxxx9.f90` lines 68-71 (`Cs2sn2`) — confirms SAMMY's `Cs/Si` arrays
26//! hold `cos(2φ)/sin(2φ)`, not `cos(φ)/sin(φ)`.
27//! - SAMMY manual Section II.B.3 (Breit-Wigner approximations).
28//!
29//! ## Formulas
30//! For each resonance at energy E_r with total J, write Δ = E − E_r and
31//! D = Δ² + (Γ_tot/2)². Then:
32//!
33//! σ_capture(E) = g_J · π/k² · Γ_n(E)·Γ_γ / D
34//!
35//! σ_elastic(E) = g_J · π/k² · [
36//! 4·sin²φ (potential)
37//! + Γ_n(E)² / D (resonance peak)
38//! + 2·Γ_n(E)·Δ·sin(2φ) / D (interference, Δ part)
39//! − 2·Γ_n(E)·Γ_tot·sin²φ / D (interference, Γ part)
40//! ]
41//!
42//! where Γ_n(E) = Γ_n(E_r) × P_l(E)/P_l(E_r) per ENDF-6 §D.1.1 eq D.7
43//! (ENDF-102 Formats Manual, IAEA public PDF page 357), and
44//! Γ_tot = Γ_n(E) + Γ_γ + Γ_f.
45//!
46//! The penetrability ratio already carries the full energy dependence of
47//! the neutron width. For s-wave (l=0), P_0(ρ) = ρ ∝ √E, so the ratio
48//! P_0(E)/P_0(E_r) = √(E/E_r) supplies the entire √E low-energy scaling;
49//! multiplying by a separate √(E/E_r) factor double-counts the wave-number
50//! dependence and yields Γ_n ∝ E rather than the physically correct
51//! Γ_n ∝ √E (1/v capture). The reference implementations all use the
52//! penetrability-ratio-only form:
53//!
54//! - NJOY/RECONR `src/reconr.f90` `csslbw`/`csmlbw`/`csmlbw2`:
55//! `gne = gn*pe*rper` (with `rper = 1/per`).
56//! - SAMMY `mlb/mmlb4.f90` `Abpart_Mlb` (lines 88-100) accumulates
57//! `Γ_n(E) = 2·P_l(E)·γ_n²` from reduced amplitudes `γ_n` obtained via
58//! `γ_n² = GN/(2·P_l(E_r))` (SAMMY `new/mnew3.f90:307-339` `Betset`),
59//! which is algebraically identical to ENDF D.7 after substitution.
60//!
61//! The sign of the `Γ_tot·sin²φ` interference term is negative. This is
62//! equivalent to SAMMY's `(1 − cos2φ)·A + sin2φ·B + Aaathr·D` form at
63//! `mmlb3.f90:56` after substituting `A = 2 − Γ_n·Γ_tot/Den`, and to
64//! `σ_el = g_J · π/k² · |1 − U_nn|²` with
65//! `U_nn = e^{−2iφ} · [1 + iΓ_n / (E_r − E − iΓ_tot/2)]`.
66//!
67//! Issue #549: prior to this commit the term had the wrong sign (`+` instead
68//! of `−`). The bias was numerically invisible in the existing `samtry`
69//! validation suite because all SLBW test cases are at ρ ≪ 1 where
70//! sin²φ ≈ ρ². The high-ρ regression test lives at
71//! `crates/nereids-physics/tests/slbw_elastic_oracle.rs`.
72
73use nereids_core::constants::{DIVISION_FLOOR, PIVOT_FLOOR};
74use nereids_endf::resonance::ResonanceData;
75
76use crate::channel;
77use crate::penetrability;
78use crate::reich_moore::{CrossSections, PrecomputedJGroup, group_resonances_by_j};
79
80// ─── Per-resonance precomputed invariants for SLBW ────────────────────────────
81//
82// In SLBW, the energy-dependent neutron width is (ENDF-6 §D.1.1 eq D.7,
83// matching NJOY/RECONR `csslbw`/`csmlbw` and SAMMY `mlb/mmlb4.f90:88-100`):
84// Γ_n(E) = Γ_n(E_r) × P_l(E)/P_l(E_r)
85//
86// The quantities that depend only on resonance parameters (not on E):
87// - P_l(E_r): penetrability at resonance energy
88// - |Γ_n(E_r)|: neutron width magnitude
89// - Γ_γ, Γ_f: capture and fission widths
90// - E_r: resonance energy
91//
92// Issue #87: Pre-compute P_l(E_r) and J-groups once before the energy loop.
93
94/// Per-resonance invariants for SLBW, pre-computed once per resonance.
95pub(crate) struct PrecomputedSlbwResonance {
96 /// Resonance energy E_r (eV).
97 pub(crate) energy: f64,
98 /// Capture width Γ_γ (eV).
99 pub(crate) gamma_g: f64,
100 /// Fission width |Γ_fa| + |Γ_fb| (eV).
101 pub(crate) gamma_f: f64,
102 /// Absolute neutron width |Γ_n(E_r)| (eV).
103 pub(crate) gn_abs: f64,
104 /// Penetrability at resonance energy P_l(ρ_r).
105 /// Pre-computed to avoid redundant `penetrability(l, rho_r)` calls.
106 pub(crate) p_at_er: f64,
107}
108
109/// Pre-computed J-group for SLBW (type alias for the generic J-group).
110pub(crate) type PrecomputedSlbwJGroup = PrecomputedJGroup<PrecomputedSlbwResonance>;
111
112/// Build pre-computed J-groups for SLBW.
113///
114/// All quantities depend only on resonance parameters (not incident energy),
115/// so the result can be computed once and reused across all energy points.
116///
117/// Uses the shared `group_resonances_by_j` helper (Issue #158) with an
118/// SLBW-specific closure for per-resonance invariant construction.
119pub(crate) fn precompute_slbw_jgroups(
120 resonances: &[nereids_endf::resonance::Resonance],
121 l: u32,
122 awr_l: f64,
123 range: &nereids_endf::resonance::ResonanceRange,
124 l_group: &nereids_endf::resonance::LGroup,
125 target_spin: f64,
126) -> Vec<PrecomputedSlbwJGroup> {
127 group_resonances_by_j(resonances, target_spin, |res| {
128 let e_r = res.energy;
129
130 // Pre-compute P_l(E_r) — this is the redundant computation Issue #87 eliminates.
131 let p_at_er = if e_r.abs() > PIVOT_FLOOR {
132 let radius_at_er = if l_group.apl > 0.0 {
133 l_group.apl
134 } else if range.naps == 0 {
135 // NAPS=0: use channel radius per ENDF-6 §2.2.1
136 channel::endf_channel_radius_fm(awr_l)
137 } else {
138 // NAPS=1 (default): use scattering radius AP or AP(E)
139 range.scattering_radius_at(e_r.abs())
140 };
141 let rho_r = channel::rho(e_r.abs(), awr_l, radius_at_er);
142 penetrability::penetrability(l, rho_r)
143 } else {
144 0.0 // Will produce gamma_n = 0 in the energy loop
145 };
146
147 PrecomputedSlbwResonance {
148 energy: e_r,
149 gamma_g: res.gg,
150 gamma_f: res.gfa.abs() + res.gfb.abs(),
151 gn_abs: res.gn.abs(),
152 p_at_er,
153 }
154 })
155}
156
157/// Compute SLBW cross-sections at a single energy.
158///
159/// Works with both SLBW and Reich-Moore ENDF data (uses the same
160/// resonance parameters but applies the SLBW formulas).
161pub fn slbw_cross_sections(data: &ResonanceData, energy_ev: f64) -> CrossSections {
162 let awr = data.awr;
163
164 let mut total = 0.0;
165 let mut elastic = 0.0;
166 let mut capture = 0.0;
167 let mut fission = 0.0;
168
169 // Use simple closed-interval logic: energy_ev must be in [e_low, e_high].
170 //
171 // This function evaluates *only* resolved SLBW/MLBW ranges and skips
172 // everything else (including URR ranges where !range.resolved). Using
173 // half-open [e_low, e_high) when the next range is a URR range would
174 // exclude the shared boundary energy from the resolved range while still
175 // skipping the URR range, producing zero XS at the boundary — an
176 // artificial dip. Closed intervals prevent that gap. A double-count at
177 // a shared boundary between two resolved ranges is a negligibly small
178 // effect compared to the gap that half-open logic would introduce.
179 for range in &data.ranges {
180 if !range.resolved || energy_ev < range.energy_low || energy_ev > range.energy_high {
181 continue;
182 }
183
184 // Each range carries its own target_spin — pass per-range, not
185 // from the first range, to correctly compute statistical weights g_J.
186 let (t, e, c, f) = slbw_cross_sections_for_range(range, energy_ev, awr, range.target_spin);
187 total += t;
188 elastic += e;
189 capture += c;
190 fission += f;
191 }
192
193 CrossSections {
194 total,
195 elastic,
196 capture,
197 fission,
198 }
199}
200
201/// Compute SLBW cross-sections for a single resolved resonance range.
202///
203/// Thin wrapper: per L-group, builds the precomputed J-groups once and
204/// delegates to `slbw_evaluate_with_cached_jgroups`. The batch grid
205/// path (`cross_sections_on_grid`) calls the same evaluator with
206/// J-groups precomputed once per range, which is how we guarantee
207/// batch/per-point bit-exact equivalence.
208///
209/// Returns `(total, elastic, capture, fission)` in barns.
210///
211/// # Panics
212/// Panics if `energy_ev` is not finite or is non-positive. This is a
213/// defensive guard at the public boundary; the Python wrapper and the
214/// SAMMY-style dispatcher already validate the energy grid via
215/// `validate_energy_grid`, so this assertion only fires for direct
216/// callers (other Rust crates, tests) that bypass the grid check.
217pub fn slbw_cross_sections_for_range(
218 range: &nereids_endf::resonance::ResonanceRange,
219 energy_ev: f64,
220 awr: f64,
221 target_spin: f64,
222) -> (f64, f64, f64, f64) {
223 // Defensive input validation at the public boundary (issue #558).
224 // See `urr::urr_cross_sections` for rationale. The leaf
225 // `pi_over_k_squared_barns` retains its `debug_assert!`; this entry
226 // guard makes the precondition genuinely enforced in release builds
227 // for direct Rust callers that bypass the Python wrapper's
228 // `validate_energy_grid`.
229 assert!(
230 energy_ev.is_finite() && energy_ev > 0.0,
231 "expected positive finite energy_ev, got {energy_ev}"
232 );
233
234 let pi_over_k2 = channel::pi_over_k_squared_barns(energy_ev, awr);
235
236 let mut total = 0.0;
237 let mut elastic = 0.0;
238 let mut capture = 0.0;
239 let mut fission = 0.0;
240
241 for l_group in &range.l_groups {
242 let l = l_group.l;
243 let awr_l = if l_group.awr > 0.0 { l_group.awr } else { awr };
244
245 // Scattering radius for phase shift (always AP/APL).
246 let scatt_radius = if l_group.apl > 0.0 {
247 l_group.apl
248 } else {
249 range.scattering_radius_at(energy_ev)
250 };
251 // Penetrability radius: NAPS=0 uses channel radius formula,
252 // NAPS=1 uses scattering radius (ENDF-6 §2.2.1).
253 let pen_radius = if l_group.apl > 0.0 {
254 l_group.apl
255 } else if range.naps == 0 {
256 channel::endf_channel_radius_fm(awr_l)
257 } else {
258 scatt_radius
259 };
260
261 let rho_phase = channel::rho(energy_ev, awr_l, scatt_radius);
262 let rho_pen = channel::rho(energy_ev, awr_l, pen_radius);
263 let phi = penetrability::phase_shift(l, rho_phase);
264 let sin_phi = phi.sin();
265 let cos_phi = phi.cos();
266 let sin2_phi = sin_phi * sin_phi;
267 let p_at_e = penetrability::penetrability(l, rho_pen);
268
269 let j_groups =
270 precompute_slbw_jgroups(&l_group.resonances, l, awr_l, range, l_group, target_spin);
271
272 let (t, e, c, f) = slbw_evaluate_with_cached_jgroups(
273 &j_groups, energy_ev, pi_over_k2, p_at_e, sin_phi, cos_phi, sin2_phi,
274 );
275 total += t;
276 elastic += e;
277 capture += c;
278 fission += f;
279 }
280
281 (total, elastic, capture, fission)
282}
283
284/// Evaluate SLBW cross-sections for a single L-group using pre-cached J-groups.
285///
286/// This is the per-energy inner loop extracted from `slbw_cross_sections_for_range`,
287/// used by the batch grid path (`cross_sections_on_grid`) to avoid redundant
288/// J-group precomputation at every energy point.
289///
290/// # Arguments
291/// * `jgroups` — Pre-computed J-groups (energy-independent invariants).
292/// * `energy_ev` — Incident neutron energy (eV).
293/// * `pi_over_k2` — π/k² in barns at this energy.
294/// * `p_at_e` — Penetrability P_l(ρ) at incident energy.
295/// * `sin_phi` — sin(φ_l) at incident energy.
296/// * `cos_phi` — cos(φ_l) at incident energy.
297/// * `sin2_phi` — sin²(φ_l) at incident energy.
298///
299/// # Returns
300/// `(total, elastic, capture, fission)` in barns for this L-group.
301#[allow(clippy::too_many_arguments)]
302pub(crate) fn slbw_evaluate_with_cached_jgroups(
303 jgroups: &[PrecomputedSlbwJGroup],
304 energy_ev: f64,
305 pi_over_k2: f64,
306 p_at_e: f64,
307 sin_phi: f64,
308 cos_phi: f64,
309 sin2_phi: f64,
310) -> (f64, f64, f64, f64) {
311 let mut total = 0.0;
312 let mut elastic = 0.0;
313 let mut capture = 0.0;
314 let mut fission = 0.0;
315
316 for jg in jgroups {
317 let g_j = jg.g_j;
318
319 // Potential scattering for this (l, J) group.
320 let pot_scatter = pi_over_k2 * g_j * 4.0 * sin2_phi;
321 elastic += pot_scatter;
322 total += pot_scatter;
323
324 for res in &jg.resonances {
325 let e_r = res.energy;
326
327 // Energy-dependent neutron width (ENDF-6 §D.1.1 eq D.7):
328 // Γ_n(E) = Γ_n(E_r) × P_l(E)/P_l(E_r)
329 // The penetrability ratio already carries the full √E (s-wave)
330 // velocity dependence; an extra √(E/E_r) multiplier would
331 // double-count the wave-number factor (see module docstring).
332 // Matches NJOY/RECONR `csslbw` `gne = gn*pe*rper` and SAMMY
333 // `mlb/mmlb4.f90:88-100` after the reduced-amplitude substitution.
334 // P_l(E_r) is pre-computed in res.p_at_er (Issue #87).
335 let gamma_n = if e_r.abs() > PIVOT_FLOOR && res.p_at_er > PIVOT_FLOOR {
336 res.gn_abs * p_at_e / res.p_at_er
337 } else {
338 0.0
339 };
340
341 let gamma_total = gamma_n + res.gamma_g + res.gamma_f;
342 let de = energy_ev - e_r;
343 let denom = de * de + (gamma_total / 2.0).powi(2);
344
345 if denom < DIVISION_FLOOR {
346 continue;
347 }
348
349 // Capture cross-section (symmetric Breit-Wigner).
350 let sigma_c = pi_over_k2 * g_j * gamma_n * res.gamma_g / denom;
351 capture += sigma_c;
352 total += sigma_c;
353
354 // Fission cross-section.
355 let sigma_f = pi_over_k2 * g_j * gamma_n * res.gamma_f / denom;
356 fission += sigma_f;
357 total += sigma_f;
358
359 // Elastic resonance scattering (interference term + resonance peak).
360 let sigma_e_res = pi_over_k2 * g_j * gamma_n * gamma_n / denom;
361 elastic += sigma_e_res;
362 total += sigma_e_res;
363
364 // Interference between resonance and potential scattering.
365 // The Γ_tot·sin²φ part is NEGATIVE: resonance attenuates the
366 // near-resonance potential scattering. See module docstring above
367 // for the derivation and SAMMY `mmlb3.f90:56` for the reference.
368 let interf = pi_over_k2
369 * g_j
370 * 2.0
371 * gamma_n
372 * (de * cos_phi * 2.0 * sin_phi - (gamma_total / 2.0) * 2.0 * sin2_phi)
373 / denom;
374 elastic += interf;
375 total += interf;
376 }
377 }
378
379 (total, elastic, capture, fission)
380}
381
382/// Evaluate **true MLBW** cross-sections for a single L-group using
383/// pre-cached J-groups (resonance-resonance interference in elastic).
384///
385/// This is the per-energy MLBW inner loop. The batch grid path
386/// (`reich_moore::cross_sections_on_grid`) precomputes the J-groups
387/// once per range and calls this evaluator at each energy; the
388/// single-energy entry point (`reich_moore::cross_sections_at_energy`)
389/// goes through the same precompute+evaluate pipeline. Both callers
390/// share exactly this evaluator — which is what prevents the #465
391/// class of bug (the batch dispatcher previously routed MLBW ranges
392/// through `slbw_evaluate_with_cached_jgroups`,
393/// silently computing SLBW's incoherent elastic sum instead of MLBW's
394/// coherent sum).
395///
396/// # Arguments
397/// * `jgroups` — Pre-computed J-groups (energy-independent invariants).
398/// Uses the same `PrecomputedSlbwJGroup` type as SLBW; the cached
399/// `p_at_er` and `gn_abs` have identical meaning across both
400/// formalisms.
401/// * `energy_ev` — Incident neutron energy (eV).
402/// * `pi_over_k2` — π/k² in barns at this energy.
403/// * `p_at_e` — Penetrability P_l(ρ) at the incident energy.
404/// * `phi` — Hard-sphere phase shift φ_l at the incident energy.
405///
406/// # Returns
407/// `(total, elastic, capture, fission)` in barns for this L-group.
408///
409/// # Physics
410/// U_nn = e^{-2iφ} · [1 + i · Σ_r Γ_n^r / (E_r - E - iΓ_tot^r/2)]
411/// σ_elastic = (π/k²) · g_J · |1 - U_nn|²
412///
413/// Capture and fission are identical to SLBW (per-resonance incoherent
414/// sums).
415///
416/// # SAMMY reference
417/// `mlb/mmlb3.f90` Elastc_Mlb, `mlb/mmlb4.f90` Abpart_Mlb.
418pub(crate) fn mlbw_evaluate_with_cached_jgroups(
419 jgroups: &[PrecomputedSlbwJGroup],
420 energy_ev: f64,
421 pi_over_k2: f64,
422 p_at_e: f64,
423 phi: f64,
424) -> (f64, f64, f64, f64) {
425 use num_complex::Complex64;
426
427 // Phase factor: e^{-2iφ} = cos(2φ) - i·sin(2φ).
428 //
429 // TRUTH SOURCE: SAMMY mlb/mmlb3.f90 Elastc_Mlb line 54.
430 // Convention: U = e^{-2iφ} · (1 + iX), NOT e^{+2iφ} · (1 - iX).
431 // The positive exponent was a bug (commit 5508fea) that caused
432 // negative total cross-sections for all MLBW isotopes (Hf-176/177/
433 // 178/179). Fixed in commit f0eadc1.
434 let phase2 = Complex64::new((2.0 * phi).cos(), -(2.0 * phi).sin());
435
436 let mut elastic = 0.0;
437 let mut capture = 0.0;
438 let mut fission = 0.0;
439
440 for jg in jgroups {
441 let g_j = jg.g_j;
442
443 // Coherent sum over resonances: X = Σ_r Γ_n^r / (E_r - E - iΓ_tot^r/2)
444 let mut x_sum = Complex64::new(0.0, 0.0);
445
446 for res in &jg.resonances {
447 let e_r = res.energy;
448 // Energy-dependent neutron width (ENDF-6 §D.1.1 eq D.7;
449 // section D.1.2 states MLBW uses the same width conversion as
450 // SLBW). Matches NJOY/RECONR `csmlbw` `gne = gn*pe*rper`.
451 let gamma_n = if e_r.abs() > PIVOT_FLOOR && res.p_at_er > PIVOT_FLOOR {
452 res.gn_abs * p_at_e / res.p_at_er
453 } else {
454 0.0
455 };
456
457 let gamma_total = gamma_n + res.gamma_g + res.gamma_f;
458 let de = energy_ev - e_r;
459 let denom = de * de + (gamma_total / 2.0).powi(2);
460
461 if denom < DIVISION_FLOOR {
462 continue;
463 }
464
465 // Capture — identical to SLBW (incoherent per-resonance).
466 let sigma_c = pi_over_k2 * g_j * gamma_n * res.gamma_g / denom;
467 capture += sigma_c;
468
469 // Fission — identical to SLBW (incoherent per-resonance).
470 let sigma_f = pi_over_k2 * g_j * gamma_n * res.gamma_f / denom;
471 fission += sigma_f;
472
473 // Accumulate coherent sum for U-matrix:
474 // Γ_n / (E_r - E - iΓ_tot/2)
475 // = Γ_n · (E_r - E + iΓ_tot/2) / [(E_r-E)² + (Γ_tot/2)²]
476 // Note: de = E - E_r, so E_r - E = -de.
477 let x_r = Complex64::new(
478 gamma_n * (-de) / denom,
479 gamma_n * (gamma_total / 2.0) / denom,
480 );
481 x_sum += x_r;
482 }
483
484 // Collision matrix: U_nn = e^{-2iφ} · (1 + i·X)
485 // 1 + i·X = (1 - X_im) + i·X_re
486 let one_plus_ix = Complex64::new(1.0 - x_sum.im, x_sum.re);
487 let u_nn = phase2 * one_plus_ix;
488
489 // σ_elastic = (π/k²) · g_J · |1 - U_nn|²
490 let one_minus_u = Complex64::new(1.0 - u_nn.re, -u_nn.im);
491 let sigma_el = pi_over_k2 * g_j * one_minus_u.norm_sqr();
492 elastic += sigma_el;
493
494 // σ_total is the sum of the channel components (NOT the optical
495 // theorem). The optical theorem σ_total = 2π/k² · g · (1 - Re(U))
496 // holds only for a UNITARY S-matrix. In MLBW capture and fission
497 // remove flux from the elastic channel, so |U| < 1 and the
498 // optical theorem overestimates absorption.
499 }
500
501 let total = elastic + capture + fission;
502 (total, elastic, capture, fission)
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508 use nereids_endf::resonance::{LGroup, Resonance, ResonanceFormalism, ResonanceRange};
509
510 #[test]
511 fn test_slbw_capture_peak() {
512 // Single resonance at 6.674 eV (U-238).
513 // SLBW and RM should agree well for an isolated resonance.
514 let data =
515 nereids_endf::resonance::test_support::u238_with_formalism(ResonanceFormalism::SLBW);
516
517 let xs = slbw_cross_sections(&data, 6.674);
518 println!("SLBW capture at 6.674 eV: {} barns", xs.capture);
519
520 // Same estimate as RM: ~22,000 barns.
521 assert!(
522 xs.capture > 15000.0 && xs.capture < 30000.0,
523 "Capture should be ~22000 barns, got {}",
524 xs.capture
525 );
526 }
527
528 #[test]
529 fn test_slbw_vs_rm_single_resonance() {
530 // For a single isolated resonance, SLBW and RM should be very close.
531 use crate::reich_moore;
532 use nereids_endf::resonance::test_support::u238_with_formalism;
533
534 let rm_data = u238_with_formalism(ResonanceFormalism::ReichMoore);
535 let slbw_data = u238_with_formalism(ResonanceFormalism::SLBW);
536
537 // Compare at several energies near the resonance peak.
538 // Both formalisms apply the ENDF-6 §D.1.1 eq D.7 energy dependence
539 // Γ_n(E) = Γ_n(E_r) · P_l(E)/P_l(E_r) (no extra √(E/E_r) factor;
540 // see module docstring). No broadening kernel is applied on either
541 // side — both are evaluated on the same unbroadened energy grid, so
542 // Doppler is not a source of discrepancy here.
543 //
544 // For a single isolated resonance there is no multi-level interference
545 // contribution: MLBW summation degenerates to SLBW when only one
546 // resonance is present (the cross-resonance interference terms in
547 // SAMMY `mlb/mmlb3.f90` vanish term-by-term). The residual we expect
548 // is therefore purely the algebraic difference between RM's
549 // channel-matrix denominator det(I − K) (3×3 here: elastic + capture +
550 // two fission channels collapse to elastic + capture for Γ_f = 0) and
551 // SLBW's scalar (E − E_r) − iΓ/2 denominator. This is much smaller
552 // than the ~10-15 % gap the previous comment claimed, which was a
553 // symptom of a double-counted velocity factor that has since been
554 // removed.
555 for &e in &[6.5, 6.674, 7.0] {
556 let rm = reich_moore::cross_sections_at_energy(&rm_data, e);
557 let slbw = slbw_cross_sections(&slbw_data, e);
558
559 let rel_diff_cap =
560 (rm.capture - slbw.capture).abs() / rm.capture.max(slbw.capture).max(1e-10);
561
562 println!(
563 "E={:.3}: RM_cap={:.2}, SLBW_cap={:.2}, rel_diff={:.4}",
564 e, rm.capture, slbw.capture, rel_diff_cap
565 );
566
567 // Near the peak, the formalisms should agree within ~5%.
568 assert!(
569 rel_diff_cap < 0.05,
570 "RM vs SLBW capture differ by {:.1}% at E={} eV",
571 rel_diff_cap * 100.0,
572 e
573 );
574 }
575 }
576
577 /// Verify the SLBW NAPS=0 code path uses the channel radius formula.
578 ///
579 /// Same structure as the RM `test_naps_zero_uses_channel_radius_formula`:
580 /// 1. NAPS=0 with AP = formula_radius matches NAPS=1 with AP = formula_radius
581 /// (confirming the formula gives the expected value)
582 /// 2. NAPS=0 with a different AP still produces valid XS (no NaN/panic)
583 #[test]
584 fn test_slbw_naps_zero_uses_channel_radius_formula() {
585 let awr: f64 = 55.345; // Fe-56-like
586 let formula_radius = channel::endf_channel_radius_fm(awr);
587
588 // NAPS=0: penetrability uses formula, phase shift uses AP (= formula here)
589 let data_naps0 = ResonanceData {
590 isotope: nereids_core::types::Isotope::new(26, 56).unwrap(),
591 za: 26056,
592 awr,
593 ranges: vec![ResonanceRange {
594 energy_low: 1e-5,
595 energy_high: 1e5,
596 resolved: true,
597 formalism: ResonanceFormalism::SLBW,
598 target_spin: 0.0,
599 scattering_radius: formula_radius, // AP = formula → same as pen_radius
600 naps: 0,
601 l_groups: vec![LGroup {
602 l: 1,
603 awr,
604 apl: 0.0,
605 qx: 0.0,
606 lrx: 0,
607 resonances: vec![Resonance {
608 energy: 30000.0,
609 j: 1.5,
610 gn: 5.0,
611 gg: 1.0,
612 gfa: 0.0,
613 gfb: 0.0,
614 }],
615 }],
616 rml: None,
617 urr: None,
618 ap_table: None,
619 r_external: vec![],
620 }],
621 };
622
623 // NAPS=1 with AP = formula_radius: both penetrability and phase use AP
624 let data_naps1 = ResonanceData {
625 isotope: nereids_core::types::Isotope::new(26, 56).unwrap(),
626 za: 26056,
627 awr,
628 ranges: vec![ResonanceRange {
629 energy_low: 1e-5,
630 energy_high: 1e5,
631 resolved: true,
632 formalism: ResonanceFormalism::SLBW,
633 target_spin: 0.0,
634 scattering_radius: formula_radius,
635 naps: 1,
636 l_groups: vec![LGroup {
637 l: 1,
638 awr,
639 apl: 0.0,
640 qx: 0.0,
641 lrx: 0,
642 resonances: vec![Resonance {
643 energy: 30000.0,
644 j: 1.5,
645 gn: 5.0,
646 gg: 1.0,
647 gfa: 0.0,
648 gfb: 0.0,
649 }],
650 }],
651 rml: None,
652 urr: None,
653 ap_table: None,
654 r_external: vec![],
655 }],
656 };
657
658 let e = 30000.0;
659 let xs_naps0 = slbw_cross_sections(&data_naps0, e);
660 let xs_naps1 = slbw_cross_sections(&data_naps1, e);
661
662 // When AP equals the formula radius, NAPS=0 and NAPS=1 should
663 // give identical results (both use the same radius everywhere).
664 assert!(
665 (xs_naps0.total - xs_naps1.total).abs() < 1e-10 * xs_naps1.total.abs().max(1.0),
666 "NAPS=0 total={} vs NAPS=1 total={}: should match when AP=formula",
667 xs_naps0.total,
668 xs_naps1.total,
669 );
670 assert!(
671 (xs_naps0.capture - xs_naps1.capture).abs() < 1e-10 * xs_naps1.capture.abs().max(1.0),
672 "NAPS=0 capture={} vs NAPS=1 capture={}: should match when AP=formula",
673 xs_naps0.capture,
674 xs_naps1.capture,
675 );
676
677 // Verify finite and positive cross-sections (no NaN from formula)
678 assert!(xs_naps0.total.is_finite() && xs_naps0.total > 0.0);
679 assert!(xs_naps0.capture.is_finite() && xs_naps0.capture > 0.0);
680
681 // Also verify NAPS=0 with a different AP still produces valid XS
682 let data_naps0_diff_ap = ResonanceData {
683 isotope: nereids_core::types::Isotope::new(26, 56).unwrap(),
684 za: 26056,
685 awr,
686 ranges: vec![ResonanceRange {
687 energy_low: 1e-5,
688 energy_high: 1e5,
689 resolved: true,
690 formalism: ResonanceFormalism::SLBW,
691 target_spin: 0.0,
692 scattering_radius: 9.0, // Different from formula
693 naps: 0,
694 l_groups: vec![LGroup {
695 l: 1,
696 awr,
697 apl: 0.0,
698 qx: 0.0,
699 lrx: 0,
700 resonances: vec![Resonance {
701 energy: 30000.0,
702 j: 1.5,
703 gn: 5.0,
704 gg: 1.0,
705 gfa: 0.0,
706 gfb: 0.0,
707 }],
708 }],
709 rml: None,
710 urr: None,
711 ap_table: None,
712 r_external: vec![],
713 }],
714 };
715 let xs_diff = slbw_cross_sections(&data_naps0_diff_ap, e);
716 assert!(
717 xs_diff.total.is_finite() && xs_diff.total > 0.0,
718 "NAPS=0 with different AP should still produce valid XS"
719 );
720 }
721
722 /// MLBW cross-sections must be non-negative for multi-resonance data.
723 /// Guard: catches e^{+2iφ} phase convention bug (commit 5508fea, fixed f0eadc1).
724 #[test]
725 fn test_mlbw_multi_resonance_positive() {
726 use crate::reich_moore::cross_sections_at_energy;
727 let data = nereids_endf::resonance::test_support::hf178_mlbw_two_resonances();
728 for &e in &[1.0, 5.0, 7.0, 7.8, 8.0, 10.0, 12.0, 16.9, 17.0, 20.0, 50.0] {
729 let xs = cross_sections_at_energy(&data, e);
730 assert!(xs.total >= 0.0, "MLBW total < 0 at E={e}: {:.4}", xs.total);
731 assert!(
732 xs.elastic >= 0.0,
733 "MLBW elastic < 0 at E={e}: {:.4}",
734 xs.elastic
735 );
736 }
737 }
738
739 /// Total = elastic + capture + fission for MLBW.
740 /// Guard: catches optical theorem misuse (U not unitary for MLBW).
741 #[test]
742 fn test_mlbw_total_equals_components() {
743 use crate::reich_moore::cross_sections_at_energy;
744 let data = nereids_endf::resonance::test_support::hf178_mlbw_two_resonances();
745 for &e in &[1.0, 5.0, 7.8, 10.0, 50.0] {
746 let xs = cross_sections_at_energy(&data, e);
747 let sum = xs.elastic + xs.capture + xs.fission;
748 assert!(
749 (xs.total - sum).abs() < 1e-10,
750 "MLBW total ({:.6}) != el+cap+fis ({:.6}) at E={e}",
751 xs.total,
752 sum
753 );
754 }
755 }
756
757 // ─── Defensive input validation at the public boundary ──────────────────
758 //
759 // `slbw_cross_sections_for_range` is `pub`, so it can be called directly
760 // from any crate without going through the Python wrapper's
761 // `validate_energy_grid`. The entry assertion turns malformed energies
762 // (zero, negative, NaN, infinity) into a loud panic at the public
763 // boundary, rather than letting them propagate into `pi_over_k_squared`
764 // (whose `debug_assert!` is invisible in release builds). See issue #558.
765
766 #[test]
767 #[should_panic(expected = "expected positive finite energy_ev")]
768 fn slbw_for_range_panics_on_zero_energy() {
769 let range = nereids_endf::resonance::test_support::minimal_slbw_range();
770 let _ = slbw_cross_sections_for_range(&range, 0.0, 236.006, 0.0);
771 }
772
773 #[test]
774 #[should_panic(expected = "expected positive finite energy_ev")]
775 fn slbw_for_range_panics_on_negative_energy() {
776 let range = nereids_endf::resonance::test_support::minimal_slbw_range();
777 let _ = slbw_cross_sections_for_range(&range, -1.0, 236.006, 0.0);
778 }
779
780 #[test]
781 #[should_panic(expected = "expected positive finite energy_ev")]
782 fn slbw_for_range_panics_on_nan_energy() {
783 let range = nereids_endf::resonance::test_support::minimal_slbw_range();
784 let _ = slbw_cross_sections_for_range(&range, f64::NAN, 236.006, 0.0);
785 }
786
787 #[test]
788 #[should_panic(expected = "expected positive finite energy_ev")]
789 fn slbw_for_range_panics_on_infinite_energy() {
790 let range = nereids_endf::resonance::test_support::minimal_slbw_range();
791 let _ = slbw_cross_sections_for_range(&range, f64::INFINITY, 236.006, 0.0);
792 }
793}