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}