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