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