Skip to main content

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