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_cross_sections_for_range`, which includes resonance-resonance
14//! interference in the elastic channel (see SAMMY `mlb/mmlb3.f90`).
15//!
16//! ## SAMMY Reference
17//! - `mlb/mmlb4.f90` Abpart_Mlb subroutine
18//! - SAMMY manual Section 2.1.1
19//!
20//! ## Formulas
21//! For each resonance at energy E_r with total J:
22//!
23//! σ_capture(E) = g_J × π/k² × Γ_n(E)·Γ_γ / ((E-E_r)² + (Γ/2)²)
24//!
25//! σ_elastic(E) = g_J × π/k² × [Γ_n(E)² / ((E-E_r)² + (Γ/2)²)
26//!                + 2·sin(φ)·(Γ_n(E)·(E-E_r)·cos(φ) + Γ_n(E)·Γ/2·sin(φ))
27//!                  / ((E-E_r)² + (Γ/2)²)
28//!                + sin²(φ)]
29//!
30//! where Γ_n(E) = Γ_n(E_r) × √(E/E_r) × P_l(E)/P_l(E_r)
31//! and Γ = Γ_n(E) + Γ_γ + Γ_f
32
33use nereids_core::constants::{DIVISION_FLOOR, PIVOT_FLOOR};
34use nereids_endf::resonance::ResonanceData;
35
36use crate::channel;
37use crate::penetrability;
38use crate::reich_moore::{CrossSections, PrecomputedJGroup, group_resonances_by_j};
39
40// ─── Per-resonance precomputed invariants for SLBW ────────────────────────────
41//
42// In SLBW, the energy-dependent neutron width is:
43//   Γ_n(E) = Γ_n(E_r) × √(E/E_r) × P_l(E)/P_l(E_r)
44//
45// The quantities that depend only on resonance parameters (not on E):
46//   - P_l(E_r): penetrability at resonance energy
47//   - |Γ_n(E_r)|: neutron width magnitude
48//   - Γ_γ, Γ_f: capture and fission widths
49//   - E_r: resonance energy
50//
51// Issue #87: Pre-compute P_l(E_r) and J-groups once before the energy loop.
52
53/// Per-resonance invariants for SLBW, pre-computed once per resonance.
54pub(crate) struct PrecomputedSlbwResonance {
55    /// Resonance energy E_r (eV).
56    pub(crate) energy: f64,
57    /// Capture width Γ_γ (eV).
58    pub(crate) gamma_g: f64,
59    /// Fission width |Γ_fa| + |Γ_fb| (eV).
60    pub(crate) gamma_f: f64,
61    /// Absolute neutron width |Γ_n(E_r)| (eV).
62    pub(crate) gn_abs: f64,
63    /// Penetrability at resonance energy P_l(ρ_r).
64    /// Pre-computed to avoid redundant `penetrability(l, rho_r)` calls.
65    pub(crate) p_at_er: f64,
66}
67
68/// Pre-computed J-group for SLBW (type alias for the generic J-group).
69pub(crate) type PrecomputedSlbwJGroup = PrecomputedJGroup<PrecomputedSlbwResonance>;
70
71/// Build pre-computed J-groups for SLBW.
72///
73/// All quantities depend only on resonance parameters (not incident energy),
74/// so the result can be computed once and reused across all energy points.
75///
76/// Uses the shared `group_resonances_by_j` helper (Issue #158) with an
77/// SLBW-specific closure for per-resonance invariant construction.
78pub(crate) fn precompute_slbw_jgroups(
79    resonances: &[nereids_endf::resonance::Resonance],
80    l: u32,
81    awr_l: f64,
82    range: &nereids_endf::resonance::ResonanceRange,
83    l_group: &nereids_endf::resonance::LGroup,
84    target_spin: f64,
85) -> Vec<PrecomputedSlbwJGroup> {
86    group_resonances_by_j(resonances, target_spin, |res| {
87        let e_r = res.energy;
88
89        // Pre-compute P_l(E_r) — this is the redundant computation Issue #87 eliminates.
90        let p_at_er = if e_r.abs() > PIVOT_FLOOR {
91            let radius_at_er = if l_group.apl > 0.0 {
92                l_group.apl
93            } else if range.naps == 0 {
94                // NAPS=0: use channel radius per ENDF-6 §2.2.1
95                channel::endf_channel_radius_fm(awr_l)
96            } else {
97                // NAPS=1 (default): use scattering radius AP or AP(E)
98                range.scattering_radius_at(e_r.abs())
99            };
100            let rho_r = channel::rho(e_r.abs(), awr_l, radius_at_er);
101            penetrability::penetrability(l, rho_r)
102        } else {
103            0.0 // Will produce gamma_n = 0 in the energy loop
104        };
105
106        PrecomputedSlbwResonance {
107            energy: e_r,
108            gamma_g: res.gg,
109            gamma_f: res.gfa.abs() + res.gfb.abs(),
110            gn_abs: res.gn.abs(),
111            p_at_er,
112        }
113    })
114}
115
116/// Compute SLBW cross-sections at a single energy.
117///
118/// Works with both SLBW and Reich-Moore ENDF data (uses the same
119/// resonance parameters but applies the SLBW formulas).
120pub fn slbw_cross_sections(data: &ResonanceData, energy_ev: f64) -> CrossSections {
121    let awr = data.awr;
122
123    let mut total = 0.0;
124    let mut elastic = 0.0;
125    let mut capture = 0.0;
126    let mut fission = 0.0;
127
128    // Use simple closed-interval logic: energy_ev must be in [e_low, e_high].
129    //
130    // This function evaluates *only* resolved SLBW/MLBW ranges and skips
131    // everything else (including URR ranges where !range.resolved).  Using
132    // half-open [e_low, e_high) when the next range is a URR range would
133    // exclude the shared boundary energy from the resolved range while still
134    // skipping the URR range, producing zero XS at the boundary — an
135    // artificial dip.  Closed intervals prevent that gap.  A double-count at
136    // a shared boundary between two resolved ranges is a negligibly small
137    // effect compared to the gap that half-open logic would introduce.
138    for range in &data.ranges {
139        if !range.resolved || energy_ev < range.energy_low || energy_ev > range.energy_high {
140            continue;
141        }
142
143        // Each range carries its own target_spin — pass per-range, not
144        // from the first range, to correctly compute statistical weights g_J.
145        let (t, e, c, f) = slbw_cross_sections_for_range(range, energy_ev, awr, range.target_spin);
146        total += t;
147        elastic += e;
148        capture += c;
149        fission += f;
150    }
151
152    CrossSections {
153        total,
154        elastic,
155        capture,
156        fission,
157    }
158}
159
160/// Compute SLBW cross-sections for a single resolved resonance range.
161///
162/// This is the per-range workhorse called by both `slbw_cross_sections`
163/// (which iterates over all ranges) and the unified dispatcher in
164/// `reich_moore::cross_sections_for_range`.
165///
166/// Returns `(total, elastic, capture, fission)` in barns.
167pub fn slbw_cross_sections_for_range(
168    range: &nereids_endf::resonance::ResonanceRange,
169    energy_ev: f64,
170    awr: f64,
171    target_spin: f64,
172) -> (f64, f64, f64, f64) {
173    let pi_over_k2 = channel::pi_over_k_squared_barns(energy_ev, awr);
174
175    let mut total = 0.0;
176    let mut elastic = 0.0;
177    let mut capture = 0.0;
178    let mut fission = 0.0;
179
180    for l_group in &range.l_groups {
181        let l = l_group.l;
182        let awr_l = if l_group.awr > 0.0 { l_group.awr } else { awr };
183
184        // Scattering radius for phase shift (always AP/APL).
185        let scatt_radius = if l_group.apl > 0.0 {
186            l_group.apl
187        } else {
188            range.scattering_radius_at(energy_ev)
189        };
190        // Penetrability radius: NAPS=0 uses channel radius formula,
191        // NAPS=1 uses scattering radius (ENDF-6 §2.2.1).
192        let pen_radius = if l_group.apl > 0.0 {
193            l_group.apl
194        } else if range.naps == 0 {
195            channel::endf_channel_radius_fm(awr_l)
196        } else {
197            scatt_radius
198        };
199
200        let rho_phase = channel::rho(energy_ev, awr_l, scatt_radius);
201        let rho_pen = channel::rho(energy_ev, awr_l, pen_radius);
202        let phi = penetrability::phase_shift(l, rho_phase);
203        let sin_phi = phi.sin();
204        let cos_phi = phi.cos();
205        let sin2_phi = sin_phi * sin_phi;
206        let p_at_e = penetrability::penetrability(l, rho_pen);
207
208        // Build J-groups and per-resonance invariants.
209        // Note: in the per-point path (slbw_cross_sections_for_range), this
210        // is rebuilt each call. The batch grid path (cross_sections_on_grid)
211        // hoists this above the energy loop via precompute_range_data.
212        let j_groups =
213            precompute_slbw_jgroups(&l_group.resonances, l, awr_l, range, l_group, target_spin);
214
215        for jg in &j_groups {
216            let g_j = jg.g_j;
217
218            // Potential scattering for this (l, J) group.
219            // Added once per spin group.
220            let pot_scatter = pi_over_k2 * g_j * 4.0 * sin2_phi;
221            elastic += pot_scatter;
222            total += pot_scatter;
223
224            for res in &jg.resonances {
225                let e_r = res.energy;
226
227                // Energy-dependent neutron width:
228                // Γ_n(E) = Γ_n(E_r) × √(E/E_r) × P_l(E)/P_l(E_r)
229                // P_l(E_r) is pre-computed in res.p_at_er (Issue #87).
230                let gamma_n = if e_r.abs() > PIVOT_FLOOR && res.p_at_er > PIVOT_FLOOR {
231                    res.gn_abs * (energy_ev / e_r.abs()).sqrt() * p_at_e / res.p_at_er
232                } else {
233                    0.0
234                };
235
236                let gamma_total = gamma_n + res.gamma_g + res.gamma_f;
237                let de = energy_ev - e_r;
238                let denom = de * de + (gamma_total / 2.0).powi(2);
239
240                if denom < DIVISION_FLOOR {
241                    continue;
242                }
243
244                // Capture cross-section (symmetric Breit-Wigner).
245                let sigma_c = pi_over_k2 * g_j * gamma_n * res.gamma_g / denom;
246                capture += sigma_c;
247                total += sigma_c;
248
249                // Fission cross-section.
250                let sigma_f = pi_over_k2 * g_j * gamma_n * res.gamma_f / denom;
251                fission += sigma_f;
252                total += sigma_f;
253
254                // Elastic resonance scattering (interference term + resonance peak).
255                // σ_el_res = g × π/k² × [Γ_n² / denom]
256                let sigma_e_res = pi_over_k2 * g_j * gamma_n * gamma_n / denom;
257                elastic += sigma_e_res;
258                total += sigma_e_res;
259
260                // Interference between resonance and potential scattering.
261                let interf = pi_over_k2
262                    * g_j
263                    * 2.0
264                    * gamma_n
265                    * (de * cos_phi * 2.0 * sin_phi + (gamma_total / 2.0) * 2.0 * sin2_phi)
266                    / denom;
267                elastic += interf;
268                total += interf;
269            }
270        }
271    }
272
273    (total, elastic, capture, fission)
274}
275
276/// Evaluate SLBW cross-sections for a single L-group using pre-cached J-groups.
277///
278/// This is the per-energy inner loop extracted from `slbw_cross_sections_for_range`,
279/// used by the batch grid path (`cross_sections_on_grid`) to avoid redundant
280/// J-group precomputation at every energy point.
281///
282/// # Arguments
283/// * `jgroups` — Pre-computed J-groups (energy-independent invariants).
284/// * `energy_ev` — Incident neutron energy (eV).
285/// * `pi_over_k2` — π/k² in barns at this energy.
286/// * `p_at_e` — Penetrability P_l(ρ) at incident energy.
287/// * `sin_phi` — sin(φ_l) at incident energy.
288/// * `cos_phi` — cos(φ_l) at incident energy.
289/// * `sin2_phi` — sin²(φ_l) at incident energy.
290///
291/// # Returns
292/// `(total, elastic, capture, fission)` in barns for this L-group.
293#[allow(clippy::too_many_arguments)]
294pub(crate) fn slbw_evaluate_with_cached_jgroups(
295    jgroups: &[PrecomputedSlbwJGroup],
296    energy_ev: f64,
297    pi_over_k2: f64,
298    p_at_e: f64,
299    sin_phi: f64,
300    cos_phi: f64,
301    sin2_phi: f64,
302) -> (f64, f64, f64, f64) {
303    let mut total = 0.0;
304    let mut elastic = 0.0;
305    let mut capture = 0.0;
306    let mut fission = 0.0;
307
308    for jg in jgroups {
309        let g_j = jg.g_j;
310
311        // Potential scattering for this (l, J) group.
312        let pot_scatter = pi_over_k2 * g_j * 4.0 * sin2_phi;
313        elastic += pot_scatter;
314        total += pot_scatter;
315
316        for res in &jg.resonances {
317            let e_r = res.energy;
318
319            // Energy-dependent neutron width:
320            // Γ_n(E) = Γ_n(E_r) × √(E/E_r) × P_l(E)/P_l(E_r)
321            // P_l(E_r) is pre-computed in res.p_at_er (Issue #87).
322            let gamma_n = if e_r.abs() > PIVOT_FLOOR && res.p_at_er > PIVOT_FLOOR {
323                res.gn_abs * (energy_ev / e_r.abs()).sqrt() * p_at_e / res.p_at_er
324            } else {
325                0.0
326            };
327
328            let gamma_total = gamma_n + res.gamma_g + res.gamma_f;
329            let de = energy_ev - e_r;
330            let denom = de * de + (gamma_total / 2.0).powi(2);
331
332            if denom < DIVISION_FLOOR {
333                continue;
334            }
335
336            // Capture cross-section (symmetric Breit-Wigner).
337            let sigma_c = pi_over_k2 * g_j * gamma_n * res.gamma_g / denom;
338            capture += sigma_c;
339            total += sigma_c;
340
341            // Fission cross-section.
342            let sigma_f = pi_over_k2 * g_j * gamma_n * res.gamma_f / denom;
343            fission += sigma_f;
344            total += sigma_f;
345
346            // Elastic resonance scattering (interference term + resonance peak).
347            let sigma_e_res = pi_over_k2 * g_j * gamma_n * gamma_n / denom;
348            elastic += sigma_e_res;
349            total += sigma_e_res;
350
351            // Interference between resonance and potential scattering.
352            let interf = pi_over_k2
353                * g_j
354                * 2.0
355                * gamma_n
356                * (de * cos_phi * 2.0 * sin_phi + (gamma_total / 2.0) * 2.0 * sin2_phi)
357                / denom;
358            elastic += interf;
359            total += interf;
360        }
361    }
362
363    (total, elastic, capture, fission)
364}
365
366/// Compute **true MLBW** cross-sections for a single resolved resonance range.
367///
368/// MLBW differs from SLBW only in the **elastic** cross-section:
369/// it includes resonance-resonance interference (coherent sum over resonances
370/// in the collision matrix, instead of SLBW's incoherent sum).
371///
372/// Capture and fission are identical to SLBW (incoherent per-resonance sums).
373///
374/// ## SAMMY Reference
375/// - `mlb/mmlb4.f90` Abpart_Mlb
376/// - `mlb/mmlb3.f90` Elastc_Mlb
377///
378/// ## Physics
379/// The collision matrix element for a single neutron channel in MLBW is:
380///
381///   U_nn = e^{-2iφ} · [1 + i · Σ_r Γ_n^r / (E_r - E - iΓ_tot^r/2)]
382///
383/// where the sum is the coherent sum over all resonances in the spin group.
384///
385/// Then:
386///   σ_elastic = (π/k²) · g_J · |1 - U_nn|²
387///   σ_total   = (2π/k²) · g_J · (1 - Re(U_nn))
388///
389/// For SLBW, |1-U|² is computed per-resonance and summed (incoherent).
390/// For MLBW, U is the coherent sum, then |1-U|² is computed once.
391pub fn mlbw_cross_sections_for_range(
392    range: &nereids_endf::resonance::ResonanceRange,
393    energy_ev: f64,
394    awr: f64,
395    target_spin: f64,
396) -> (f64, f64, f64, f64) {
397    use num_complex::Complex64;
398
399    let pi_over_k2 = channel::pi_over_k_squared_barns(energy_ev, awr);
400
401    let mut elastic = 0.0;
402    let mut capture = 0.0;
403    let mut fission = 0.0;
404
405    for l_group in &range.l_groups {
406        let l = l_group.l;
407        let awr_l = if l_group.awr > 0.0 { l_group.awr } else { awr };
408
409        let scatt_radius = if l_group.apl > 0.0 {
410            l_group.apl
411        } else {
412            range.scattering_radius_at(energy_ev)
413        };
414        let pen_radius = if l_group.apl > 0.0 {
415            l_group.apl
416        } else if range.naps == 0 {
417            channel::endf_channel_radius_fm(awr_l)
418        } else {
419            scatt_radius
420        };
421
422        let rho_phase = channel::rho(energy_ev, awr_l, scatt_radius);
423        let rho_pen = channel::rho(energy_ev, awr_l, pen_radius);
424        let phi = penetrability::phase_shift(l, rho_phase);
425        let p_at_e = penetrability::penetrability(l, rho_pen);
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        //
435        // Also confirmed by SAMMY rml/mrml11.f lines 84-88: the elastic
436        // formula sin²(φ)·(1-2Xi) - sin(2φ)·Xr + Xr²+Xi² is consistent
437        // ONLY with e^{-2iφ}.
438        let phase2 = Complex64::new((2.0 * phi).cos(), -(2.0 * phi).sin());
439
440        let j_groups =
441            precompute_slbw_jgroups(&l_group.resonances, l, awr_l, range, l_group, target_spin);
442
443        for jg in &j_groups {
444            let g_j = jg.g_j;
445
446            // Coherent sum over resonances: X = Σ_r Γ_n^r / (E_r - E - iΓ_tot^r/2)
447            let mut x_sum = Complex64::new(0.0, 0.0);
448
449            for res in &jg.resonances {
450                let e_r = res.energy;
451                let gamma_n = if e_r.abs() > PIVOT_FLOOR && res.p_at_er > PIVOT_FLOOR {
452                    res.gn_abs * (energy_ev / e_r.abs()).sqrt() * 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            // SAMMY convention (mmlb3.f90 Elastc_Mlb).
486            // Note: 1 + i·X = (1 - X_im) + i·X_re
487            let one_plus_ix = Complex64::new(1.0 - x_sum.im, x_sum.re);
488            let u_nn = phase2 * one_plus_ix;
489
490            // σ_elastic = (π/k²) · g_J · |1 - U_nn|²
491            let one_minus_u = Complex64::new(1.0 - u_nn.re, -u_nn.im);
492            let sigma_el = pi_over_k2 * g_j * one_minus_u.norm_sqr();
493            elastic += sigma_el;
494
495            // Total = elastic + capture + fission (NOT optical theorem).
496            // The optical theorem σ_total = 2π/k² · g · (1 - Re(U)) is only
497            // valid for a UNITARY S-matrix. In MLBW, capture and fission
498            // remove flux from the elastic channel, so |U| < 1 and the
499            // optical theorem overestimates absorption. Computing total as
500            // the sum of components is always correct.
501            // (Capture and fission already accumulated per-resonance above.)
502        }
503    }
504
505    // Total = sum of all components (elastic was accumulated per J-group,
506    // capture and fission per resonance).
507    let total = elastic + capture + fission;
508
509    (total, elastic, capture, fission)
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515    use nereids_endf::resonance::{LGroup, Resonance, ResonanceFormalism, ResonanceRange};
516
517    #[test]
518    fn test_slbw_capture_peak() {
519        // Single resonance at 6.674 eV (U-238).
520        // SLBW and RM should agree well for an isolated resonance.
521        let data = ResonanceData {
522            isotope: nereids_core::types::Isotope::new(92, 238).unwrap(),
523            za: 92238,
524            awr: 236.006,
525            ranges: vec![ResonanceRange {
526                energy_low: 1e-5,
527                energy_high: 1e4,
528                resolved: true,
529                formalism: ResonanceFormalism::SLBW,
530                target_spin: 0.0,
531                scattering_radius: 9.4285,
532                naps: 1,
533                l_groups: vec![LGroup {
534                    l: 0,
535                    awr: 236.006,
536                    apl: 0.0,
537                    qx: 0.0,
538                    lrx: 0,
539                    resonances: vec![Resonance {
540                        energy: 6.674,
541                        j: 0.5,
542                        gn: 1.493e-3,
543                        gg: 23.0e-3,
544                        gfa: 0.0,
545                        gfb: 0.0,
546                    }],
547                }],
548                rml: None,
549                urr: None,
550                ap_table: None,
551                r_external: vec![],
552            }],
553        };
554
555        let xs = slbw_cross_sections(&data, 6.674);
556        println!("SLBW capture at 6.674 eV: {} barns", xs.capture);
557
558        // Same estimate as RM: ~22,000 barns.
559        assert!(
560            xs.capture > 15000.0 && xs.capture < 30000.0,
561            "Capture should be ~22000 barns, got {}",
562            xs.capture
563        );
564    }
565
566    #[test]
567    fn test_slbw_vs_rm_single_resonance() {
568        // For a single isolated resonance, SLBW and RM should be very close.
569        use crate::reich_moore;
570
571        let resonances = vec![LGroup {
572            l: 0,
573            awr: 236.006,
574            apl: 0.0,
575            qx: 0.0,
576            lrx: 0,
577            resonances: vec![Resonance {
578                energy: 6.674,
579                j: 0.5,
580                gn: 1.493e-3,
581                gg: 23.0e-3,
582                gfa: 0.0,
583                gfb: 0.0,
584            }],
585        }];
586
587        let rm_data = ResonanceData {
588            isotope: nereids_core::types::Isotope::new(92, 238).unwrap(),
589            za: 92238,
590            awr: 236.006,
591            ranges: vec![ResonanceRange {
592                energy_low: 1e-5,
593                energy_high: 1e4,
594                resolved: true,
595                formalism: ResonanceFormalism::ReichMoore,
596                target_spin: 0.0,
597                scattering_radius: 9.4285,
598                naps: 1,
599                l_groups: resonances.clone(),
600                rml: None,
601                urr: None,
602                ap_table: None,
603                r_external: vec![],
604            }],
605        };
606
607        let slbw_data = ResonanceData {
608            isotope: nereids_core::types::Isotope::new(92, 238).unwrap(),
609            za: 92238,
610            awr: 236.006,
611            ranges: vec![ResonanceRange {
612                energy_low: 1e-5,
613                energy_high: 1e4,
614                resolved: true,
615                formalism: ResonanceFormalism::SLBW,
616                target_spin: 0.0,
617                scattering_radius: 9.4285,
618                naps: 1,
619                l_groups: resonances,
620                rml: None,
621                urr: None,
622                ap_table: None,
623                r_external: vec![],
624            }],
625        };
626
627        // Compare at several energies near the resonance peak.
628        // Note: RM and SLBW differ in how they handle energy-dependent
629        // neutron widths away from the peak. SLBW includes an extra √(E/E_r)
630        // velocity factor in Γ_n(E), leading to ~10-15% differences in the
631        // resonance wings. At the peak and very near it, they agree well.
632        for &e in &[6.5, 6.674, 7.0] {
633            let rm = reich_moore::cross_sections_at_energy(&rm_data, e);
634            let slbw = slbw_cross_sections(&slbw_data, e);
635
636            let rel_diff_cap =
637                (rm.capture - slbw.capture).abs() / rm.capture.max(slbw.capture).max(1e-10);
638
639            println!(
640                "E={:.3}: RM_cap={:.2}, SLBW_cap={:.2}, rel_diff={:.4}",
641                e, rm.capture, slbw.capture, rel_diff_cap
642            );
643
644            // Near the peak, the formalisms should agree within ~5%.
645            assert!(
646                rel_diff_cap < 0.05,
647                "RM vs SLBW capture differ by {:.1}% at E={} eV",
648                rel_diff_cap * 100.0,
649                e
650            );
651        }
652    }
653
654    /// Verify the SLBW NAPS=0 code path uses the channel radius formula.
655    ///
656    /// Same structure as the RM `test_naps_zero_uses_channel_radius_formula`:
657    /// 1. NAPS=0 with AP = formula_radius matches NAPS=1 with AP = formula_radius
658    ///    (confirming the formula gives the expected value)
659    /// 2. NAPS=0 with a different AP still produces valid XS (no NaN/panic)
660    #[test]
661    fn test_slbw_naps_zero_uses_channel_radius_formula() {
662        let awr: f64 = 55.345; // Fe-56-like
663        let formula_radius = channel::endf_channel_radius_fm(awr);
664
665        // NAPS=0: penetrability uses formula, phase shift uses AP (= formula here)
666        let data_naps0 = ResonanceData {
667            isotope: nereids_core::types::Isotope::new(26, 56).unwrap(),
668            za: 26056,
669            awr,
670            ranges: vec![ResonanceRange {
671                energy_low: 1e-5,
672                energy_high: 1e5,
673                resolved: true,
674                formalism: ResonanceFormalism::SLBW,
675                target_spin: 0.0,
676                scattering_radius: formula_radius, // AP = formula → same as pen_radius
677                naps: 0,
678                l_groups: vec![LGroup {
679                    l: 1,
680                    awr,
681                    apl: 0.0,
682                    qx: 0.0,
683                    lrx: 0,
684                    resonances: vec![Resonance {
685                        energy: 30000.0,
686                        j: 1.5,
687                        gn: 5.0,
688                        gg: 1.0,
689                        gfa: 0.0,
690                        gfb: 0.0,
691                    }],
692                }],
693                rml: None,
694                urr: None,
695                ap_table: None,
696                r_external: vec![],
697            }],
698        };
699
700        // NAPS=1 with AP = formula_radius: both penetrability and phase use AP
701        let data_naps1 = ResonanceData {
702            isotope: nereids_core::types::Isotope::new(26, 56).unwrap(),
703            za: 26056,
704            awr,
705            ranges: vec![ResonanceRange {
706                energy_low: 1e-5,
707                energy_high: 1e5,
708                resolved: true,
709                formalism: ResonanceFormalism::SLBW,
710                target_spin: 0.0,
711                scattering_radius: formula_radius,
712                naps: 1,
713                l_groups: vec![LGroup {
714                    l: 1,
715                    awr,
716                    apl: 0.0,
717                    qx: 0.0,
718                    lrx: 0,
719                    resonances: vec![Resonance {
720                        energy: 30000.0,
721                        j: 1.5,
722                        gn: 5.0,
723                        gg: 1.0,
724                        gfa: 0.0,
725                        gfb: 0.0,
726                    }],
727                }],
728                rml: None,
729                urr: None,
730                ap_table: None,
731                r_external: vec![],
732            }],
733        };
734
735        let e = 30000.0;
736        let xs_naps0 = slbw_cross_sections(&data_naps0, e);
737        let xs_naps1 = slbw_cross_sections(&data_naps1, e);
738
739        // When AP equals the formula radius, NAPS=0 and NAPS=1 should
740        // give identical results (both use the same radius everywhere).
741        assert!(
742            (xs_naps0.total - xs_naps1.total).abs() < 1e-10 * xs_naps1.total.abs().max(1.0),
743            "NAPS=0 total={} vs NAPS=1 total={}: should match when AP=formula",
744            xs_naps0.total,
745            xs_naps1.total,
746        );
747        assert!(
748            (xs_naps0.capture - xs_naps1.capture).abs() < 1e-10 * xs_naps1.capture.abs().max(1.0),
749            "NAPS=0 capture={} vs NAPS=1 capture={}: should match when AP=formula",
750            xs_naps0.capture,
751            xs_naps1.capture,
752        );
753
754        // Verify finite and positive cross-sections (no NaN from formula)
755        assert!(xs_naps0.total.is_finite() && xs_naps0.total > 0.0);
756        assert!(xs_naps0.capture.is_finite() && xs_naps0.capture > 0.0);
757
758        // Also verify NAPS=0 with a different AP still produces valid XS
759        let data_naps0_diff_ap = ResonanceData {
760            isotope: nereids_core::types::Isotope::new(26, 56).unwrap(),
761            za: 26056,
762            awr,
763            ranges: vec![ResonanceRange {
764                energy_low: 1e-5,
765                energy_high: 1e5,
766                resolved: true,
767                formalism: ResonanceFormalism::SLBW,
768                target_spin: 0.0,
769                scattering_radius: 9.0, // Different from formula
770                naps: 0,
771                l_groups: vec![LGroup {
772                    l: 1,
773                    awr,
774                    apl: 0.0,
775                    qx: 0.0,
776                    lrx: 0,
777                    resonances: vec![Resonance {
778                        energy: 30000.0,
779                        j: 1.5,
780                        gn: 5.0,
781                        gg: 1.0,
782                        gfa: 0.0,
783                        gfb: 0.0,
784                    }],
785                }],
786                rml: None,
787                urr: None,
788                ap_table: None,
789                r_external: vec![],
790            }],
791        };
792        let xs_diff = slbw_cross_sections(&data_naps0_diff_ap, e);
793        assert!(
794            xs_diff.total.is_finite() && xs_diff.total > 0.0,
795            "NAPS=0 with different AP should still produce valid XS"
796        );
797    }
798
799    fn make_mlbw_multi_resonance_data() -> nereids_endf::resonance::ResonanceData {
800        use nereids_endf::resonance::*;
801        ResonanceData {
802            isotope: nereids_core::types::Isotope::new(72, 178).unwrap(),
803            za: 72178,
804            awr: 177.94,
805            ranges: vec![ResonanceRange {
806                energy_low: 0.0,
807                energy_high: 100.0,
808                formalism: ResonanceFormalism::MLBW,
809                naps: 0,
810                resolved: true,
811                scattering_radius: 9.48,
812                target_spin: 0.0,
813                l_groups: vec![LGroup {
814                    l: 0,
815                    awr: 177.94,
816                    apl: 0.0,
817                    qx: 0.0,
818                    lrx: 0,
819                    resonances: vec![
820                        Resonance {
821                            energy: 7.8,
822                            j: 0.5,
823                            gn: 0.002,
824                            gg: 0.060,
825                            gfa: 0.0,
826                            gfb: 0.0,
827                        },
828                        Resonance {
829                            energy: 16.9,
830                            j: 0.5,
831                            gn: 0.004,
832                            gg: 0.055,
833                            gfa: 0.0,
834                            gfb: 0.0,
835                        },
836                    ],
837                }],
838                rml: None,
839                ap_table: None,
840                urr: None,
841                r_external: vec![],
842            }],
843        }
844    }
845
846    /// MLBW cross-sections must be non-negative for multi-resonance data.
847    /// Guard: catches e^{+2iφ} phase convention bug (commit 5508fea, fixed f0eadc1).
848    #[test]
849    fn test_mlbw_multi_resonance_positive() {
850        use crate::reich_moore::cross_sections_at_energy;
851        let data = make_mlbw_multi_resonance_data();
852        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] {
853            let xs = cross_sections_at_energy(&data, e);
854            assert!(xs.total >= 0.0, "MLBW total < 0 at E={e}: {:.4}", xs.total);
855            assert!(
856                xs.elastic >= 0.0,
857                "MLBW elastic < 0 at E={e}: {:.4}",
858                xs.elastic
859            );
860        }
861    }
862
863    /// Total = elastic + capture + fission for MLBW.
864    /// Guard: catches optical theorem misuse (U not unitary for MLBW).
865    #[test]
866    fn test_mlbw_total_equals_components() {
867        use crate::reich_moore::cross_sections_at_energy;
868        let data = make_mlbw_multi_resonance_data();
869        for &e in &[1.0, 5.0, 7.8, 10.0, 50.0] {
870            let xs = cross_sections_at_energy(&data, e);
871            let sum = xs.elastic + xs.capture + xs.fission;
872            assert!(
873                (xs.total - sum).abs() < 1e-10,
874                "MLBW total ({:.6}) != el+cap+fis ({:.6}) at E={e}",
875                xs.total,
876                sum
877            );
878        }
879    }
880}