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