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