Skip to main content

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