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        assert!(
1516            xs.capture > 15000.0 && xs.capture < 30000.0,
1517            "Capture should be ~22000 barns, got {}",
1518            xs.capture
1519        );
1520        assert!(xs.total > xs.capture, "Total > capture");
1521        assert!(xs.elastic > 0.0, "Elastic should be positive");
1522        assert!(xs.fission.abs() < 1e-10, "No fission for U-238");
1523    }
1524
1525    #[test]
1526    fn test_1_over_v_behavior() {
1527        // Far from resonances, capture cross-section should follow 1/v ∝ 1/√E.
1528        // The 6.674 eV resonance tail should dominate at low energies.
1529        let data = u238_single_resonance();
1530
1531        let xs_01 = cross_sections_at_energy(&data, 0.1);
1532        let xs_04 = cross_sections_at_energy(&data, 0.4);
1533
1534        // At low E, σ ∝ 1/√E, so σ(0.1)/σ(0.4) ≈ √(0.4/0.1) = 2.0
1535        let ratio = xs_01.capture / xs_04.capture;
1536        assert!(
1537            (ratio - 2.0).abs() < 0.3,
1538            "Expected ~2.0 for 1/v behavior, got {}",
1539            ratio
1540        );
1541    }
1542
1543    #[test]
1544    fn test_cross_sections_positive() {
1545        // All cross-sections must be non-negative at all energies.
1546        let data = u238_single_resonance();
1547
1548        for &e in &[0.01, 0.1, 1.0, 5.0, 6.0, 6.674, 7.0, 10.0, 100.0, 1000.0] {
1549            let xs = cross_sections_at_energy(&data, e);
1550            assert!(xs.total >= 0.0, "Total negative at E={}: {}", e, xs.total);
1551            assert!(
1552                xs.elastic >= 0.0,
1553                "Elastic negative at E={}: {}",
1554                e,
1555                xs.elastic
1556            );
1557            assert!(
1558                xs.capture >= -1e-10,
1559                "Capture negative at E={}: {}",
1560                e,
1561                xs.capture
1562            );
1563        }
1564    }
1565
1566    /// Parse the full vendored U-238 ENDF and compute cross-sections.
1567    ///
1568    /// Validates against the SAMMY ex027 case (Doppler-broadened at 300 K),
1569    /// against which we compare unbroadened RM values that should bracket
1570    /// the broadened data. Fixture is shipped under this crate's
1571    /// `tests/data/u238_ex027.endf` (public-domain ENDF/B-VIII.0) so the
1572    /// gate runs even when the crate is built standalone (outside the
1573    /// workspace, where `examples/data/` is not packaged).  The original
1574    /// `examples/data/u238_ex027.endf` is kept for end-user example code.
1575    #[test]
1576    fn test_u238_full_endf_cross_sections() {
1577        let endf_path =
1578            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/u238_ex027.endf");
1579
1580        let endf_text = std::fs::read_to_string(&endf_path)
1581            .unwrap_or_else(|e| panic!("vendored U-238 fixture missing at {endf_path:?}: {e}"));
1582        let data = nereids_endf::parser::parse_endf_file2(&endf_text).unwrap();
1583
1584        // Compute cross-sections at several energies near the 6.674 eV resonance.
1585        let energies = [1.0, 5.0, 6.0, 6.5, 6.674, 7.0, 8.0, 10.0, 20.0, 50.0, 100.0];
1586
1587        for &e in &energies {
1588            let xs = cross_sections_at_energy(&data, e);
1589            // Basic sanity: all cross-sections non-negative.
1590            assert!(xs.total >= 0.0, "Total negative at E={}", e);
1591            assert!(xs.elastic >= 0.0, "Elastic negative at E={}", e);
1592            // Capture can be very slightly negative due to floating point.
1593            assert!(
1594                xs.capture >= -0.01,
1595                "Capture negative at E={}: {}",
1596                e,
1597                xs.capture
1598            );
1599        }
1600
1601        // Check the 6.674 eV resonance peak.
1602        // With the full ENDF file (all resonances), the peak capture
1603        // should still be dominated by the 6.674 eV resonance.
1604        let xs_peak = cross_sections_at_energy(&data, 6.674);
1605        assert!(
1606            xs_peak.capture > 10000.0,
1607            "Capture at 6.674 eV should be >10,000 barns (got {})",
1608            xs_peak.capture
1609        );
1610
1611        // The 20.87 eV resonance should also show a significant peak.
1612        let xs_20 = cross_sections_at_energy(&data, 20.87);
1613        assert!(
1614            xs_20.capture > 1000.0,
1615            "Capture at 20.87 eV should be >1,000 barns (got {})",
1616            xs_20.capture
1617        );
1618
1619        // SAMMY ex027 broadened output at ~6.674 eV gives ~339 barns capture.
1620        // Our UNBROADENED result should be MUCH larger (since Doppler broadening
1621        // spreads the peak). This confirms we're computing the correct physics.
1622        assert!(
1623            xs_peak.capture > 339.0,
1624            "Unbroadened peak must exceed SAMMY broadened value"
1625        );
1626    }
1627
1628    /// `cross_sections_at_energy` with an SLBW-formalism range must give
1629    /// the same result as `slbw::slbw_cross_sections`.
1630    #[test]
1631    fn test_dispatcher_slbw_matches_slbw_module() {
1632        let data = u238_with_formalism(ResonanceFormalism::SLBW);
1633
1634        let test_energies = [0.1, 1.0, 5.0, 6.0, 6.674, 7.0, 10.0, 100.0];
1635        for &e in &test_energies {
1636            let via_dispatcher = cross_sections_at_energy(&data, e);
1637            let via_slbw = crate::slbw::slbw_cross_sections(&data, e);
1638
1639            let eps = 1e-10;
1640            assert!(
1641                (via_dispatcher.total - via_slbw.total).abs() < eps,
1642                "total mismatch at {e} eV: dispatcher={} slbw={}",
1643                via_dispatcher.total,
1644                via_slbw.total
1645            );
1646            assert!(
1647                (via_dispatcher.capture - via_slbw.capture).abs() < eps,
1648                "capture mismatch at {e} eV: dispatcher={} slbw={}",
1649                via_dispatcher.capture,
1650                via_slbw.capture
1651            );
1652            assert!(
1653                (via_dispatcher.elastic - via_slbw.elastic).abs() < eps,
1654                "elastic mismatch at {e} eV: dispatcher={} slbw={}",
1655                via_dispatcher.elastic,
1656                via_slbw.elastic
1657            );
1658        }
1659    }
1660
1661    /// For a **single isolated resonance**, MLBW and SLBW should produce
1662    /// identical results because the interference term (cross-resonance)
1663    /// vanishes when there is only one resonance per spin group.
1664    ///
1665    /// Capture and fission are always identical (interference only
1666    /// affects elastic).  Elastic should also match for single resonances.
1667    #[test]
1668    fn test_mlbw_single_resonance_matches_slbw() {
1669        let data_mlbw = u238_with_formalism(ResonanceFormalism::MLBW);
1670        let data_slbw = u238_with_formalism(ResonanceFormalism::SLBW);
1671
1672        let test_energies = [1.0, 6.674, 10.0];
1673        for &e in &test_energies {
1674            let xs_mlbw = cross_sections_at_energy(&data_mlbw, e);
1675            let xs_slbw = cross_sections_at_energy(&data_slbw, e);
1676
1677            // Capture and fission must be identical.
1678            assert!(
1679                (xs_mlbw.capture - xs_slbw.capture).abs() < 1e-10,
1680                "MLBW/SLBW capture mismatch at {e} eV: mlbw={} slbw={}",
1681                xs_mlbw.capture,
1682                xs_slbw.capture
1683            );
1684
1685            // Elastic: for single resonance, MLBW formula should reduce
1686            // to SLBW because there are no cross-terms.
1687            let rel_diff = if xs_slbw.elastic.abs() > 1e-10 {
1688                (xs_mlbw.elastic - xs_slbw.elastic).abs() / xs_slbw.elastic
1689            } else {
1690                (xs_mlbw.elastic - xs_slbw.elastic).abs()
1691            };
1692            assert!(
1693                rel_diff < 0.01,
1694                "MLBW/SLBW elastic mismatch at {e} eV: mlbw={} slbw={} (rel_diff={rel_diff})",
1695                xs_mlbw.elastic,
1696                xs_slbw.elastic,
1697            );
1698        }
1699
1700        // Sanity: peak capture at resonance energy should be large.
1701        let xs_peak = cross_sections_at_energy(&data_mlbw, 6.674);
1702        assert!(
1703            xs_peak.capture > 1000.0,
1704            "MLBW capture at 6.674 eV should be substantial (got {})",
1705            xs_peak.capture
1706        );
1707    }
1708
1709    /// Singular Y-matrix guard: when the R-matrix contribution nearly
1710    /// cancels the L⁻¹ diagonal at a resonance energy, Y ≈ 0 and
1711    /// Y⁻¹ diverges.  The cross-sections must remain finite and
1712    /// non-negative (no NaN or Inf propagation).
1713    ///
1714    /// We construct a scenario where evaluation occurs exactly at E_r,
1715    /// maximizing the R-matrix contribution.  With an extremely narrow
1716    /// resonance (Γ_γ = 1e-15 eV), the imaginary denominator is tiny
1717    /// and the R-matrix peak is enormous, stressing the Y inversion.
1718    #[test]
1719    fn test_reich_moore_singular_y_matrix_guard() {
1720        // Extremely narrow resonance: Γ_γ = 1e-15 eV forces R-matrix
1721        // contribution to be enormous at E = E_r, pushing Y toward
1722        // singularity and exercising the y_denom < LOG_FLOOR guard.
1723        let data = single_resonance(SingleResonanceParams {
1724            energy: 10.0,     // E_r
1725            gamma_n: 1.0e-3,  // Γ_n (eV)
1726            gamma_g: 1.0e-15, // Γ_γ (extremely small → near-singular Y)
1727            j: 0.5,
1728            l: 0,
1729            awr: 236.006,
1730            target_spin: 0.0,
1731            scattering_radius: 9.4285,
1732        });
1733
1734        // Evaluate exactly at E_r where R is maximized.
1735        let xs = cross_sections_at_energy(&data, 10.0);
1736        assert!(
1737            xs.total.is_finite() && xs.total >= 0.0,
1738            "Total must be finite and non-negative at resonance peak, got {}",
1739            xs.total
1740        );
1741        assert!(
1742            xs.elastic.is_finite() && xs.elastic >= 0.0,
1743            "Elastic must be finite and non-negative, got {}",
1744            xs.elastic
1745        );
1746        assert!(
1747            xs.capture.is_finite(),
1748            "Capture must be finite, got {}",
1749            xs.capture
1750        );
1751    }
1752
1753    /// Zero capture width definitively triggers the y_denom guard:
1754    /// with Γ_γ = 0, the R-matrix denominator at E = E_r is zero,
1755    /// making R infinite.  The singularity guard must return zeros
1756    /// rather than NaN/Inf.
1757    #[test]
1758    fn test_reich_moore_zero_capture_width_guard() {
1759        let data = single_resonance(SingleResonanceParams {
1760            energy: 10.0,    // E_r
1761            gamma_n: 1.0e-3, // Γ_n (eV)
1762            gamma_g: 0.0,    // Γ_γ = 0 → guaranteed singularity
1763            j: 0.5,
1764            l: 0,
1765            awr: 236.006,
1766            target_spin: 0.0,
1767            scattering_radius: 9.4285,
1768        });
1769
1770        // At E = E_r with Γ_γ = 0, the denominator (E_r - E)² + (Γ_γ/2)² = 0,
1771        // so the DIVISION_FLOOR guard on the R-matrix denom fires, but even if
1772        // it didn't, the y_denom guard would catch it downstream.
1773        let xs = cross_sections_at_energy(&data, 10.0);
1774        assert!(
1775            xs.total.is_finite() && xs.total >= 0.0,
1776            "Total must be finite and non-negative with zero capture width, got {}",
1777            xs.total
1778        );
1779        assert!(
1780            xs.elastic.is_finite() && xs.elastic >= 0.0,
1781            "Elastic must be finite and non-negative, got {}",
1782            xs.elastic
1783        );
1784        // With Γ_γ = 0 the true capture is exactly zero, but floating-point
1785        // arithmetic at this singularity can produce a tiny negative value
1786        // (machine-epsilon level).  Accept values > -1e-10 barns.
1787        assert!(
1788            xs.capture.is_finite() && xs.capture > -1e-10,
1789            "Capture must be finite and nearly non-negative, got {}",
1790            xs.capture
1791        );
1792    }
1793
1794    /// Reich-Moore 2-channel (fission) with a singular det guard:
1795    /// when both fission and neutron widths are tiny, the 2x2 Y-matrix
1796    /// determinant can be near zero.  Results must be finite.
1797    #[test]
1798    fn test_reich_moore_fission_near_singular() {
1799        let data = ResonanceData {
1800            isotope: nereids_core::types::Isotope::new(94, 239).unwrap(),
1801            za: 94239,
1802            awr: 236.998,
1803            ranges: vec![ResonanceRange {
1804                energy_low: 1e-5,
1805                energy_high: 1e4,
1806                resolved: true,
1807                formalism: ResonanceFormalism::ReichMoore,
1808                target_spin: 0.5,
1809                scattering_radius: 9.41,
1810                naps: 1,
1811                l_groups: vec![LGroup {
1812                    l: 0,
1813                    awr: 236.998,
1814                    apl: 0.0,
1815                    qx: 0.0,
1816                    lrx: 0,
1817                    resonances: vec![Resonance {
1818                        energy: 10.0,
1819                        j: 1.0,
1820                        gn: 1.0e-8,  // very small neutron width
1821                        gg: 1.0e-8,  // very small capture width
1822                        gfa: 1.0e-8, // very small fission width
1823                        gfb: 0.0,
1824                    }],
1825                }],
1826                rml: None,
1827                ap_table: None,
1828                urr: None,
1829                r_external: vec![],
1830            }],
1831        };
1832
1833        // Evaluate at the resonance energy.
1834        let xs = cross_sections_at_energy(&data, 10.0);
1835        assert!(
1836            xs.total.is_finite() && xs.total >= 0.0,
1837            "Total must be finite, got {}",
1838            xs.total
1839        );
1840        assert!(
1841            xs.fission.is_finite() && xs.fission >= 0.0,
1842            "Fission must be finite and non-negative, got {}",
1843            xs.fission
1844        );
1845    }
1846
1847    /// `cross_sections_on_grid` (batch, precompute hoisted) must produce
1848    /// identical results to `cross_sections_at_energy` (per-point) for
1849    /// Reich-Moore data.
1850    #[test]
1851    fn test_grid_matches_per_point_reich_moore() {
1852        let data = u238_single_resonance();
1853
1854        let energies = [0.01, 0.1, 1.0, 5.0, 6.0, 6.674, 7.0, 10.0, 100.0, 1000.0];
1855        let grid_results = cross_sections_on_grid(&data, &energies);
1856
1857        for (i, &e) in energies.iter().enumerate() {
1858            let point = cross_sections_at_energy(&data, e);
1859            let grid = &grid_results[i];
1860            let eps = 1e-12;
1861            assert!(
1862                (point.total - grid.total).abs() < eps,
1863                "total mismatch at E={e}: per_point={} grid={}",
1864                point.total,
1865                grid.total
1866            );
1867            assert!(
1868                (point.elastic - grid.elastic).abs() < eps,
1869                "elastic mismatch at E={e}: per_point={} grid={}",
1870                point.elastic,
1871                grid.elastic
1872            );
1873            assert!(
1874                (point.capture - grid.capture).abs() < eps,
1875                "capture mismatch at E={e}: per_point={} grid={}",
1876                point.capture,
1877                grid.capture
1878            );
1879            assert!(
1880                (point.fission - grid.fission).abs() < eps,
1881                "fission mismatch at E={e}: per_point={} grid={}",
1882                point.fission,
1883                grid.fission
1884            );
1885        }
1886    }
1887
1888    /// `cross_sections_on_grid` must match `cross_sections_at_energy` for
1889    /// SLBW-formalism data too (the batch path precomputes SLBW J-groups).
1890    #[test]
1891    fn test_grid_matches_per_point_slbw() {
1892        let data = u238_with_formalism(ResonanceFormalism::SLBW);
1893
1894        let energies = [0.1, 1.0, 5.0, 6.0, 6.674, 7.0, 10.0, 100.0];
1895        let grid_results = cross_sections_on_grid(&data, &energies);
1896
1897        for (i, &e) in energies.iter().enumerate() {
1898            let point = cross_sections_at_energy(&data, e);
1899            let grid = &grid_results[i];
1900            let eps = 1e-12;
1901            assert!(
1902                (point.total - grid.total).abs() < eps,
1903                "total mismatch at E={e}: per_point={} grid={}",
1904                point.total,
1905                grid.total
1906            );
1907            assert!(
1908                (point.capture - grid.capture).abs() < eps,
1909                "capture mismatch at E={e}: per_point={} grid={}",
1910                point.capture,
1911                grid.capture
1912            );
1913        }
1914    }
1915
1916    /// `cross_sections_on_grid` must match per-point for the full U-238
1917    /// ENDF file (many resonances, L-groups, J-groups). Fixture is the
1918    /// crate-local `tests/data/u238_ex027.endf`, so the gate runs
1919    /// unconditionally on CI even when the crate is built standalone
1920    /// (outside the workspace, where `examples/data/` is not packaged).
1921    #[test]
1922    fn test_grid_matches_per_point_u238_full() {
1923        let endf_path =
1924            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/u238_ex027.endf");
1925
1926        let endf_text = std::fs::read_to_string(&endf_path)
1927            .unwrap_or_else(|e| panic!("vendored U-238 fixture missing at {endf_path:?}: {e}"));
1928        let data = nereids_endf::parser::parse_endf_file2(&endf_text).unwrap();
1929
1930        let energies: Vec<f64> = (0..100).map(|i| 1.0 + i as f64 * 0.5).collect();
1931        let grid_results = cross_sections_on_grid(&data, &energies);
1932
1933        for (i, &e) in energies.iter().enumerate() {
1934            let point = cross_sections_at_energy(&data, e);
1935            let grid = &grid_results[i];
1936            let eps = 1e-10;
1937            assert!(
1938                (point.total - grid.total).abs() < eps * point.total.abs().max(1.0),
1939                "total mismatch at E={e}: per_point={} grid={}",
1940                point.total,
1941                grid.total
1942            );
1943            assert!(
1944                (point.capture - grid.capture).abs() < eps * point.capture.abs().max(1.0),
1945                "capture mismatch at E={e}: per_point={} grid={}",
1946                point.capture,
1947                grid.capture
1948            );
1949        }
1950    }
1951
1952    /// Verify that NAPS=0 uses the channel radius formula for penetrability
1953    /// while still using AP for phase shifts.
1954    ///
1955    /// The NAPS flag controls which radius is used for penetrability
1956    /// and shift factor calculations (ENDF-6 §2.2.1):
1957    /// - NAPS=0: channel radius = (0.123·A^(1/3) + 0.08) × 10  (fm)
1958    /// - NAPS=1: scattering radius AP (or AP(E))
1959    ///
1960    /// Note: for L=0, P_0(rho) = 1 regardless of radius, so NAPS only
1961    /// affects L>=1.  Even for L>=1, the neutron width uses the RATIO
1962    /// P_l(E)/P_l(E_r), so the radius effect largely cancels.  The main
1963    /// observable impact of NAPS is through the penetrability and
1964    /// shift-factor terms (P_l, S_l) for L>=1; the phase shift itself
1965    /// always uses the scattering radius AP regardless of NAPS.
1966    ///
1967    /// This test verifies the code path is wired correctly by checking:
1968    /// 1. NAPS=0 with AP = formula_radius matches NAPS=1 with AP = formula_radius
1969    ///    (confirming the formula gives the expected value)
1970    /// 2. NAPS=0 with a different AP still produces valid XS (no NaN/panic)
1971    #[test]
1972    fn test_naps_zero_uses_channel_radius_formula() {
1973        let awr: f64 = 55.345; // Fe-56-like
1974        let formula_radius = channel::endf_channel_radius_fm(awr);
1975
1976        // NAPS=0: penetrability uses formula, phase shift uses AP (= formula here)
1977        let data_naps0 = ResonanceData {
1978            isotope: nereids_core::types::Isotope::new(26, 56).unwrap(),
1979            za: 26056,
1980            awr,
1981            ranges: vec![ResonanceRange {
1982                energy_low: 1e-5,
1983                energy_high: 1e5,
1984                resolved: true,
1985                formalism: ResonanceFormalism::ReichMoore,
1986                target_spin: 0.0,
1987                scattering_radius: formula_radius, // AP = formula → same as pen_radius
1988                naps: 0,
1989                l_groups: vec![LGroup {
1990                    l: 1,
1991                    awr,
1992                    apl: 0.0,
1993                    qx: 0.0,
1994                    lrx: 0,
1995                    resonances: vec![Resonance {
1996                        energy: 30000.0,
1997                        j: 1.5,
1998                        gn: 5.0,
1999                        gg: 1.0,
2000                        gfa: 0.0,
2001                        gfb: 0.0,
2002                    }],
2003                }],
2004                rml: None,
2005                urr: None,
2006                ap_table: None,
2007                r_external: vec![],
2008            }],
2009        };
2010
2011        // NAPS=1 with AP = formula_radius: both penetrability and phase use AP
2012        let data_naps1 = ResonanceData {
2013            isotope: nereids_core::types::Isotope::new(26, 56).unwrap(),
2014            za: 26056,
2015            awr,
2016            ranges: vec![ResonanceRange {
2017                energy_low: 1e-5,
2018                energy_high: 1e5,
2019                resolved: true,
2020                formalism: ResonanceFormalism::ReichMoore,
2021                target_spin: 0.0,
2022                scattering_radius: formula_radius,
2023                naps: 1,
2024                l_groups: vec![LGroup {
2025                    l: 1,
2026                    awr,
2027                    apl: 0.0,
2028                    qx: 0.0,
2029                    lrx: 0,
2030                    resonances: vec![Resonance {
2031                        energy: 30000.0,
2032                        j: 1.5,
2033                        gn: 5.0,
2034                        gg: 1.0,
2035                        gfa: 0.0,
2036                        gfb: 0.0,
2037                    }],
2038                }],
2039                rml: None,
2040                urr: None,
2041                ap_table: None,
2042                r_external: vec![],
2043            }],
2044        };
2045
2046        let e = 30000.0;
2047        let xs_naps0 = cross_sections_at_energy(&data_naps0, e);
2048        let xs_naps1 = cross_sections_at_energy(&data_naps1, e);
2049
2050        // When AP equals the formula radius, NAPS=0 and NAPS=1 should
2051        // give identical results (both use the same radius everywhere).
2052        assert!(
2053            (xs_naps0.total - xs_naps1.total).abs() < 1e-10 * xs_naps1.total.abs().max(1.0),
2054            "NAPS=0 total={} vs NAPS=1 total={}: should match when AP=formula",
2055            xs_naps0.total,
2056            xs_naps1.total,
2057        );
2058        assert!(
2059            (xs_naps0.capture - xs_naps1.capture).abs() < 1e-10 * xs_naps1.capture.abs().max(1.0),
2060            "NAPS=0 capture={} vs NAPS=1 capture={}: should match when AP=formula",
2061            xs_naps0.capture,
2062            xs_naps1.capture,
2063        );
2064
2065        // Verify finite and positive cross-sections (no NaN from formula)
2066        assert!(xs_naps0.total.is_finite() && xs_naps0.total > 0.0);
2067        assert!(xs_naps0.capture.is_finite() && xs_naps0.capture > 0.0);
2068
2069        // Also verify the formula value is sane
2070        assert!(
2071            (formula_radius - 5.49).abs() < 0.1,
2072            "Expected ~5.49 fm for Fe-56-like, got {formula_radius}"
2073        );
2074    }
2075
2076    // ─── Issue #465: batch vs per-point equivalence across formalisms ────────
2077    //
2078    // These tests lock the contract that `cross_sections_on_grid` must
2079    // produce element-wise bit-exact output matching
2080    // `cross_sections_at_energy` on the same grid.  Two paths diverging
2081    // silently is the class of bug that #465 exposed — the batch path was
2082    // routing MLBW ranges through the SLBW incoherent-sum evaluator,
2083    // producing up to 55 % relative error on real Hf isotopes — so this
2084    // harness must cover every formalism that has a distinct evaluator.
2085    //
2086    // The synthetic MLBW test below uses the smallest configuration that
2087    // exercises the coherent-vs-incoherent divergence (a single J-group
2088    // with ≥2 resonances).  The Hf-177 test provides a real-world anchor
2089    // against ENDF-B-VIII.1 data committed under `tests/data/endf/`.
2090
2091    /// Synthetic MLBW fixture: the batch API and per-point API MUST agree
2092    /// bit-exactly.  This is the root cause of #465 — the batch
2093    /// dispatcher lumped MLBW into the SLBW evaluator (incoherent sum)
2094    /// while the per-point dispatcher correctly routed through the
2095    /// coherent-sum MLBW evaluator.  Both paths now share
2096    /// `slbw::mlbw_evaluate_with_cached_jgroups`.
2097    ///
2098    /// Uses the Hf-177 high-J fixture from `test_support`: two s-wave
2099    /// resonances at 2.386 eV and 5.89 eV in the same J = 4.0 group
2100    /// (`hf177_mlbw_two_resonances_high_j`).
2101    #[test]
2102    fn test_batch_matches_per_point_mlbw_synthetic() {
2103        let data = nereids_endf::resonance::test_support::hf177_mlbw_two_resonances_high_j();
2104        // Sample densely across the 2.386 eV and 5.89 eV resonances and
2105        // their interference region; also cover tail behaviour.
2106        let energies: Vec<f64> = (0..101).map(|i| 0.5 + (i as f64) * 0.1).collect();
2107
2108        let per_point: Vec<CrossSections> = energies
2109            .iter()
2110            .map(|&e| cross_sections_at_energy(&data, e))
2111            .collect();
2112        let batch = cross_sections_on_grid(&data, &energies);
2113
2114        assert_eq!(per_point.len(), batch.len());
2115        for (i, (pp, b)) in per_point.iter().zip(batch.iter()).enumerate() {
2116            // Bit-exact equality — same math, same inputs, same
2117            // accumulation order, so f64::to_bits() must match.
2118            assert_eq!(
2119                pp.total.to_bits(),
2120                b.total.to_bits(),
2121                "total mismatch at E[{i}]={}: per_point={:.17e}  batch={:.17e}  rel_diff={:.3e}",
2122                energies[i],
2123                pp.total,
2124                b.total,
2125                if pp.total != 0.0 {
2126                    (pp.total - b.total).abs() / pp.total.abs()
2127                } else {
2128                    (pp.total - b.total).abs()
2129                },
2130            );
2131            assert_eq!(
2132                pp.elastic.to_bits(),
2133                b.elastic.to_bits(),
2134                "elastic mismatch at E[{i}]={}: per_point={:.17e}  batch={:.17e}",
2135                energies[i],
2136                pp.elastic,
2137                b.elastic,
2138            );
2139            assert_eq!(pp.capture.to_bits(), b.capture.to_bits());
2140            assert_eq!(pp.fission.to_bits(), b.fission.to_bits());
2141        }
2142    }
2143
2144    /// Real-world anchor: ENDF-B-VIII.1 Hf-177 is MLBW (LRF=2) with 180
2145    /// resonances.  Locks the #465 production symptom (up to 55 %
2146    /// relative divergence on the VENUS analysis grid).
2147    ///
2148    /// The ENDF file is committed under `tests/data/endf/Hf-177.endf`
2149    /// at the workspace root (~2.8 MB, public domain, see the README
2150    /// there).  The fixture lives outside `crates/nereids-physics/` so
2151    /// it is not included by `cargo package` when this crate is
2152    /// published in isolation.  When the fixture is absent the test
2153    /// skips with a note rather than panicking, so standalone crate
2154    /// checkouts still see a clean `cargo test` run.  In the full
2155    /// workspace (where the fixture is always present) the gate runs
2156    /// as normal.
2157    #[test]
2158    fn test_batch_matches_per_point_hf177_real_endf() {
2159        use nereids_endf::parser::parse_endf_file2;
2160        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2161            .parent()
2162            .unwrap()
2163            .parent()
2164            .unwrap()
2165            .join("tests/data/endf/Hf-177.endf");
2166        let text = match std::fs::read_to_string(&path) {
2167            Ok(t) => t,
2168            Err(e) => {
2169                eprintln!(
2170                    "skipping test_batch_matches_per_point_hf177_real_endf: \
2171                     fixture not available at {path:?}: {e}. \
2172                     Run from the full NEREIDS workspace to exercise this regression gate."
2173                );
2174                return;
2175            }
2176        };
2177        let data = parse_endf_file2(&text).unwrap();
2178
2179        // 500 points spanning the resolved MLBW range (up to 250 eV).
2180        // Focuses coverage on the analysis-relevant VENUS grid segment.
2181        let n = 500;
2182        let energies: Vec<f64> = (0..n)
2183            .map(|i| 0.5 + (i as f64) * ((200.0 - 0.5) / (n - 1) as f64))
2184            .collect();
2185
2186        let per_point: Vec<f64> = energies
2187            .iter()
2188            .map(|&e| cross_sections_at_energy(&data, e).total)
2189            .collect();
2190        let batch: Vec<f64> = cross_sections_on_grid(&data, &energies)
2191            .into_iter()
2192            .map(|cs| cs.total)
2193            .collect();
2194
2195        // On MLBW data the legacy batch path differs by up to 55 %.  The
2196        // fix must bring this to bit-exact.  To keep the assertion
2197        // focused (and avoid cascade failures that hide other issues),
2198        // count mismatches and report the worst offender before asserting.
2199        let mut max_rel = 0.0f64;
2200        let mut worst_idx = 0usize;
2201        let mut mismatches = 0usize;
2202        for (i, (&a, &b)) in per_point.iter().zip(batch.iter()).enumerate() {
2203            if a.to_bits() != b.to_bits() {
2204                mismatches += 1;
2205            }
2206            let rel = if a != 0.0 {
2207                (a - b).abs() / a.abs()
2208            } else {
2209                (a - b).abs()
2210            };
2211            if rel > max_rel {
2212                max_rel = rel;
2213                worst_idx = i;
2214            }
2215        }
2216        assert_eq!(
2217            mismatches, 0,
2218            "{mismatches}/{n} points differ; worst at E[{worst_idx}]={} — \
2219             per_point={:.6e}  batch={:.6e}  rel_diff={max_rel:.3e}",
2220            energies[worst_idx], per_point[worst_idx], batch[worst_idx],
2221        );
2222    }
2223
2224    // ─── Top-level pub-fn energy-validation guards ─────────────────────────
2225    //
2226    // Mirrors the SLBW / RML / URR test patterns: NaN, ±Inf, 0, and -1 must
2227    // each panic with the canonical "expected positive finite energy_ev"
2228    // message.  These tests gate the symmetric defense-in-depth contract on
2229    // both Reich-Moore top-level pub fns so a future refactor cannot
2230    // silently drop the assert.
2231
2232    #[test]
2233    #[should_panic(expected = "expected positive finite energy_ev")]
2234    fn cross_sections_at_energy_panics_on_nan() {
2235        let data = u238_single_resonance();
2236        let _ = cross_sections_at_energy(&data, f64::NAN);
2237    }
2238
2239    #[test]
2240    #[should_panic(expected = "expected positive finite energy_ev")]
2241    fn cross_sections_at_energy_panics_on_infinity() {
2242        let data = u238_single_resonance();
2243        let _ = cross_sections_at_energy(&data, f64::INFINITY);
2244    }
2245
2246    #[test]
2247    #[should_panic(expected = "expected positive finite energy_ev")]
2248    fn cross_sections_at_energy_panics_on_zero() {
2249        let data = u238_single_resonance();
2250        let _ = cross_sections_at_energy(&data, 0.0);
2251    }
2252
2253    #[test]
2254    #[should_panic(expected = "expected positive finite energy_ev")]
2255    fn cross_sections_at_energy_panics_on_negative() {
2256        let data = u238_single_resonance();
2257        let _ = cross_sections_at_energy(&data, -1.0);
2258    }
2259
2260    #[test]
2261    #[should_panic(expected = "expected positive finite energy_ev")]
2262    fn cross_sections_on_grid_panics_on_nan() {
2263        let data = u238_single_resonance();
2264        let _ = cross_sections_on_grid(&data, &[1.0, f64::NAN, 2.0]);
2265    }
2266
2267    #[test]
2268    #[should_panic(expected = "expected positive finite energy_ev")]
2269    fn cross_sections_on_grid_panics_on_infinity() {
2270        let data = u238_single_resonance();
2271        let _ = cross_sections_on_grid(&data, &[1.0, f64::INFINITY]);
2272    }
2273
2274    #[test]
2275    #[should_panic(expected = "expected positive finite energy_ev")]
2276    fn cross_sections_on_grid_panics_on_zero() {
2277        let data = u238_single_resonance();
2278        let _ = cross_sections_on_grid(&data, &[1.0, 0.0, 2.0]);
2279    }
2280
2281    #[test]
2282    #[should_panic(expected = "expected positive finite energy_ev")]
2283    fn cross_sections_on_grid_panics_on_negative() {
2284        let data = u238_single_resonance();
2285        let _ = cross_sections_on_grid(&data, &[-1.0, 1.0, 2.0]);
2286    }
2287}