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