nereids_physics/
reich_moore.rs

1//! Multi-formalism cross-section dispatcher.
2//!
3//! `cross_sections_at_energy` is the primary entry point for computing
4//! energy-dependent cross-sections from ENDF resonance data.  It
5//! iterates over all resonance ranges in the data and dispatches each to
6//! the appropriate formalism-specific calculator:
7//!
8//! | ENDF LRF | Formalism                  | Implemented as              |
9//! |----------|----------------------------|-----------------------------|
10//! | 1        | SLBW                       | `slbw::slbw_cross_sections_for_range` |
11//! | 2        | MLBW                       | `slbw::mlbw_cross_sections_for_range` (true MLBW with resonance interference) |
12//! | 3        | Reich-Moore                | `reich_moore_spin_group` (this module) |
13//! | 7        | R-Matrix Limited           | `rmatrix_limited::cross_sections_for_rml_range` |
14//! | URR      | Hauser-Feshbach average    | `urr::urr_cross_sections` |
15//!
16//! ## Reich-Moore Approximation
17//! In the full R-matrix, all channels (neutron, capture, fission) appear
18//! explicitly. The Reich-Moore approximation *eliminates the capture channel*
19//! from the channel space, absorbing its effect into an imaginary part of
20//! the energy denominator. This makes the level matrix smaller while
21//! remaining highly accurate.
22//!
23//! For non-fissile isotopes (like U-238 below threshold), each spin group
24//! has only ONE explicit channel (neutron elastic), making the R-matrix
25//! a scalar — and the calculation is very efficient.
26//!
27//! ## SAMMY Reference
28//! - `rml/mrml07.f` Setr: R-matrix construction
29//! - `rml/mrml09.f` Yinvrs: level matrix inversion
30//! - `rml/mrml11.f` Setxqx: X-matrix, Sectio: cross-sections
31//! - `rml/mrml03.f` Betset: ENDF widths → reduced width amplitudes
32//! - SAMMY manual Section 2.1 (R-matrix theory)
33
34use num_complex::Complex64;
35
36use nereids_core::constants::{DIVISION_FLOOR, LOG_FLOOR, PIVOT_FLOOR, QUANTUM_NUMBER_EPS};
37use nereids_endf::resonance::{ResonanceData, ResonanceFormalism, ResonanceRange, Tab1};
38
39use crate::channel;
40use crate::penetrability;
41use crate::rmatrix_limited;
42use crate::slbw;
43use crate::urr;
44
45// ─── Per-resonance precomputed invariants ─────────────────────────────────────
46//
47// These quantities depend only on the resonance parameters and the channel
48// radius at the resonance energy — both are energy-independent constants.
49// Pre-computing them once (outside the energy loop) eliminates redundant
50// `penetrability(l, rho_r)` and `group_by_j()` calls per energy point.
51//
52// Issue #87: "Perf: Pre-cache J-groups and per-resonance quantities"
53
54/// Per-resonance invariants for the single-channel (non-fissile) Reich-Moore path.
55///
56/// Pre-computed once per resonance before the energy sweep.
57/// Reference: SAMMY `rml/mrml03.f` Betset (lines 240-276)
58struct PrecomputedResonanceSingle {
59    /// Resonance energy E_r (eV).
60    energy: f64,
61    /// Capture width Γ_γ (eV).
62    gamma_g: f64,
63    /// Reduced width amplitude squared γ²_n = |Γ_n| / (2·P_l(E_r)).
64    gamma_n_reduced_sq: f64,
65}
66
67/// Per-resonance invariants for the 2-channel (one fission) Reich-Moore path.
68struct PrecomputedResonance2ch {
69    /// Resonance energy E_r (eV).
70    energy: f64,
71    /// Capture width Γ_γ (eV).
72    gamma_g: f64,
73    /// Reduced width amplitude β_n = sign(Γ_n) × √(|Γ_n| / (2·P_l(E_r))).
74    beta_n: f64,
75    /// Fission width amplitude β_f = sign(Γ_f) × √(|Γ_f| / 2).
76    beta_f: f64,
77}
78
79/// Per-resonance invariants for the 3-channel (two fission) Reich-Moore path.
80struct PrecomputedResonance3ch {
81    /// Resonance energy E_r (eV).
82    energy: f64,
83    /// Capture width Γ_γ (eV).
84    gamma_g: f64,
85    /// Reduced width amplitude β_n.
86    beta_n: f64,
87    /// Fission width amplitude β_fa.
88    beta_fa: f64,
89    /// Fission width amplitude β_fb.
90    beta_fb: f64,
91}
92
93/// Pre-computed J-group, generic over the per-resonance invariant type.
94///
95/// Groups resonances by total angular momentum J, with per-resonance
96/// invariants already computed. The J-grouping depends only on the
97/// resonance data, not on the incident energy, so it is computed once
98/// and reused for every energy point.
99///
100/// Used by both Reich-Moore (single/2ch/3ch) and SLBW precompute paths.
101pub(crate) struct PrecomputedJGroup<R> {
102    /// Total angular momentum J (signed, per SAMMY convention).
103    pub(crate) j: f64,
104    /// Statistical weight g_J = (2J+1) / ((2I+1)(2s+1)).
105    pub(crate) g_j: f64,
106    /// Pre-computed per-resonance quantities for this J-group.
107    pub(crate) resonances: Vec<R>,
108}
109
110/// Type aliases for each channel count (preserves readability at call sites).
111type PrecomputedJGroupSingle = PrecomputedJGroup<PrecomputedResonanceSingle>;
112type PrecomputedJGroup2ch = PrecomputedJGroup<PrecomputedResonance2ch>;
113type PrecomputedJGroup3ch = PrecomputedJGroup<PrecomputedResonance3ch>;
114
115/// Compute penetrability at the resonance energy P_l(ρ_r).
116///
117/// ENDF widths are defined as Γ_n = 2·P_l(AP(E_r), E_r)·γ²_n,
118/// so the penetrability must be evaluated at the resonance energy
119/// using the channel radius AP(E_r) — not the incident-energy AP(E).
120///
121/// This function is the core quantity that Issue #87 caches: previously
122/// it was recomputed for every resonance at every energy point.
123///
124/// When E_r ≈ 0, the penetrability is zero (matching SLBW behavior in
125/// `slbw.rs`). This ensures the result depends only on resonance
126/// parameters and is independent of the incident energy, enabling the
127/// precompute to be hoisted above the energy loop.
128fn penetrability_at_resonance(
129    e_r: f64,
130    l: u32,
131    awr: f64,
132    channel_radius: f64,
133    ap_table: Option<&Tab1>,
134) -> f64 {
135    if e_r.abs() > PIVOT_FLOOR {
136        let radius_at_er = ap_table.map_or(channel_radius, |t| t.evaluate(e_r.abs()));
137        let rho_r = channel::rho(e_r.abs(), awr, radius_at_er);
138        penetrability::penetrability(l, rho_r)
139    } else {
140        // E_r ≈ 0 → P_l(0) = 0, so γ²_n = 0 regardless.
141        // Using 0.0 keeps this function energy-independent.
142        0.0
143    }
144}
145
146/// Group resonances by total angular momentum J, building per-resonance
147/// precomputed invariants via a caller-supplied closure.
148///
149/// This is the shared core of all `precompute_jgroups_*` functions (RM single,
150/// 2ch, 3ch) and SLBW's `precompute_slbw_jgroups`.  The closure `build_resonance`
151/// receives each ENDF resonance and returns the per-resonance struct `R` that
152/// differs between formalisms and channel counts.
153///
154/// The grouping logic is identical across all callers:
155/// 1. Extract J from each resonance.
156/// 2. Find or create a J-group (matching within `QUANTUM_NUMBER_EPS`).
157/// 3. Compute `g_J = (2J+1) / ((2I+1)(2s+1))` for new groups.
158/// 4. Push the precomputed resonance into the matching group.
159pub(crate) fn group_resonances_by_j<R>(
160    resonances: &[nereids_endf::resonance::Resonance],
161    target_spin: f64,
162    mut build_resonance: impl FnMut(&nereids_endf::resonance::Resonance) -> R,
163) -> Vec<PrecomputedJGroup<R>> {
164    let mut j_values: Vec<f64> = Vec::new();
165    let mut groups: Vec<PrecomputedJGroup<R>> = Vec::new();
166
167    for res in resonances {
168        let j = res.j;
169        let precomp = build_resonance(res);
170
171        if let Some(idx) = j_values
172            .iter()
173            .position(|&gj| (gj - j).abs() < QUANTUM_NUMBER_EPS)
174        {
175            groups[idx].resonances.push(precomp);
176        } else {
177            j_values.push(j);
178            groups.push(PrecomputedJGroup {
179                j,
180                g_j: channel::statistical_weight(j, target_spin),
181                resonances: vec![precomp],
182            });
183        }
184    }
185    groups
186}
187
188/// Build pre-computed J-groups for the single-channel (non-fissile) path.
189///
190/// Groups resonances by J, pre-computes γ²_n per resonance.
191/// All quantities depend only on resonance parameters (not incident energy),
192/// so the result can be computed once and reused across all energy points.
193fn precompute_jgroups_single(
194    resonances: &[nereids_endf::resonance::Resonance],
195    l: u32,
196    awr: f64,
197    channel_radius: f64,
198    ap_table: Option<&Tab1>,
199    target_spin: f64,
200) -> Vec<PrecomputedJGroupSingle> {
201    group_resonances_by_j(resonances, target_spin, |res| {
202        let p_at_er = penetrability_at_resonance(res.energy, l, awr, channel_radius, ap_table);
203        let gamma_n_reduced_sq = if p_at_er > PIVOT_FLOOR {
204            res.gn.abs() / (2.0 * p_at_er)
205        } else {
206            0.0
207        };
208        PrecomputedResonanceSingle {
209            energy: res.energy,
210            gamma_g: res.gg,
211            gamma_n_reduced_sq,
212        }
213    })
214}
215
216/// Build pre-computed J-groups for the 2-channel fission path.
217///
218/// All quantities depend only on resonance parameters (not incident energy),
219/// so the result can be computed once and reused across all energy points.
220fn precompute_jgroups_2ch(
221    resonances: &[nereids_endf::resonance::Resonance],
222    l: u32,
223    awr: f64,
224    channel_radius: f64,
225    ap_table: Option<&Tab1>,
226    target_spin: f64,
227) -> Vec<PrecomputedJGroup2ch> {
228    group_resonances_by_j(resonances, target_spin, |res| {
229        let p_at_er = penetrability_at_resonance(res.energy, l, awr, channel_radius, ap_table);
230
231        let beta_n = if p_at_er > PIVOT_FLOOR {
232            let sign = if res.gn >= 0.0 { 1.0 } else { -1.0 };
233            sign * (res.gn.abs() / (2.0 * p_at_er)).sqrt()
234        } else {
235            0.0
236        };
237
238        let beta_f = {
239            let sign = if res.gfa >= 0.0 { 1.0 } else { -1.0 };
240            sign * (res.gfa.abs() / 2.0).sqrt()
241        };
242
243        PrecomputedResonance2ch {
244            energy: res.energy,
245            gamma_g: res.gg,
246            beta_n,
247            beta_f,
248        }
249    })
250}
251
252/// Build pre-computed J-groups for the 3-channel fission path.
253///
254/// All quantities depend only on resonance parameters (not incident energy),
255/// so the result can be computed once and reused across all energy points.
256fn precompute_jgroups_3ch(
257    resonances: &[nereids_endf::resonance::Resonance],
258    l: u32,
259    awr: f64,
260    channel_radius: f64,
261    ap_table: Option<&Tab1>,
262    target_spin: f64,
263) -> Vec<PrecomputedJGroup3ch> {
264    group_resonances_by_j(resonances, target_spin, |res| {
265        let p_at_er = penetrability_at_resonance(res.energy, l, awr, channel_radius, ap_table);
266
267        let beta_n = if p_at_er > PIVOT_FLOOR {
268            let sign = if res.gn >= 0.0 { 1.0 } else { -1.0 };
269            sign * (res.gn.abs() / (2.0 * p_at_er)).sqrt()
270        } else {
271            0.0
272        };
273
274        let beta_fa = {
275            let sign = if res.gfa >= 0.0 { 1.0 } else { -1.0 };
276            sign * (res.gfa.abs() / 2.0).sqrt()
277        };
278
279        let beta_fb = {
280            let sign = if res.gfb >= 0.0 { 1.0 } else { -1.0 };
281            sign * (res.gfb.abs() / 2.0).sqrt()
282        };
283
284        PrecomputedResonance3ch {
285            energy: res.energy,
286            gamma_g: res.gg,
287            beta_n,
288            beta_fa,
289            beta_fb,
290        }
291    })
292}
293
294/// Cross-section results at a single energy point.
295#[derive(Debug, Clone, Copy)]
296pub struct CrossSections {
297    /// Total cross-section (barns).
298    pub total: f64,
299    /// Elastic scattering cross-section (barns).
300    pub elastic: f64,
301    /// Capture (n,γ) cross-section (barns).
302    pub capture: f64,
303    /// Fission cross-section (barns).
304    pub fission: f64,
305}
306
307/// Compute cross-sections at a single energy.
308///
309/// Dispatches each resonance range to the appropriate formalism-specific
310/// calculator (SLBW, MLBW, Reich-Moore, R-Matrix Limited, URR) based on the
311/// formalism stored in that range.  See the module-level table for the full
312/// dispatch map.
313///
314/// Adjacent ranges that share a boundary energy use half-open intervals
315/// `[e_low, e_high)` so the boundary point is counted exactly once
316/// (ENDF-6 §2 convention).
317///
318/// # Limitations
319/// MLBW (Multi-Level Breit-Wigner, LRF=2) ranges use true MLBW with interference.
320/// formulas as an approximation, ignoring resonance-resonance interference.
321/// Results may be inaccurate for closely spaced or overlapping resonances.
322///
323/// # Arguments
324/// * `data` — Parsed resonance parameters from ENDF.
325/// * `energy_ev` — Neutron energy in eV (lab frame).
326///
327/// # Returns
328/// Cross-sections in barns.
329pub fn cross_sections_at_energy(data: &ResonanceData, energy_ev: f64) -> CrossSections {
330    let awr = data.awr;
331
332    let mut total = 0.0;
333    let mut elastic = 0.0;
334    let mut capture = 0.0;
335    let mut fission = 0.0;
336
337    for (range_idx, range) in data.ranges.iter().enumerate() {
338        // Use half-open [low, high) only when the *next* range begins exactly at
339        // this range's upper endpoint AND that next range can actually produce
340        // cross-sections.  Ranges that parse successfully but whose physics is
341        // not yet wired up (URR with urr=None) must not steal the boundary —
342        // otherwise the shared energy point falls into a gap.
343        // ENDF-6 §2 — adjacent ranges share a single boundary energy.
344        let next_starts_here = data
345            .ranges
346            .get(range_idx + 1)
347            .is_some_and(|next| next.energy_low == range.energy_high && range_is_evaluable(next));
348        let in_range = if next_starts_here {
349            energy_ev >= range.energy_low && energy_ev < range.energy_high
350        } else {
351            energy_ev >= range.energy_low && energy_ev <= range.energy_high
352        };
353        if !in_range {
354            continue;
355        }
356
357        // URR (LRU=2): Hauser-Feshbach average cross-sections.
358        // These ranges have `resolved = false` so they must be dispatched before
359        // the `!range.resolved` skip below.
360        //
361        // Note: `parse_urr_range` sets urr.e_low == range.energy_low and
362        // urr.e_high == range.energy_high, so the outer `in_range` check and the
363        // inner band guard in `urr_cross_sections` test the same interval.
364        // The inner guard is kept as a safety net for direct calls.
365        if let Some(urr_data) = &range.urr {
366            debug_assert_eq!(
367                urr_data.e_low, range.energy_low,
368                "URR e_low must equal range.energy_low"
369            );
370            debug_assert_eq!(
371                urr_data.e_high, range.energy_high,
372                "URR e_high must equal range.energy_high"
373            );
374            let ap_fm = range.scattering_radius_at(energy_ev);
375            let (t, e, c, f) = urr::urr_cross_sections(urr_data, energy_ev, ap_fm);
376            total += t;
377            elastic += e;
378            capture += c;
379            fission += f;
380            continue;
381        }
382
383        if !range.resolved {
384            continue;
385        }
386
387        // Each range carries its own target_spin — pass per-range, not
388        // from the first range, to correctly compute statistical weights g_J.
389        let (t, e, c, f) = cross_sections_for_range(range, energy_ev, awr, range.target_spin);
390        total += t;
391        elastic += e;
392        capture += c;
393        fission += f;
394    }
395
396    CrossSections {
397        total,
398        elastic,
399        capture,
400        fission,
401    }
402}
403
404/// Compute cross-sections over a grid of energies.
405///
406/// Optimized batch evaluation: precomputes J-groups and per-resonance
407/// invariants (reduced width amplitudes, penetrability at E_r) once per
408/// resonance range, then evaluates each energy point using the cached data.
409/// This avoids redundant `group_by_j` + `penetrability(l, rho_r)` calls
410/// that the per-point API (`cross_sections_at_energy`) would repeat.
411///
412/// Issue #87: the precompute is hoisted above the energy loop so that
413/// `precompute_jgroups_*` runs O(ranges) times total, not O(ranges × energies).
414///
415/// # Limitations
416/// MLBW (Multi-Level Breit-Wigner, LRF=2) ranges use true MLBW with interference.
417/// formulas as an approximation, ignoring resonance-resonance interference.
418/// Results may be inaccurate for closely spaced or overlapping resonances.
419///
420/// # Arguments
421/// * `data` — Parsed resonance parameters from ENDF.
422/// * `energies` — Slice of neutron energies in eV.
423///
424/// # Returns
425/// Vector of cross-sections, one per energy point.
426pub fn cross_sections_on_grid(data: &ResonanceData, energies: &[f64]) -> Vec<CrossSections> {
427    if energies.is_empty() {
428        return Vec::new();
429    }
430
431    let awr = data.awr;
432
433    // Phase 1: precompute per-range data (J-groups, reduced widths, etc.).
434    // This runs once for the entire grid, not per energy point.
435    let precomputed: Vec<PrecomputedRangeData> = data
436        .ranges
437        .iter()
438        .enumerate()
439        .map(|(range_idx, range)| precompute_range_data(range, range_idx, data, awr))
440        .collect();
441
442    // Phase 2: evaluate each energy point using precomputed data.
443    energies
444        .iter()
445        .map(|&energy_ev| {
446            let mut total = 0.0;
447            let mut elastic = 0.0;
448            let mut capture = 0.0;
449            let mut fission = 0.0;
450
451            for pc in &precomputed {
452                let in_range = if pc.half_open_upper {
453                    energy_ev >= pc.energy_low && energy_ev < pc.energy_high
454                } else {
455                    energy_ev >= pc.energy_low && energy_ev <= pc.energy_high
456                };
457                if !in_range {
458                    continue;
459                }
460
461                let (t, e, c, f) = evaluate_precomputed_range(pc, energy_ev, awr);
462                total += t;
463                elastic += e;
464                capture += c;
465                fission += f;
466            }
467
468            CrossSections {
469                total,
470                elastic,
471                capture,
472                fission,
473            }
474        })
475        .collect()
476}
477
478// ─── Precomputed range data for batch grid evaluation ────────────────────────
479//
480// These types hold energy-independent invariants for a single resonance range,
481// precomputed once by `precompute_range_data` and reused for every energy point
482// in `cross_sections_on_grid`.
483
484/// Precomputed data for a single L-group within a Reich-Moore range.
485///
486/// Holds the J-group cache and metadata needed to compute energy-dependent
487/// channel parameters (rho, P_l, S_l, phi_l) at each energy point.
488enum PrecomputedRmLGroupData {
489    /// Non-fissile: single neutron channel, capture eliminated.
490    Single {
491        l: u32,
492        awr_l: f64,
493        /// L-group override radius (fm). Used only for scattering radius
494        /// (phase shifts). 0.0 means use range radius.
495        apl: f64,
496        /// Precomputed penetrability radius (fm): APL when set, else
497        /// NAPS=0 formula. 0.0 means fall back to `scatt_radius`.
498        pen_radius_override: f64,
499        jgroups: Vec<PrecomputedJGroupSingle>,
500    },
501    /// One fission channel (gfa != 0, gfb == 0).
502    TwoCh {
503        l: u32,
504        awr_l: f64,
505        apl: f64,
506        pen_radius_override: f64,
507        jgroups: Vec<PrecomputedJGroup2ch>,
508    },
509    /// Two fission channels (both gfa and gfb != 0).
510    ThreeCh {
511        l: u32,
512        awr_l: f64,
513        apl: f64,
514        pen_radius_override: f64,
515        jgroups: Vec<PrecomputedJGroup3ch>,
516    },
517}
518
519/// Precomputed data for a single SLBW L-group.
520struct PrecomputedSlbwLGroupData {
521    l: u32,
522    awr_l: f64,
523    /// L-group override radius (fm). 0.0 means use range radius.
524    apl: f64,
525    /// Precomputed penetrability radius (fm): APL when set, else
526    /// NAPS=0 formula. 0.0 means fall back to `scatt_radius`.
527    pen_radius_override: f64,
528    jgroups: Vec<slbw::PrecomputedSlbwJGroup>,
529}
530
531/// Precomputed data for a single resonance range.
532///
533/// Wraps the formalism-specific precomputed L-group data plus the
534/// energy interval metadata needed for range dispatch.
535struct PrecomputedRangeData<'a> {
536    energy_low: f64,
537    energy_high: f64,
538    half_open_upper: bool,
539    kind: PrecomputedRangeKind<'a>,
540}
541
542/// Formalism-specific precomputed data for a range.
543enum PrecomputedRangeKind<'a> {
544    /// Reich-Moore (LRF=3): precomputed J-groups per L-group.
545    /// The range reference is kept for `scattering_radius_at(energy_ev)`.
546    ReichMoore {
547        range: &'a ResonanceRange,
548        l_groups: Vec<PrecomputedRmLGroupData>,
549    },
550    /// SLBW/MLBW (LRF=1,2): precomputed J-groups per L-group.
551    /// The range reference is kept for `scattering_radius_at(energy_ev)`.
552    Slbw {
553        range: &'a ResonanceRange,
554        l_groups: Vec<PrecomputedSlbwLGroupData>,
555    },
556    /// R-Matrix Limited (LRF=7): no precompute, evaluate per-energy.
557    RMatrixLimited(&'a nereids_endf::resonance::RmlData),
558    /// URR: no precompute, evaluate per-energy.
559    Urr {
560        urr_data: &'a nereids_endf::resonance::UrrData,
561        range: &'a ResonanceRange,
562    },
563    /// Not evaluable (skip).
564    Skip,
565}
566
567/// Build precomputed range data for a single resonance range.
568///
569/// This extracts all energy-independent quantities (J-group structure,
570/// reduced width amplitudes, penetrability at resonance energies) so they
571/// can be reused across all energy points without redundant computation.
572fn precompute_range_data<'a>(
573    range: &'a ResonanceRange,
574    range_idx: usize,
575    data: &'a ResonanceData,
576    awr: f64,
577) -> PrecomputedRangeData<'a> {
578    // Half-open upper bound logic (same as cross_sections_at_energy).
579    let next_starts_here = data
580        .ranges
581        .get(range_idx + 1)
582        .is_some_and(|next| next.energy_low == range.energy_high && range_is_evaluable(next));
583
584    let make = |kind| PrecomputedRangeData {
585        energy_low: range.energy_low,
586        energy_high: range.energy_high,
587        half_open_upper: next_starts_here,
588        kind,
589    };
590
591    // URR ranges.
592    if let Some(urr_data) = &range.urr {
593        return make(PrecomputedRangeKind::Urr { urr_data, range });
594    }
595
596    if !range.resolved {
597        return make(PrecomputedRangeKind::Skip);
598    }
599
600    // RML ranges.
601    if let Some(rml) = &range.rml {
602        return make(PrecomputedRangeKind::RMatrixLimited(rml));
603    }
604
605    // SLBW/MLBW ranges: precompute J-groups per L-group.
606    if matches!(
607        range.formalism,
608        ResonanceFormalism::SLBW | ResonanceFormalism::MLBW
609    ) {
610        let l_groups: Vec<PrecomputedSlbwLGroupData> = range
611            .l_groups
612            .iter()
613            .map(|l_group| {
614                let l = l_group.l;
615                let awr_l = if l_group.awr > 0.0 { l_group.awr } else { awr };
616                let jgroups = slbw::precompute_slbw_jgroups(
617                    &l_group.resonances,
618                    l,
619                    awr_l,
620                    range,
621                    l_group,
622                    range.target_spin,
623                );
624                let pen_radius_override = if l_group.apl > 0.0 {
625                    l_group.apl
626                } else if range.naps == 0 {
627                    channel::endf_channel_radius_fm(awr_l)
628                } else {
629                    0.0
630                };
631                PrecomputedSlbwLGroupData {
632                    l,
633                    awr_l,
634                    apl: l_group.apl,
635                    pen_radius_override,
636                    jgroups,
637                }
638            })
639            .collect();
640        return make(PrecomputedRangeKind::Slbw { range, l_groups });
641    }
642
643    // Reich-Moore ranges: precompute J-groups per L-group.
644    if range.formalism == ResonanceFormalism::ReichMoore {
645        let rm_l_groups: Vec<PrecomputedRmLGroupData> = range
646            .l_groups
647            .iter()
648            .map(|l_group| {
649                let l = l_group.l;
650                let awr_l = if l_group.awr > 0.0 { l_group.awr } else { awr };
651
652                // Pen-radius override: energy-independent when APL > 0 or NAPS=0.
653                // Stored in the precomputed struct to avoid recomputation per energy.
654                let pen_radius_override = if l_group.apl > 0.0 {
655                    l_group.apl
656                } else if range.naps == 0 {
657                    channel::endf_channel_radius_fm(awr_l)
658                } else {
659                    0.0
660                };
661                // Channel radius for precompute: when a penetrability radius
662                // override is available (APL > 0 or NAPS=0), use that
663                // precomputed radius; otherwise fall back to the constant
664                // scattering_radius. The ap_table (NRO=1) case is handled
665                // inside penetrability_at_resonance, which evaluates the
666                // table at E_r for each resonance.
667                let channel_radius = if pen_radius_override > 0.0 {
668                    pen_radius_override
669                } else {
670                    range.scattering_radius
671                };
672                // NAPS=0: penetrability uses the formula radius, not the AP(E) table.
673                let ap_table_ref: Option<&Tab1> = if l_group.apl > 0.0 || range.naps == 0 {
674                    None
675                } else {
676                    range.ap_table.as_ref()
677                };
678
679                let has_fission = l_group
680                    .resonances
681                    .iter()
682                    .any(|r| r.gfa.abs() > PIVOT_FLOOR || r.gfb.abs() > PIVOT_FLOOR);
683                let has_two_fission = l_group.resonances.iter().any(|r| r.gfb.abs() > PIVOT_FLOOR);
684
685                if !has_fission {
686                    let jgroups = precompute_jgroups_single(
687                        &l_group.resonances,
688                        l,
689                        awr_l,
690                        channel_radius,
691                        ap_table_ref,
692                        range.target_spin,
693                    );
694                    PrecomputedRmLGroupData::Single {
695                        l,
696                        awr_l,
697                        apl: l_group.apl,
698                        pen_radius_override,
699                        jgroups,
700                    }
701                } else if !has_two_fission {
702                    // P-7: R-external now applied in the 2ch evaluation path.
703                    let jgroups = precompute_jgroups_2ch(
704                        &l_group.resonances,
705                        l,
706                        awr_l,
707                        channel_radius,
708                        ap_table_ref,
709                        range.target_spin,
710                    );
711                    PrecomputedRmLGroupData::TwoCh {
712                        l,
713                        awr_l,
714                        apl: l_group.apl,
715                        pen_radius_override,
716                        jgroups,
717                    }
718                } else {
719                    // P-7: R-external now applied in the 3ch evaluation path.
720                    let jgroups = precompute_jgroups_3ch(
721                        &l_group.resonances,
722                        l,
723                        awr_l,
724                        channel_radius,
725                        ap_table_ref,
726                        range.target_spin,
727                    );
728                    PrecomputedRmLGroupData::ThreeCh {
729                        l,
730                        awr_l,
731                        apl: l_group.apl,
732                        pen_radius_override,
733                        jgroups,
734                    }
735                }
736            })
737            .collect();
738        return make(PrecomputedRangeKind::ReichMoore {
739            range,
740            l_groups: rm_l_groups,
741        });
742    }
743
744    // Unrecognized formalism: skip.
745    make(PrecomputedRangeKind::Skip)
746}
747
748/// Evaluate cross-sections for a precomputed range at a single energy.
749///
750/// Uses the cached J-groups and per-resonance invariants to avoid
751/// redundant precomputation. Only energy-dependent quantities (rho,
752/// P_l, S_l, phi_l, pi/k^2) are computed per call.
753fn evaluate_precomputed_range(
754    pc: &PrecomputedRangeData,
755    energy_ev: f64,
756    awr: f64,
757) -> (f64, f64, f64, f64) {
758    match &pc.kind {
759        PrecomputedRangeKind::Skip => (0.0, 0.0, 0.0, 0.0),
760
761        PrecomputedRangeKind::RMatrixLimited(rml) => {
762            rmatrix_limited::cross_sections_for_rml_range(rml, energy_ev)
763        }
764
765        PrecomputedRangeKind::Urr { urr_data, range } => {
766            let ap_fm = range.scattering_radius_at(energy_ev);
767            urr::urr_cross_sections(urr_data, energy_ev, ap_fm)
768        }
769
770        PrecomputedRangeKind::Slbw { range, l_groups } => {
771            let pi_over_k2 = channel::pi_over_k_squared_barns(energy_ev, awr);
772            let mut total = 0.0;
773            let mut elastic = 0.0;
774            let mut capture = 0.0;
775            let mut fission = 0.0;
776
777            for lg in l_groups {
778                // Scattering radius for phase shift (always AP/APL).
779                let scatt_radius = if lg.apl > 0.0 {
780                    lg.apl
781                } else {
782                    range.scattering_radius_at(energy_ev)
783                };
784                // Penetrability radius: precomputed override (APL or NAPS=0
785                // formula), falling back to scattering radius.
786                let pen_radius = if lg.pen_radius_override > 0.0 {
787                    lg.pen_radius_override
788                } else {
789                    scatt_radius
790                };
791
792                let rho_phase = channel::rho(energy_ev, lg.awr_l, scatt_radius);
793                let rho_pen = channel::rho(energy_ev, lg.awr_l, pen_radius);
794                let phi = penetrability::phase_shift(lg.l, rho_phase);
795                let sin_phi = phi.sin();
796                let cos_phi = phi.cos();
797                let sin2_phi = sin_phi * sin_phi;
798                let p_at_e = penetrability::penetrability(lg.l, rho_pen);
799
800                let (t, e, c, f) = slbw::slbw_evaluate_with_cached_jgroups(
801                    &lg.jgroups,
802                    energy_ev,
803                    pi_over_k2,
804                    p_at_e,
805                    sin_phi,
806                    cos_phi,
807                    sin2_phi,
808                );
809                total += t;
810                elastic += e;
811                capture += c;
812                fission += f;
813            }
814
815            (total, elastic, capture, fission)
816        }
817
818        PrecomputedRangeKind::ReichMoore { range, l_groups } => {
819            let mut total = 0.0;
820            let mut elastic = 0.0;
821            let mut capture = 0.0;
822            let mut fission = 0.0;
823
824            for lg in l_groups {
825                let (l, awr_l, apl, pen_ovr) = match lg {
826                    PrecomputedRmLGroupData::Single {
827                        l,
828                        awr_l,
829                        apl,
830                        pen_radius_override,
831                        ..
832                    } => (*l, *awr_l, *apl, *pen_radius_override),
833                    PrecomputedRmLGroupData::TwoCh {
834                        l,
835                        awr_l,
836                        apl,
837                        pen_radius_override,
838                        ..
839                    } => (*l, *awr_l, *apl, *pen_radius_override),
840                    PrecomputedRmLGroupData::ThreeCh {
841                        l,
842                        awr_l,
843                        apl,
844                        pen_radius_override,
845                        ..
846                    } => (*l, *awr_l, *apl, *pen_radius_override),
847                };
848
849                // Scattering radius for phase shift (always AP/APL).
850                let scatt_radius = if apl > 0.0 {
851                    apl
852                } else {
853                    range.scattering_radius_at(energy_ev)
854                };
855                // Penetrability/shift radius: precomputed override (APL or
856                // NAPS=0 formula), falling back to scattering radius.
857                let pen_radius = if pen_ovr > 0.0 { pen_ovr } else { scatt_radius };
858
859                let rho_phase = channel::rho(energy_ev, awr_l, scatt_radius);
860                let rho_pen = channel::rho(energy_ev, awr_l, pen_radius);
861                let p_l = penetrability::penetrability(l, rho_pen);
862                let s_l = penetrability::shift_factor(l, rho_pen);
863                let phi_l = penetrability::phase_shift(l, rho_phase);
864
865                let (t, e, c, f) = match lg {
866                    PrecomputedRmLGroupData::Single { l, jgroups, .. } => {
867                        let mut t = 0.0;
868                        let mut e = 0.0;
869                        let mut c = 0.0;
870                        let mut f = 0.0;
871                        for jg in jgroups {
872                            // SAFETY: Float J comparison is safe here because both R-matrix resonance J values
873                            // and R-external J values originate from the same `compute_j_offsets()` map in
874                            // sammy.rs and follow the same computation path. The possible J offsets differ by
875                            // multiples of ~1e-6, which is many orders of magnitude larger than the 1e-10
876                            // QUANTUM_NUMBER_EPS used here, so the comparison reliably identifies matching J values.
877                            let r_ext = range
878                                .r_external
879                                .iter()
880                                .find(|re| re.l == *l && (re.j - jg.j).abs() < QUANTUM_NUMBER_EPS)
881                                .map(|re| re.evaluate(energy_ev))
882                                .unwrap_or(0.0);
883                            let (jt, je, jc, jf) = reich_moore_spin_group_precomputed(
884                                &jg.resonances,
885                                energy_ev,
886                                awr_l,
887                                jg.g_j,
888                                p_l,
889                                s_l,
890                                phi_l,
891                                r_ext,
892                            );
893                            t += jt;
894                            e += je;
895                            c += jc;
896                            f += jf;
897                        }
898                        (t, e, c, f)
899                    }
900                    PrecomputedRmLGroupData::TwoCh { jgroups, l, .. } => {
901                        let mut t = 0.0;
902                        let mut e = 0.0;
903                        let mut c = 0.0;
904                        let mut f = 0.0;
905                        for jg in jgroups {
906                            // P-7: R-external for 2ch fission path.
907                            let r_ext = range
908                                .r_external
909                                .iter()
910                                .find(|re| re.l == *l && (re.j - jg.j).abs() < QUANTUM_NUMBER_EPS)
911                                .map(|re| re.evaluate(energy_ev))
912                                .unwrap_or(0.0);
913                            let (jt, je, jc, jf) = reich_moore_2ch_precomputed(
914                                &jg.resonances,
915                                energy_ev,
916                                awr_l,
917                                jg.g_j,
918                                p_l,
919                                s_l,
920                                phi_l,
921                                r_ext,
922                            );
923                            t += jt;
924                            e += je;
925                            c += jc;
926                            f += jf;
927                        }
928                        (t, e, c, f)
929                    }
930                    PrecomputedRmLGroupData::ThreeCh { jgroups, l, .. } => {
931                        let mut t = 0.0;
932                        let mut e = 0.0;
933                        let mut c = 0.0;
934                        let mut f = 0.0;
935                        for jg in jgroups {
936                            // P-7: R-external for 3ch fission path.
937                            let r_ext = range
938                                .r_external
939                                .iter()
940                                .find(|re| re.l == *l && (re.j - jg.j).abs() < QUANTUM_NUMBER_EPS)
941                                .map(|re| re.evaluate(energy_ev))
942                                .unwrap_or(0.0);
943                            let (jt, je, jc, jf) = reich_moore_3ch_precomputed(
944                                &jg.resonances,
945                                energy_ev,
946                                awr_l,
947                                jg.g_j,
948                                p_l,
949                                s_l,
950                                phi_l,
951                                r_ext,
952                            );
953                            t += jt;
954                            e += je;
955                            c += jc;
956                            f += jf;
957                        }
958                        (t, e, c, f)
959                    }
960                };
961
962                total += t;
963                elastic += e;
964                capture += c;
965                fission += f;
966            }
967
968            (total, elastic, capture, fission)
969        }
970    }
971}
972
973/// Can this range actually produce non-zero cross-sections?
974///
975/// Returns `true` for formalisms whose physics evaluation is implemented:
976/// - SLBW (LRF=1) and MLBW (LRF=2) resolved ranges
977/// - Reich-Moore (LRF=3) resolved ranges
978/// - R-Matrix Limited (LRF=7) resolved ranges
979/// - URR ranges with parsed data (`urr.is_some()`)
980///
981/// Returns `false` for URR placeholders created when unsupported INT
982/// codes force a skip, or other unrecognized formalisms.
983///
984/// **Keep in sync with `cross_sections_for_range`.**  Whenever a new
985/// formalism is dispatched there, add it to the `matches!` pattern here so
986/// that energy boundary logic (`next_starts_here`) stays correct.
987fn range_is_evaluable(range: &ResonanceRange) -> bool {
988    if range.urr.is_some() {
989        return true;
990    }
991    if !range.resolved {
992        return false;
993    }
994    matches!(
995        range.formalism,
996        ResonanceFormalism::SLBW
997            | ResonanceFormalism::MLBW
998            | ResonanceFormalism::ReichMoore
999            | ResonanceFormalism::RMatrixLimited
1000    )
1001}
1002
1003/// Cross-sections for a single resolved resonance range.
1004///
1005/// Returns (total, elastic, capture, fission) in barns.
1006/// Dispatches to the R-Matrix Limited calculator for LRF=7 ranges.
1007fn cross_sections_for_range(
1008    range: &ResonanceRange,
1009    energy_ev: f64,
1010    awr: f64,
1011    target_spin: f64,
1012) -> (f64, f64, f64, f64) {
1013    // LRF=7 (R-Matrix Limited): dispatch to multi-channel calculator.
1014    if let Some(rml) = &range.rml {
1015        return rmatrix_limited::cross_sections_for_rml_range(rml, energy_ev);
1016    }
1017
1018    // SLBW: dispatch to the SLBW per-range calculator.
1019    if range.formalism == ResonanceFormalism::SLBW {
1020        return slbw::slbw_cross_sections_for_range(range, energy_ev, awr, target_spin);
1021    }
1022
1023    // MLBW: dispatch to the true multi-level Breit-Wigner calculator.
1024    // Unlike SLBW, this includes resonance-resonance interference in the
1025    // elastic channel (cross-terms between resonances within the same
1026    // spin group).  See SAMMY mlb/mmlb3.f90 Elastc_Mlb (line 54).
1027    if range.formalism == ResonanceFormalism::MLBW {
1028        return slbw::mlbw_cross_sections_for_range(range, energy_ev, awr, target_spin);
1029    }
1030
1031    let mut total = 0.0;
1032    let mut elastic = 0.0;
1033    let mut capture = 0.0;
1034    let mut fission = 0.0;
1035
1036    for l_group in &range.l_groups {
1037        let l = l_group.l;
1038        let awr_l = if l_group.awr > 0.0 { l_group.awr } else { awr };
1039
1040        // Scattering radius: use L-dependent radius if available,
1041        // otherwise the energy-dependent (or constant) global radius.
1042        // This is always used for hard-sphere phase shifts.
1043        let scatt_radius = if l_group.apl > 0.0 {
1044            l_group.apl
1045        } else {
1046            range.scattering_radius_at(energy_ev)
1047        };
1048
1049        // Penetrability/shift radius: NAPS=0 uses channel radius formula
1050        // (ENDF-6 §2.2.1), NAPS=1 uses the scattering radius.
1051        // APL overrides both — when set, it is the channel radius.
1052        let pen_radius = if l_group.apl > 0.0 {
1053            l_group.apl
1054        } else if range.naps == 0 {
1055            // NAPS=0: channel radius per ENDF-6 §2.2.1
1056            channel::endf_channel_radius_fm(awr_l)
1057        } else {
1058            scatt_radius
1059        };
1060
1061        // AP table for per-resonance radius: only applicable when the global
1062        // NRO=1 table is in use (i.e., no L-group override via APL).
1063        // When NRO=1, ENDF widths are defined at AP(E_r), not AP(energy_ev),
1064        // so p_at_er must use the radius evaluated at the resonance energy.
1065        // NAPS=0: penetrability uses the formula radius, not the AP(E) table.
1066        let ap_table_ref: Option<&Tab1> = if l_group.apl > 0.0 || range.naps == 0 {
1067            None
1068        } else {
1069            range.ap_table.as_ref()
1070        };
1071
1072        // Compute channel parameters at this energy.
1073        // Phase shift uses the scattering radius; penetrability/shift use
1074        // the NAPS-dependent radius (ENDF-6 §2.2.1).
1075        let rho_phase = channel::rho(energy_ev, awr_l, scatt_radius);
1076        let rho_pen = channel::rho(energy_ev, awr_l, pen_radius);
1077        let p_l = penetrability::penetrability(l, rho_pen);
1078        let s_l = penetrability::shift_factor(l, rho_pen);
1079        let phi_l = penetrability::phase_shift(l, rho_phase);
1080
1081        // Determine fission channel count for this L-group.
1082        let has_fission = l_group
1083            .resonances
1084            .iter()
1085            .any(|r| r.gfa.abs() > PIVOT_FLOOR || r.gfb.abs() > PIVOT_FLOOR);
1086        let has_two_fission = l_group.resonances.iter().any(|r| r.gfb.abs() > PIVOT_FLOOR);
1087
1088        match range.formalism {
1089            ResonanceFormalism::ReichMoore => {
1090                if !has_fission {
1091                    // Single-channel (non-fissile): build J-groups.
1092                    // Note: in the per-point path (cross_sections_at_energy),
1093                    // this is rebuilt each call. The batch grid path
1094                    // (cross_sections_on_grid) hoists this above the energy loop.
1095                    let jgroups = precompute_jgroups_single(
1096                        &l_group.resonances,
1097                        l,
1098                        awr_l,
1099                        pen_radius,
1100                        ap_table_ref,
1101                        target_spin,
1102                    );
1103                    for jg in &jgroups {
1104                        // R-external: look up background R-matrix for this (L, J).
1105                        // SAFETY: Float J comparison is safe here because both R-matrix resonance J values
1106                        // and R-external J values originate from the same `compute_j_offsets()` map in
1107                        // sammy.rs and follow the same computation path. The possible J offsets differ by
1108                        // multiples of ~1e-6, which is many orders of magnitude larger than the 1e-10
1109                        // QUANTUM_NUMBER_EPS used here, so the comparison reliably identifies matching J values.
1110                        let r_ext = range
1111                            .r_external
1112                            .iter()
1113                            .find(|re| re.l == l && (re.j - jg.j).abs() < QUANTUM_NUMBER_EPS)
1114                            .map(|re| re.evaluate(energy_ev))
1115                            .unwrap_or(0.0);
1116                        let (t, e, c, f) = reich_moore_spin_group_precomputed(
1117                            &jg.resonances,
1118                            energy_ev,
1119                            awr_l,
1120                            jg.g_j,
1121                            p_l,
1122                            s_l,
1123                            phi_l,
1124                            r_ext,
1125                        );
1126                        total += t;
1127                        elastic += e;
1128                        capture += c;
1129                        fission += f;
1130                    }
1131                } else if !has_two_fission {
1132                    // 2-channel fission: build J-groups (see note above).
1133                    let jgroups = precompute_jgroups_2ch(
1134                        &l_group.resonances,
1135                        l,
1136                        awr_l,
1137                        pen_radius,
1138                        ap_table_ref,
1139                        target_spin,
1140                    );
1141                    for jg in &jgroups {
1142                        // P-7: R-external for 2ch fission path.
1143                        let r_ext = range
1144                            .r_external
1145                            .iter()
1146                            .find(|re| re.l == l && (re.j - jg.j).abs() < QUANTUM_NUMBER_EPS)
1147                            .map(|re| re.evaluate(energy_ev))
1148                            .unwrap_or(0.0);
1149                        let (t, e, c, f) = reich_moore_2ch_precomputed(
1150                            &jg.resonances,
1151                            energy_ev,
1152                            awr_l,
1153                            jg.g_j,
1154                            p_l,
1155                            s_l,
1156                            phi_l,
1157                            r_ext,
1158                        );
1159                        total += t;
1160                        elastic += e;
1161                        capture += c;
1162                        fission += f;
1163                    }
1164                } else {
1165                    // 3-channel fission: build J-groups (see note above).
1166                    let jgroups = precompute_jgroups_3ch(
1167                        &l_group.resonances,
1168                        l,
1169                        awr_l,
1170                        pen_radius,
1171                        ap_table_ref,
1172                        target_spin,
1173                    );
1174                    for jg in &jgroups {
1175                        // P-7: R-external for 3ch fission path.
1176                        let r_ext = range
1177                            .r_external
1178                            .iter()
1179                            .find(|re| re.l == l && (re.j - jg.j).abs() < QUANTUM_NUMBER_EPS)
1180                            .map(|re| re.evaluate(energy_ev))
1181                            .unwrap_or(0.0);
1182                        let (t, e, c, f) = reich_moore_3ch_precomputed(
1183                            &jg.resonances,
1184                            energy_ev,
1185                            awr_l,
1186                            jg.g_j,
1187                            p_l,
1188                            s_l,
1189                            phi_l,
1190                            r_ext,
1191                        );
1192                        total += t;
1193                        elastic += e;
1194                        capture += c;
1195                        fission += f;
1196                    }
1197                }
1198            }
1199            ResonanceFormalism::SLBW | ResonanceFormalism::MLBW => {
1200                // Unreachable: SLBW/MLBW ranges are dispatched before
1201                // entering this loop (see early return above).
1202                unreachable!("SLBW/MLBW dispatched before l_group loop");
1203            }
1204            _ => {
1205                // Other formalisms (e.g. Adler-Adler LRF=4) are not
1206                // implemented.  `range_is_evaluable` returns `false` for
1207                // these, so they should never reach here through the
1208                // normal dispatcher.  This arm exists only for
1209                // exhaustiveness; contribution is zero.
1210                continue;
1211            }
1212        }
1213    }
1214
1215    (total, elastic, capture, fission)
1216}
1217
1218/// Cross-sections for a single spin group (J, π) in the Reich-Moore formalism,
1219/// using pre-computed per-resonance invariants (γ²_n cached).
1220///
1221/// For non-fissile isotopes, the R-matrix has a single neutron channel
1222/// and the capture channel is eliminated (absorbed into the imaginary
1223/// part of the resonance denominator).
1224///
1225/// ## Mathematical Formulation
1226///
1227/// For a single neutron channel with eliminated capture:
1228///
1229/// R(E) = Σ_n γ²_n / (E_n - E - iΓ_γ,n/2)
1230///
1231/// where γ²_n = Γ_n,n / (2·P_l(E_n)) is the reduced width amplitude squared.
1232///
1233/// Level matrix (scalar): Y = (S - B + iP)⁻¹ - R
1234///
1235/// X-matrix (scalar): X = P · Y⁻¹ · R · (S - B + iP)⁻¹
1236///
1237/// The scattering matrix element is:
1238///   U = e^{-2iφ} · (1 + 2i·X)
1239///
1240/// Cross-sections:
1241///   σ_elastic = (π/k²) · g_J · |1 - U|²
1242///   σ_total   = (2π/k²) · g_J · (1 - Re(U))
1243///   σ_capture = σ_total - σ_elastic (unitarity deficit)
1244///
1245/// Reference: SAMMY `rml/mrml11.f` Sectio routine
1246#[allow(clippy::too_many_arguments)]
1247fn reich_moore_spin_group_precomputed(
1248    resonances: &[PrecomputedResonanceSingle],
1249    energy_ev: f64,
1250    awr: f64,
1251    g_j: f64,
1252    p_l: f64,
1253    s_l: f64,
1254    phi_l: f64,
1255    r_ext: f64,
1256) -> (f64, f64, f64, f64) {
1257    let pi_over_k2 = channel::pi_over_k_squared_barns(energy_ev, awr);
1258
1259    // Single-channel case (neutron only, capture eliminated).
1260    // This is the common case for non-fissile isotopes.
1261
1262    // Boundary condition B = S_l(E): the shift factor and boundary cancel.
1263    //
1264    // SAMMY convention (CalcShift=false / Ishift=0): the shift factor
1265    // is NOT computed in the level matrix — Pgh (src/xxx/mxxx8.f90)
1266    // leaves S-B = 0 for all L values when Ishift=0. The resonance
1267    // energies in the .par file are observed peak positions.
1268    //
1269    // ENDF-102 convention (LRF=3 Reich-Moore): B_l = S_l, giving
1270    // S_l - B_l = 0 identically. Resonance energies are "formal"
1271    // eigenvalues, but with B=S the observed peaks coincide.
1272    //
1273    // Net effect: l_real = S - B = 0 for all L, so the level-matrix
1274    // denominator is 0 + iP, regardless of orbital angular momentum.
1275    let boundary = s_l;
1276
1277    // Build the R-matrix (scalar, complex) = Σ_n γ²_n / (E_n - E - iΓ_γ,n/2)
1278    //
1279    // Note: ENDF stores "observed" widths Γ_n. The reduced width amplitude is:
1280    //   γ²_n = Γ_n / (2 · P_l(ρ_n))
1281    // where ρ_n = k(E_n)·a, evaluated at the resonance energy.
1282    //
1283    // Issue #87: γ²_n is now pre-computed in PrecomputedResonanceSingle.
1284    //
1285    // Reference: SAMMY `rml/mrml03.f` Betset (lines 240-276)
1286    let mut r_real = 0.0;
1287    let mut r_imag = 0.0;
1288
1289    for res in resonances {
1290        let e_r = res.energy;
1291        let gamma_g = res.gamma_g;
1292        let gamma_n_reduced_sq = res.gamma_n_reduced_sq;
1293
1294        // Denominator: (E_n - E)² + (Γ_γ/2)²
1295        let de = e_r - energy_ev;
1296        let half_gg = gamma_g / 2.0;
1297        let denom = de * de + half_gg * half_gg;
1298
1299        if denom > DIVISION_FLOOR {
1300            // R-matrix contribution:
1301            // R += γ²_n / (E_n - E - i·Γ_γ/2)
1302            //    = γ²_n · (E_n - E + i·Γ_γ/2) / denom
1303            r_real += gamma_n_reduced_sq * de / denom;
1304            r_imag += gamma_n_reduced_sq * half_gg / denom;
1305        }
1306    }
1307
1308    // R-external: diagonal, real-valued background R-matrix correction.
1309    // Adds smooth energy-dependent contribution from distant resonances.
1310    // SAMMY Ref: mcro2.f90 Setr_Cro lines 180-193
1311    r_real += r_ext;
1312
1313    // Level matrix Y = 1/(S - B + iP) - R  (scalar, complex)
1314    let l_real = s_l - boundary;
1315    let l_imag = p_l;
1316    let l_denom = l_real * l_real + l_imag * l_imag;
1317    if l_denom < LOG_FLOOR {
1318        return (0.0, 0.0, 0.0, 0.0);
1319    }
1320
1321    // 1/(S - B + iP) = (S - B - iP) / |S - B + iP|²
1322    let l_inv_real = l_real / l_denom;
1323    let l_inv_imag = -l_imag / l_denom;
1324
1325    let y_real = l_inv_real - r_real;
1326    let y_imag = l_inv_imag - r_imag;
1327
1328    // Y⁻¹ = 1/Y
1329    let y_denom = y_real * y_real + y_imag * y_imag;
1330    if y_denom < LOG_FLOOR {
1331        return (0.0, 0.0, 0.0, 0.0);
1332    }
1333    let y_inv_real = y_real / y_denom;
1334    let y_inv_imag = -y_imag / y_denom;
1335
1336    // X-matrix (scalar): X = P · Y⁻¹ · R · (1/(S-B+iP))
1337    // Actually: X = √P · Y⁻¹ · R · √P · (1/(S-B+iP))
1338    //
1339    // From SAMMY mrml11.f: XXXX = √P_J · (Y⁻¹·R)_JI · (√P_I / L_II)
1340    // For single channel: X = √P · Y⁻¹ · R · √P / L
1341    //                       = P · Y⁻¹ · R / (S-B+iP)
1342    //
1343    // Let's compute step by step:
1344    // 1. q = Y⁻¹ · R (complex multiply)
1345    let q_real = y_inv_real * r_real - y_inv_imag * r_imag;
1346    let q_imag = y_inv_real * r_imag + y_inv_imag * r_real;
1347
1348    // 2. X = P · q / (S-B+iP) = P · q · (S-B-iP) / |S-B+iP|²
1349    let x_unscaled_real = q_real * l_real + q_imag * l_imag;
1350    let x_unscaled_imag = q_imag * l_real - q_real * l_imag;
1351    let x_real = p_l * x_unscaled_real / l_denom;
1352    let x_imag = p_l * x_unscaled_imag / l_denom;
1353
1354    // Compute the collision matrix element U from X.
1355    //
1356    //   U = e^{-2iφ} · (1 + 2iX)
1357    //
1358    // The phase factor uses e^{-2iφ}, NOT e^{+2iφ}.  SAMMY's Cossin
1359    // subroutine (src/xxx/mxxx6.f90 line 8) generates cos(2φ) and sin(2φ),
1360    // and the Total subroutine (src/cro/mcro4.f90 line 232) combines them
1361    // as:  Re(U) = cos(2φ)·Wr + sin(2φ)·Wi = Re(e^{-2iφ} · W)
1362    //
1363    // This is the Ω² factor in Lane & Thomas: Ω = e^{-iφ}.
1364    //
1365    // Reference: ENDF-102 Section 2, Lane & Thomas R-matrix theory
1366    let x = Complex64::new(x_real, x_imag);
1367    let phase = Complex64::new((2.0 * phi_l).cos(), -(2.0 * phi_l).sin());
1368    let u = phase * (1.0 + 2.0 * Complex64::i() * x);
1369
1370    // Cross-sections from the collision matrix U:
1371    //
1372    //   σ_total   = g_J · (2π/k²) · (1 - Re(U))
1373    //   σ_elastic = g_J · (π/k²) · |1 - U|²
1374    //   σ_capture = σ_total - σ_elastic  (unitarity deficit)
1375    //
1376    // Reference: standard R-matrix cross-section formulas
1377    let sigma_total = g_j * 2.0 * pi_over_k2 * (1.0 - u.re);
1378    let one_minus_u = 1.0 - u;
1379    let sigma_elastic = g_j * pi_over_k2 * one_minus_u.norm_sqr();
1380    let sigma_capture = sigma_total - sigma_elastic;
1381
1382    // For non-fissile isotopes, all absorption is capture.
1383    (sigma_total, sigma_elastic, sigma_capture, 0.0)
1384}
1385
1386/// Reich-Moore 2-channel (neutron + 1 fission) with pre-computed betas.
1387///
1388/// Reference: SAMMY `rml/mrml09.f` Twoch routine
1389#[allow(clippy::too_many_arguments)]
1390fn reich_moore_2ch_precomputed(
1391    resonances: &[PrecomputedResonance2ch],
1392    energy_ev: f64,
1393    awr: f64,
1394    g_j: f64,
1395    p_l: f64,
1396    s_l: f64,
1397    phi_l: f64,
1398    r_ext: f64,
1399) -> (f64, f64, f64, f64) {
1400    let pi_over_k2 = channel::pi_over_k_squared_barns(energy_ev, awr);
1401    // B = S_l(E) — see comment in reich_moore_spin_group_precomputed.
1402    let boundary = s_l;
1403
1404    // 2-channel: neutron + one fission channel.
1405    // R-matrix is 2x2 complex.
1406    let mut r_mat = [[Complex64::new(0.0, 0.0); 2]; 2];
1407
1408    for res in resonances {
1409        // Denominator: (E_n - E) - i*Gamma_g/2
1410        let de = res.energy - energy_ev;
1411        let half_gg = res.gamma_g / 2.0;
1412        let inv_denom = 1.0 / Complex64::new(de, -half_gg);
1413
1414        // R_ij += beta_i * beta_j / denom
1415        let betas = [res.beta_n, res.beta_f];
1416        for i in 0..2 {
1417            for j in 0..2 {
1418                r_mat[i][j] += betas[i] * betas[j] * inv_denom;
1419            }
1420        }
1421    }
1422
1423    // P-7: R-external adds to the neutron channel diagonal only.
1424    // This is a smooth energy-dependent background from distant resonances.
1425    // SAMMY ref: mcro2.f90 Setr_Cro lines 180-193.
1426    if r_ext != 0.0 {
1427        r_mat[0][0] += Complex64::new(r_ext, 0.0);
1428    }
1429
1430    // Level matrix Y = diag(1/(S-B+iP)) - R
1431    // Channel 0 (neutron): L = S_l - B + i*P_l
1432    // Channel 1 (fission): L = 0 + i*1 (no penetrability, Pent=0)
1433    //   -> fission channel: P_f = 1, S_f = 0
1434    let l_n = Complex64::new(s_l - boundary, p_l);
1435    let l_f = Complex64::new(0.0, 1.0); // Fission: no barrier
1436
1437    let l_inv = [1.0 / l_n, 1.0 / l_f];
1438
1439    let mut y_mat = [[Complex64::new(0.0, 0.0); 2]; 2];
1440    for i in 0..2 {
1441        for j in 0..2 {
1442            y_mat[i][j] = -r_mat[i][j];
1443        }
1444        y_mat[i][i] += l_inv[i];
1445    }
1446
1447    // Invert 2x2 Y-matrix.
1448    // Guard against singular matrix.
1449    let det = y_mat[0][0] * y_mat[1][1] - y_mat[0][1] * y_mat[1][0];
1450    if det.norm() < LOG_FLOOR {
1451        return (0.0, 0.0, 0.0, 0.0);
1452    }
1453    let inv_det = 1.0 / det;
1454    let y_inv = [
1455        [y_mat[1][1] * inv_det, -y_mat[0][1] * inv_det],
1456        [-y_mat[1][0] * inv_det, y_mat[0][0] * inv_det],
1457    ];
1458
1459    // X-matrix (ENDF-102 Eq. 2.76):
1460    // W_ij = sqrt(P_i) * (L^{-1}_i) * (Y^{-1} * R)_ij * sqrt(P_j)
1461    //
1462    // The L^{-1} factor is applied to channel i (row), NOT channel j (column).
1463    // This matters for off-diagonal elements where L_n = iP ≠ L_f = i.
1464    // Ref: (I - RL)^{-1} = L^{-1} · Y^{-1}, so L^{-1} multiplies from the left.
1465    let mut q = [[Complex64::new(0.0, 0.0); 2]; 2];
1466    for i in 0..2 {
1467        for j in 0..2 {
1468            for k in 0..2 {
1469                q[i][j] += y_inv[i][k] * r_mat[k][j];
1470            }
1471        }
1472    }
1473
1474    let sqrt_p = [p_l.sqrt(), 1.0]; // sqrt(P_n), sqrt(P_f)
1475    let l_vals = [l_n, l_f];
1476    let mut x_mat = [[Complex64::new(0.0, 0.0); 2]; 2];
1477    for i in 0..2 {
1478        for j in 0..2 {
1479            x_mat[i][j] = sqrt_p[i] * q[i][j] * sqrt_p[j] / l_vals[i];
1480        }
1481    }
1482
1483    // Collision matrix U from X-matrix.
1484    // Phase: e^{-2iφ} for diagonal, e^{-iφ} for off-diagonal (one neutron leg).
1485    // See comment in reich_moore_spin_group_precomputed for SAMMY reference.
1486    let phase2 = Complex64::new((2.0 * phi_l).cos(), -(2.0 * phi_l).sin());
1487    let phase1 = Complex64::new(phi_l.cos(), -phi_l.sin());
1488
1489    let u_nn = phase2 * (1.0 + 2.0 * Complex64::i() * x_mat[0][0]);
1490    let u_nf = phase1 * 2.0 * Complex64::i() * x_mat[0][1];
1491
1492    // Cross-sections from U-matrix.
1493    let sigma_total = g_j * 2.0 * pi_over_k2 * (1.0 - u_nn.re);
1494    let sigma_elastic = g_j * pi_over_k2 * (1.0 - u_nn).norm_sqr();
1495    let sigma_fission = g_j * pi_over_k2 * u_nf.norm_sqr();
1496    let sigma_capture = sigma_total - sigma_elastic - sigma_fission;
1497
1498    (sigma_total, sigma_elastic, sigma_capture, sigma_fission)
1499}
1500
1501/// 3-channel Reich-Moore (neutron + 2 fission channels) with pre-computed betas.
1502#[allow(clippy::too_many_arguments)]
1503fn reich_moore_3ch_precomputed(
1504    resonances: &[PrecomputedResonance3ch],
1505    energy_ev: f64,
1506    awr: f64,
1507    g_j: f64,
1508    p_l: f64,
1509    s_l: f64,
1510    phi_l: f64,
1511    r_ext: f64,
1512) -> (f64, f64, f64, f64) {
1513    let pi_over_k2 = channel::pi_over_k_squared_barns(energy_ev, awr);
1514    // B = S_l(E) — see comment in reich_moore_spin_group_precomputed.
1515    let boundary = s_l;
1516
1517    let mut r_mat = [[Complex64::new(0.0, 0.0); 3]; 3];
1518
1519    for res in resonances {
1520        let de = res.energy - energy_ev;
1521        let half_gg = res.gamma_g / 2.0;
1522        let inv_denom = 1.0 / Complex64::new(de, -half_gg);
1523
1524        let betas = [res.beta_n, res.beta_fa, res.beta_fb];
1525        for i in 0..3 {
1526            for j in 0..3 {
1527                r_mat[i][j] += betas[i] * betas[j] * inv_denom;
1528            }
1529        }
1530    }
1531
1532    // P-7: R-external adds to the neutron channel diagonal only.
1533    if r_ext != 0.0 {
1534        r_mat[0][0] += Complex64::new(r_ext, 0.0);
1535    }
1536
1537    // Level matrix Y.
1538    let l_n = Complex64::new(s_l - boundary, p_l);
1539    let l_f = Complex64::new(0.0, 1.0);
1540    let l_vals = [l_n, l_f, l_f];
1541    let l_inv: Vec<Complex64> = l_vals.iter().map(|&li| 1.0 / li).collect();
1542
1543    let mut y_mat = [[Complex64::new(0.0, 0.0); 3]; 3];
1544    for i in 0..3 {
1545        for j in 0..3 {
1546            y_mat[i][j] = -r_mat[i][j];
1547        }
1548        y_mat[i][i] += l_inv[i];
1549    }
1550
1551    // Invert 3x3 via cofactor expansion.
1552    let y_inv = match invert_3x3(y_mat) {
1553        Some(inv) => inv,
1554        None => return (0.0, 0.0, 0.0, 0.0),
1555    };
1556
1557    // X-matrix.
1558    let sqrt_p = [p_l.sqrt(), 1.0, 1.0];
1559    let mut x_mat = [[Complex64::new(0.0, 0.0); 3]; 3];
1560    let mut q = [[Complex64::new(0.0, 0.0); 3]; 3];
1561    for i in 0..3 {
1562        for j in 0..3 {
1563            for k in 0..3 {
1564                q[i][j] += y_inv[i][k] * r_mat[k][j];
1565            }
1566        }
1567    }
1568    for i in 0..3 {
1569        for j in 0..3 {
1570            x_mat[i][j] = sqrt_p[i] * q[i][j] * sqrt_p[j] / l_vals[i];
1571        }
1572    }
1573
1574    // Collision matrix U from X-matrix.
1575    // Phase: e^{-2iφ} for diagonal, e^{-iφ} for off-diagonal.
1576    let phase2 = Complex64::new((2.0 * phi_l).cos(), -(2.0 * phi_l).sin());
1577    let phase1 = Complex64::new(phi_l.cos(), -phi_l.sin());
1578
1579    let u_nn = phase2 * (1.0 + 2.0 * Complex64::i() * x_mat[0][0]);
1580    let u_nf1 = phase1 * 2.0 * Complex64::i() * x_mat[0][1];
1581    let u_nf2 = phase1 * 2.0 * Complex64::i() * x_mat[0][2];
1582
1583    // Cross-sections from U-matrix.
1584    let sigma_total = g_j * 2.0 * pi_over_k2 * (1.0 - u_nn.re);
1585    let sigma_elastic = g_j * pi_over_k2 * (1.0 - u_nn).norm_sqr();
1586    let sigma_fission = g_j * pi_over_k2 * (u_nf1.norm_sqr() + u_nf2.norm_sqr());
1587    let sigma_capture = sigma_total - sigma_elastic - sigma_fission;
1588
1589    (sigma_total, sigma_elastic, sigma_capture, sigma_fission)
1590}
1591
1592/// Invert a 3×3 complex matrix via cofactor expansion.
1593///
1594/// Returns `None` if the matrix is singular (|det| < LOG_FLOOR), preventing
1595/// NaN propagation from 1/det when det ≈ 0.
1596fn invert_3x3(m: [[Complex64; 3]; 3]) -> Option<[[Complex64; 3]; 3]> {
1597    let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
1598        - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
1599        + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
1600
1601    if det.norm() < LOG_FLOOR {
1602        return None; // singular — caller returns zero cross-sections
1603    }
1604
1605    let inv_det = 1.0 / det;
1606
1607    let mut result = [[Complex64::new(0.0, 0.0); 3]; 3];
1608    result[0][0] = (m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv_det;
1609    result[0][1] = (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv_det;
1610    result[0][2] = (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv_det;
1611    result[1][0] = (m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv_det;
1612    result[1][1] = (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv_det;
1613    result[1][2] = (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv_det;
1614    result[2][0] = (m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv_det;
1615    result[2][1] = (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv_det;
1616    result[2][2] = (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv_det;
1617
1618    Some(result)
1619}
1620
1621// J-group assembly is now done inline via the `precompute_jgroups_*` functions
1622// (Issue #87).  The old `group_by_j` import is no longer needed.
1623
1624#[cfg(test)]
1625mod tests {
1626    use super::*;
1627    use nereids_endf::resonance::{LGroup, Resonance, ResonanceRange};
1628
1629    /// Create a simple single-resonance test case for validation.
1630    #[allow(clippy::too_many_arguments)]
1631    fn make_single_resonance_data(
1632        energy: f64,
1633        gamma_n: f64,
1634        gamma_g: f64,
1635        j: f64,
1636        l: u32,
1637        awr: f64,
1638        target_spin: f64,
1639        scattering_radius: f64,
1640    ) -> ResonanceData {
1641        ResonanceData {
1642            isotope: nereids_core::types::Isotope::new(92, 238).unwrap(),
1643            za: 92238,
1644            awr,
1645            ranges: vec![ResonanceRange {
1646                energy_low: 1e-5,
1647                energy_high: 1e4,
1648                resolved: true,
1649                formalism: ResonanceFormalism::ReichMoore,
1650                target_spin,
1651                scattering_radius,
1652                naps: 1,
1653                l_groups: vec![LGroup {
1654                    l,
1655                    awr,
1656                    apl: 0.0,
1657                    qx: 0.0,
1658                    lrx: 0,
1659                    resonances: vec![Resonance {
1660                        energy,
1661                        j,
1662                        gn: gamma_n,
1663                        gg: gamma_g,
1664                        gfa: 0.0,
1665                        gfb: 0.0,
1666                    }],
1667                }],
1668                rml: None,
1669                ap_table: None,
1670                urr: None,
1671                r_external: vec![],
1672            }],
1673        }
1674    }
1675
1676    #[test]
1677    fn test_capture_peak_single_resonance() {
1678        // U-238 6.674 eV resonance.
1679        // At the resonance energy, capture cross-section should peak at ~22,000 barns.
1680        let data = make_single_resonance_data(
1681            6.674,    // E_r (eV)
1682            1.493e-3, // Γ_n (eV)
1683            23.0e-3,  // Γ_γ (eV)
1684            0.5,      // J
1685            0,        // L
1686            236.006,  // AWR
1687            0.0,      // target spin I
1688            9.4285,   // scattering radius (fm)
1689        );
1690
1691        let xs = cross_sections_at_energy(&data, 6.674);
1692
1693        // The capture cross-section at peak should be approximately:
1694        // σ_c = g_J × π/k² × 4×Γ_n×Γ_γ / Γ² where Γ = Γ_n + Γ_γ
1695        // For the RM formalism the peak is very close to this BW estimate.
1696        // g_J = 1.0, π/k² ≈ 98,200 barns, Γ = 0.024493
1697        // σ_c ≈ 1.0 × 98200 × 4 × 1.493e-3 × 23.0e-3 / (24.493e-3)²
1698        //     ≈ 98200 × 0.2289 ≈ 22,478 barns
1699        println!("Capture at 6.674 eV: {} barns", xs.capture);
1700        println!("Total at 6.674 eV: {} barns", xs.total);
1701        println!("Elastic at 6.674 eV: {} barns", xs.elastic);
1702
1703        assert!(
1704            xs.capture > 15000.0 && xs.capture < 30000.0,
1705            "Capture should be ~22000 barns, got {}",
1706            xs.capture
1707        );
1708        assert!(xs.total > xs.capture, "Total > capture");
1709        assert!(xs.elastic > 0.0, "Elastic should be positive");
1710        assert!(xs.fission.abs() < 1e-10, "No fission for U-238");
1711    }
1712
1713    #[test]
1714    fn test_1_over_v_behavior() {
1715        // Far from resonances, capture cross-section should follow 1/v ∝ 1/√E.
1716        // The 6.674 eV resonance tail should dominate at low energies.
1717        let data =
1718            make_single_resonance_data(6.674, 1.493e-3, 23.0e-3, 0.5, 0, 236.006, 0.0, 9.4285);
1719
1720        let xs_01 = cross_sections_at_energy(&data, 0.1);
1721        let xs_04 = cross_sections_at_energy(&data, 0.4);
1722
1723        // At low E, σ ∝ 1/√E, so σ(0.1)/σ(0.4) ≈ √(0.4/0.1) = 2.0
1724        let ratio = xs_01.capture / xs_04.capture;
1725        println!("1/v ratio test: σ(0.1)/σ(0.4) = {}", ratio);
1726        assert!(
1727            (ratio - 2.0).abs() < 0.3,
1728            "Expected ~2.0 for 1/v behavior, got {}",
1729            ratio
1730        );
1731    }
1732
1733    #[test]
1734    fn test_cross_sections_positive() {
1735        // All cross-sections must be non-negative at all energies.
1736        let data =
1737            make_single_resonance_data(6.674, 1.493e-3, 23.0e-3, 0.5, 0, 236.006, 0.0, 9.4285);
1738
1739        for &e in &[0.01, 0.1, 1.0, 5.0, 6.0, 6.674, 7.0, 10.0, 100.0, 1000.0] {
1740            let xs = cross_sections_at_energy(&data, e);
1741            assert!(xs.total >= 0.0, "Total negative at E={}: {}", e, xs.total);
1742            assert!(
1743                xs.elastic >= 0.0,
1744                "Elastic negative at E={}: {}",
1745                e,
1746                xs.elastic
1747            );
1748            assert!(
1749                xs.capture >= -1e-10,
1750                "Capture negative at E={}: {}",
1751                e,
1752                xs.capture
1753            );
1754        }
1755    }
1756
1757    /// Parse full U-238 ENDF file and compute cross-sections.
1758    ///
1759    /// Validates against SAMMY ex027 output (Doppler-broadened at 300K, but
1760    /// we compare unbroadened RM values which should bracket the broadened data).
1761    #[test]
1762    fn test_u238_full_endf_cross_sections() {
1763        let endf_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1764            .parent()
1765            .unwrap()
1766            .parent()
1767            .unwrap()
1768            .join("../SAMMY/SAMMY/samexm_new/ex027_new/ex027.endf");
1769
1770        if !endf_path.exists() {
1771            println!("Skipping: SAMMY checkout not found at {:?}", endf_path);
1772            return;
1773        }
1774
1775        let endf_text = std::fs::read_to_string(&endf_path).unwrap();
1776        let data = nereids_endf::parser::parse_endf_file2(&endf_text).unwrap();
1777
1778        // Compute cross-sections at several energies near the 6.674 eV resonance.
1779        let energies = [1.0, 5.0, 6.0, 6.5, 6.674, 7.0, 8.0, 10.0, 20.0, 50.0, 100.0];
1780
1781        println!("\nU-238 Reich-Moore cross-sections (unbroadened):");
1782        println!(
1783            "{:>10} {:>12} {:>12} {:>12} {:>12}",
1784            "E (eV)", "Total", "Elastic", "Capture", "Fission"
1785        );
1786
1787        for &e in &energies {
1788            let xs = cross_sections_at_energy(&data, e);
1789            println!(
1790                "{:>10.3} {:>12.3} {:>12.3} {:>12.3} {:>12.6}",
1791                e, xs.total, xs.elastic, xs.capture, xs.fission
1792            );
1793
1794            // Basic sanity: all cross-sections non-negative.
1795            assert!(xs.total >= 0.0, "Total negative at E={}", e);
1796            assert!(xs.elastic >= 0.0, "Elastic negative at E={}", e);
1797            // Capture can be very slightly negative due to floating point.
1798            assert!(
1799                xs.capture >= -0.01,
1800                "Capture negative at E={}: {}",
1801                e,
1802                xs.capture
1803            );
1804        }
1805
1806        // Check the 6.674 eV resonance peak.
1807        // With the full ENDF file (all resonances), the peak capture
1808        // should still be dominated by the 6.674 eV resonance.
1809        let xs_peak = cross_sections_at_energy(&data, 6.674);
1810        assert!(
1811            xs_peak.capture > 10000.0,
1812            "Capture at 6.674 eV should be >10,000 barns (got {})",
1813            xs_peak.capture
1814        );
1815
1816        // The 20.87 eV resonance should also show a significant peak.
1817        let xs_20 = cross_sections_at_energy(&data, 20.87);
1818        assert!(
1819            xs_20.capture > 1000.0,
1820            "Capture at 20.87 eV should be >1,000 barns (got {})",
1821            xs_20.capture
1822        );
1823
1824        // SAMMY ex027 broadened output at ~6.674 eV gives ~339 barns capture.
1825        // Our UNBROADENED result should be MUCH larger (since Doppler broadening
1826        // spreads the peak). This confirms we're computing the correct physics.
1827        println!(
1828            "\n6.674 eV peak: capture={:.0} barns (unbroadened), SAMMY broadened ~339 barns",
1829            xs_peak.capture
1830        );
1831        assert!(
1832            xs_peak.capture > 339.0,
1833            "Unbroadened peak must exceed SAMMY broadened value"
1834        );
1835    }
1836
1837    /// Build a single-resonance `ResonanceData` with a chosen formalism.
1838    fn make_slbw_data(formalism: ResonanceFormalism) -> ResonanceData {
1839        ResonanceData {
1840            isotope: nereids_core::types::Isotope::new(92, 238).unwrap(),
1841            za: 92238,
1842            awr: 236.006,
1843            ranges: vec![ResonanceRange {
1844                energy_low: 1e-5,
1845                energy_high: 1e4,
1846                resolved: true,
1847                formalism,
1848                target_spin: 0.0,
1849                scattering_radius: 9.4285,
1850                naps: 1,
1851                l_groups: vec![LGroup {
1852                    l: 0,
1853                    awr: 236.006,
1854                    apl: 0.0,
1855                    qx: 0.0,
1856                    lrx: 0,
1857                    resonances: vec![Resonance {
1858                        energy: 6.674,
1859                        j: 0.5,
1860                        gn: 1.493e-3,
1861                        gg: 23.0e-3,
1862                        gfa: 0.0,
1863                        gfb: 0.0,
1864                    }],
1865                }],
1866                rml: None,
1867                ap_table: None,
1868                urr: None,
1869                r_external: vec![],
1870            }],
1871        }
1872    }
1873
1874    /// `cross_sections_at_energy` with an SLBW-formalism range must give
1875    /// the same result as `slbw::slbw_cross_sections`.
1876    #[test]
1877    fn test_dispatcher_slbw_matches_slbw_module() {
1878        let data = make_slbw_data(ResonanceFormalism::SLBW);
1879
1880        let test_energies = [0.1, 1.0, 5.0, 6.0, 6.674, 7.0, 10.0, 100.0];
1881        for &e in &test_energies {
1882            let via_dispatcher = cross_sections_at_energy(&data, e);
1883            let via_slbw = crate::slbw::slbw_cross_sections(&data, e);
1884
1885            let eps = 1e-10;
1886            assert!(
1887                (via_dispatcher.total - via_slbw.total).abs() < eps,
1888                "total mismatch at {e} eV: dispatcher={} slbw={}",
1889                via_dispatcher.total,
1890                via_slbw.total
1891            );
1892            assert!(
1893                (via_dispatcher.capture - via_slbw.capture).abs() < eps,
1894                "capture mismatch at {e} eV: dispatcher={} slbw={}",
1895                via_dispatcher.capture,
1896                via_slbw.capture
1897            );
1898            assert!(
1899                (via_dispatcher.elastic - via_slbw.elastic).abs() < eps,
1900                "elastic mismatch at {e} eV: dispatcher={} slbw={}",
1901                via_dispatcher.elastic,
1902                via_slbw.elastic
1903            );
1904        }
1905    }
1906
1907    /// For a **single isolated resonance**, MLBW and SLBW should produce
1908    /// identical results because the interference term (cross-resonance)
1909    /// vanishes when there is only one resonance per spin group.
1910    ///
1911    /// Capture and fission are always identical (interference only
1912    /// affects elastic).  Elastic should also match for single resonances.
1913    #[test]
1914    fn test_mlbw_single_resonance_matches_slbw() {
1915        let data_mlbw = make_slbw_data(ResonanceFormalism::MLBW);
1916        let data_slbw = make_slbw_data(ResonanceFormalism::SLBW);
1917
1918        let test_energies = [1.0, 6.674, 10.0];
1919        for &e in &test_energies {
1920            let xs_mlbw = cross_sections_at_energy(&data_mlbw, e);
1921            let xs_slbw = cross_sections_at_energy(&data_slbw, e);
1922
1923            // Capture and fission must be identical.
1924            assert!(
1925                (xs_mlbw.capture - xs_slbw.capture).abs() < 1e-10,
1926                "MLBW/SLBW capture mismatch at {e} eV: mlbw={} slbw={}",
1927                xs_mlbw.capture,
1928                xs_slbw.capture
1929            );
1930
1931            // Elastic: for single resonance, MLBW formula should reduce
1932            // to SLBW because there are no cross-terms.
1933            let rel_diff = if xs_slbw.elastic.abs() > 1e-10 {
1934                (xs_mlbw.elastic - xs_slbw.elastic).abs() / xs_slbw.elastic
1935            } else {
1936                (xs_mlbw.elastic - xs_slbw.elastic).abs()
1937            };
1938            assert!(
1939                rel_diff < 0.01,
1940                "MLBW/SLBW elastic mismatch at {e} eV: mlbw={} slbw={} (rel_diff={rel_diff})",
1941                xs_mlbw.elastic,
1942                xs_slbw.elastic,
1943            );
1944        }
1945
1946        // Sanity: peak capture at resonance energy should be large.
1947        let xs_peak = cross_sections_at_energy(&data_mlbw, 6.674);
1948        assert!(
1949            xs_peak.capture > 1000.0,
1950            "MLBW capture at 6.674 eV should be substantial (got {})",
1951            xs_peak.capture
1952        );
1953    }
1954
1955    /// Singular Y-matrix guard: when the R-matrix contribution nearly
1956    /// cancels the L⁻¹ diagonal at a resonance energy, Y ≈ 0 and
1957    /// Y⁻¹ diverges.  The cross-sections must remain finite and
1958    /// non-negative (no NaN or Inf propagation).
1959    ///
1960    /// We construct a scenario where evaluation occurs exactly at E_r,
1961    /// maximizing the R-matrix contribution.  With an extremely narrow
1962    /// resonance (Γ_γ = 1e-15 eV), the imaginary denominator is tiny
1963    /// and the R-matrix peak is enormous, stressing the Y inversion.
1964    #[test]
1965    fn test_reich_moore_singular_y_matrix_guard() {
1966        // Extremely narrow resonance: Γ_γ = 1e-15 eV forces R-matrix
1967        // contribution to be enormous at E = E_r, pushing Y toward
1968        // singularity and exercising the y_denom < LOG_FLOOR guard.
1969        let data = make_single_resonance_data(
1970            10.0,    // E_r
1971            1.0e-3,  // Γ_n (eV)
1972            1.0e-15, // Γ_γ (extremely small → near-singular Y)
1973            0.5,     // J
1974            0,       // L
1975            236.006, // AWR
1976            0.0,     // target spin
1977            9.4285,  // radius
1978        );
1979
1980        // Evaluate exactly at E_r where R is maximized.
1981        let xs = cross_sections_at_energy(&data, 10.0);
1982        assert!(
1983            xs.total.is_finite() && xs.total >= 0.0,
1984            "Total must be finite and non-negative at resonance peak, got {}",
1985            xs.total
1986        );
1987        assert!(
1988            xs.elastic.is_finite() && xs.elastic >= 0.0,
1989            "Elastic must be finite and non-negative, got {}",
1990            xs.elastic
1991        );
1992        assert!(
1993            xs.capture.is_finite(),
1994            "Capture must be finite, got {}",
1995            xs.capture
1996        );
1997    }
1998
1999    /// Zero capture width definitively triggers the y_denom guard:
2000    /// with Γ_γ = 0, the R-matrix denominator at E = E_r is zero,
2001    /// making R infinite.  The singularity guard must return zeros
2002    /// rather than NaN/Inf.
2003    #[test]
2004    fn test_reich_moore_zero_capture_width_guard() {
2005        let data = make_single_resonance_data(
2006            10.0,    // E_r
2007            1.0e-3,  // Γ_n (eV)
2008            0.0,     // Γ_γ = 0 → guaranteed singularity
2009            0.5,     // J
2010            0,       // L
2011            236.006, // AWR
2012            0.0,     // target spin
2013            9.4285,  // radius
2014        );
2015
2016        // At E = E_r with Γ_γ = 0, the denominator (E_r - E)² + (Γ_γ/2)² = 0,
2017        // so the DIVISION_FLOOR guard on the R-matrix denom fires, but even if
2018        // it didn't, the y_denom guard would catch it downstream.
2019        let xs = cross_sections_at_energy(&data, 10.0);
2020        assert!(
2021            xs.total.is_finite() && xs.total >= 0.0,
2022            "Total must be finite and non-negative with zero capture width, got {}",
2023            xs.total
2024        );
2025        assert!(
2026            xs.elastic.is_finite() && xs.elastic >= 0.0,
2027            "Elastic must be finite and non-negative, got {}",
2028            xs.elastic
2029        );
2030        // With Γ_γ = 0 the true capture is exactly zero, but floating-point
2031        // arithmetic at this singularity can produce a tiny negative value
2032        // (machine-epsilon level).  Accept values > -1e-10 barns.
2033        assert!(
2034            xs.capture.is_finite() && xs.capture > -1e-10,
2035            "Capture must be finite and nearly non-negative, got {}",
2036            xs.capture
2037        );
2038    }
2039
2040    /// Reich-Moore 2-channel (fission) with a singular det guard:
2041    /// when both fission and neutron widths are tiny, the 2x2 Y-matrix
2042    /// determinant can be near zero.  Results must be finite.
2043    #[test]
2044    fn test_reich_moore_fission_near_singular() {
2045        let data = ResonanceData {
2046            isotope: nereids_core::types::Isotope::new(94, 239).unwrap(),
2047            za: 94239,
2048            awr: 236.998,
2049            ranges: vec![ResonanceRange {
2050                energy_low: 1e-5,
2051                energy_high: 1e4,
2052                resolved: true,
2053                formalism: ResonanceFormalism::ReichMoore,
2054                target_spin: 0.5,
2055                scattering_radius: 9.41,
2056                naps: 1,
2057                l_groups: vec![LGroup {
2058                    l: 0,
2059                    awr: 236.998,
2060                    apl: 0.0,
2061                    qx: 0.0,
2062                    lrx: 0,
2063                    resonances: vec![Resonance {
2064                        energy: 10.0,
2065                        j: 1.0,
2066                        gn: 1.0e-8,  // very small neutron width
2067                        gg: 1.0e-8,  // very small capture width
2068                        gfa: 1.0e-8, // very small fission width
2069                        gfb: 0.0,
2070                    }],
2071                }],
2072                rml: None,
2073                ap_table: None,
2074                urr: None,
2075                r_external: vec![],
2076            }],
2077        };
2078
2079        // Evaluate at the resonance energy.
2080        let xs = cross_sections_at_energy(&data, 10.0);
2081        assert!(
2082            xs.total.is_finite() && xs.total >= 0.0,
2083            "Total must be finite, got {}",
2084            xs.total
2085        );
2086        assert!(
2087            xs.fission.is_finite() && xs.fission >= 0.0,
2088            "Fission must be finite and non-negative, got {}",
2089            xs.fission
2090        );
2091    }
2092
2093    /// `cross_sections_on_grid` (batch, precompute hoisted) must produce
2094    /// identical results to `cross_sections_at_energy` (per-point) for
2095    /// Reich-Moore data.
2096    #[test]
2097    fn test_grid_matches_per_point_reich_moore() {
2098        let data =
2099            make_single_resonance_data(6.674, 1.493e-3, 23.0e-3, 0.5, 0, 236.006, 0.0, 9.4285);
2100
2101        let energies = [0.01, 0.1, 1.0, 5.0, 6.0, 6.674, 7.0, 10.0, 100.0, 1000.0];
2102        let grid_results = cross_sections_on_grid(&data, &energies);
2103
2104        for (i, &e) in energies.iter().enumerate() {
2105            let point = cross_sections_at_energy(&data, e);
2106            let grid = &grid_results[i];
2107            let eps = 1e-12;
2108            assert!(
2109                (point.total - grid.total).abs() < eps,
2110                "total mismatch at E={e}: per_point={} grid={}",
2111                point.total,
2112                grid.total
2113            );
2114            assert!(
2115                (point.elastic - grid.elastic).abs() < eps,
2116                "elastic mismatch at E={e}: per_point={} grid={}",
2117                point.elastic,
2118                grid.elastic
2119            );
2120            assert!(
2121                (point.capture - grid.capture).abs() < eps,
2122                "capture mismatch at E={e}: per_point={} grid={}",
2123                point.capture,
2124                grid.capture
2125            );
2126            assert!(
2127                (point.fission - grid.fission).abs() < eps,
2128                "fission mismatch at E={e}: per_point={} grid={}",
2129                point.fission,
2130                grid.fission
2131            );
2132        }
2133    }
2134
2135    /// `cross_sections_on_grid` must match `cross_sections_at_energy` for
2136    /// SLBW-formalism data too (the batch path precomputes SLBW J-groups).
2137    #[test]
2138    fn test_grid_matches_per_point_slbw() {
2139        let data = make_slbw_data(ResonanceFormalism::SLBW);
2140
2141        let energies = [0.1, 1.0, 5.0, 6.0, 6.674, 7.0, 10.0, 100.0];
2142        let grid_results = cross_sections_on_grid(&data, &energies);
2143
2144        for (i, &e) in energies.iter().enumerate() {
2145            let point = cross_sections_at_energy(&data, e);
2146            let grid = &grid_results[i];
2147            let eps = 1e-12;
2148            assert!(
2149                (point.total - grid.total).abs() < eps,
2150                "total mismatch at E={e}: per_point={} grid={}",
2151                point.total,
2152                grid.total
2153            );
2154            assert!(
2155                (point.capture - grid.capture).abs() < eps,
2156                "capture mismatch at E={e}: per_point={} grid={}",
2157                point.capture,
2158                grid.capture
2159            );
2160        }
2161    }
2162
2163    /// `cross_sections_on_grid` must match per-point for the full U-238
2164    /// ENDF file (many resonances, L-groups, J-groups).
2165    #[test]
2166    fn test_grid_matches_per_point_u238_full() {
2167        let endf_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2168            .parent()
2169            .unwrap()
2170            .parent()
2171            .unwrap()
2172            .join("../SAMMY/SAMMY/samexm_new/ex027_new/ex027.endf");
2173
2174        if !endf_path.exists() {
2175            println!("Skipping: SAMMY checkout not found at {:?}", endf_path);
2176            return;
2177        }
2178
2179        let endf_text = std::fs::read_to_string(&endf_path).unwrap();
2180        let data = nereids_endf::parser::parse_endf_file2(&endf_text).unwrap();
2181
2182        let energies: Vec<f64> = (0..100).map(|i| 1.0 + i as f64 * 0.5).collect();
2183        let grid_results = cross_sections_on_grid(&data, &energies);
2184
2185        for (i, &e) in energies.iter().enumerate() {
2186            let point = cross_sections_at_energy(&data, e);
2187            let grid = &grid_results[i];
2188            let eps = 1e-10;
2189            assert!(
2190                (point.total - grid.total).abs() < eps * point.total.abs().max(1.0),
2191                "total mismatch at E={e}: per_point={} grid={}",
2192                point.total,
2193                grid.total
2194            );
2195            assert!(
2196                (point.capture - grid.capture).abs() < eps * point.capture.abs().max(1.0),
2197                "capture mismatch at E={e}: per_point={} grid={}",
2198                point.capture,
2199                grid.capture
2200            );
2201        }
2202    }
2203
2204    /// Verify that NAPS=0 uses the channel radius formula for penetrability
2205    /// while still using AP for phase shifts.
2206    ///
2207    /// The NAPS flag controls which radius is used for penetrability
2208    /// and shift factor calculations (ENDF-6 §2.2.1):
2209    /// - NAPS=0: channel radius = (0.123·A^(1/3) + 0.08) × 10  (fm)
2210    /// - NAPS=1: scattering radius AP (or AP(E))
2211    ///
2212    /// Note: for L=0, P_0(rho) = 1 regardless of radius, so NAPS only
2213    /// affects L>=1.  Even for L>=1, the neutron width uses the RATIO
2214    /// P_l(E)/P_l(E_r), so the radius effect largely cancels.  The main
2215    /// observable impact of NAPS is through the penetrability and
2216    /// shift-factor terms (P_l, S_l) for L>=1; the phase shift itself
2217    /// always uses the scattering radius AP regardless of NAPS.
2218    ///
2219    /// This test verifies the code path is wired correctly by checking:
2220    /// 1. NAPS=0 with AP = formula_radius matches NAPS=1 with AP = formula_radius
2221    ///    (confirming the formula gives the expected value)
2222    /// 2. NAPS=0 with a different AP still produces valid XS (no NaN/panic)
2223    #[test]
2224    fn test_naps_zero_uses_channel_radius_formula() {
2225        let awr: f64 = 55.345; // Fe-56-like
2226        let formula_radius = channel::endf_channel_radius_fm(awr);
2227
2228        // NAPS=0: penetrability uses formula, phase shift uses AP (= formula here)
2229        let data_naps0 = ResonanceData {
2230            isotope: nereids_core::types::Isotope::new(26, 56).unwrap(),
2231            za: 26056,
2232            awr,
2233            ranges: vec![ResonanceRange {
2234                energy_low: 1e-5,
2235                energy_high: 1e5,
2236                resolved: true,
2237                formalism: ResonanceFormalism::ReichMoore,
2238                target_spin: 0.0,
2239                scattering_radius: formula_radius, // AP = formula → same as pen_radius
2240                naps: 0,
2241                l_groups: vec![LGroup {
2242                    l: 1,
2243                    awr,
2244                    apl: 0.0,
2245                    qx: 0.0,
2246                    lrx: 0,
2247                    resonances: vec![Resonance {
2248                        energy: 30000.0,
2249                        j: 1.5,
2250                        gn: 5.0,
2251                        gg: 1.0,
2252                        gfa: 0.0,
2253                        gfb: 0.0,
2254                    }],
2255                }],
2256                rml: None,
2257                urr: None,
2258                ap_table: None,
2259                r_external: vec![],
2260            }],
2261        };
2262
2263        // NAPS=1 with AP = formula_radius: both penetrability and phase use AP
2264        let data_naps1 = ResonanceData {
2265            isotope: nereids_core::types::Isotope::new(26, 56).unwrap(),
2266            za: 26056,
2267            awr,
2268            ranges: vec![ResonanceRange {
2269                energy_low: 1e-5,
2270                energy_high: 1e5,
2271                resolved: true,
2272                formalism: ResonanceFormalism::ReichMoore,
2273                target_spin: 0.0,
2274                scattering_radius: formula_radius,
2275                naps: 1,
2276                l_groups: vec![LGroup {
2277                    l: 1,
2278                    awr,
2279                    apl: 0.0,
2280                    qx: 0.0,
2281                    lrx: 0,
2282                    resonances: vec![Resonance {
2283                        energy: 30000.0,
2284                        j: 1.5,
2285                        gn: 5.0,
2286                        gg: 1.0,
2287                        gfa: 0.0,
2288                        gfb: 0.0,
2289                    }],
2290                }],
2291                rml: None,
2292                urr: None,
2293                ap_table: None,
2294                r_external: vec![],
2295            }],
2296        };
2297
2298        let e = 30000.0;
2299        let xs_naps0 = cross_sections_at_energy(&data_naps0, e);
2300        let xs_naps1 = cross_sections_at_energy(&data_naps1, e);
2301
2302        // When AP equals the formula radius, NAPS=0 and NAPS=1 should
2303        // give identical results (both use the same radius everywhere).
2304        assert!(
2305            (xs_naps0.total - xs_naps1.total).abs() < 1e-10 * xs_naps1.total.abs().max(1.0),
2306            "NAPS=0 total={} vs NAPS=1 total={}: should match when AP=formula",
2307            xs_naps0.total,
2308            xs_naps1.total,
2309        );
2310        assert!(
2311            (xs_naps0.capture - xs_naps1.capture).abs() < 1e-10 * xs_naps1.capture.abs().max(1.0),
2312            "NAPS=0 capture={} vs NAPS=1 capture={}: should match when AP=formula",
2313            xs_naps0.capture,
2314            xs_naps1.capture,
2315        );
2316
2317        // Verify finite and positive cross-sections (no NaN from formula)
2318        assert!(xs_naps0.total.is_finite() && xs_naps0.total > 0.0);
2319        assert!(xs_naps0.capture.is_finite() && xs_naps0.capture > 0.0);
2320
2321        // Also verify the formula value is sane
2322        assert!(
2323            (formula_radius - 5.49).abs() < 0.1,
2324            "Expected ~5.49 fm for Fe-56-like, got {formula_radius}"
2325        );
2326    }
2327}