nereids_physics/
rmatrix_limited.rs

1//! R-Matrix Limited (LRF=7) cross-section calculation.
2//!
3//! Computes energy-dependent cross-sections (total, elastic, capture, fission)
4//! from ENDF LRF=7 resonance parameters using the full multi-channel R-matrix
5//! formalism.
6//!
7//! ## Relationship to Reich-Moore (LRF=3)
8//!
9//! Reich-Moore is a special case of R-matrix theory that *eliminates* the
10//! capture channel via the Wigner-Eisenbud approximation, collapsing the
11//! level matrix to a scalar per spin group. LRF=7 retains all channels
12//! (elastic, capture, fission) explicitly, requiring an NCH×NCH complex
13//! matrix inversion per spin group per energy point.
14//!
15//! ## Key Formulas
16//!
17//! (ENDF-6 Formats Manual Appendix D; SAMMY manual §3.1; SAMMY `rml/mrml07.f` Pgh + `mrml11.f` Setxqx)
18//!
19//! ```text
20//! R-matrix:
21//!   R_cc'(E) = Σ_n γ_nc · γ_nc' / (E_n - E)   [complex for KRM=3, NCH×NCH, symmetric]
22//!   γ_nc = reduced width amplitude for resonance n in channel c (eV^{1/2})
23//!
24//! Level denominator per channel:
25//!   L_c(E) = S_c(E) - B_c + i·P_c(E)
26//!   P_c = penetrability,  S_c = shift factor,  B_c = boundary condition
27//!
28//! Reduced level matrix (SAMMY "Ymat"):
29//!   Ỹ_cc'(E) = δ_cc' / L_c(E) - R_cc'(E)   [complex, NCH×NCH]
30//!   → Ỹinv = Ỹ⁻¹  (invert Ỹ)
31//!
32//! Intermediate matrix (SAMMY "XXXX"):
33//!   Ξ_cc'(E) = (√P_c / L_c) · (Ỹinv · R)_cc' · √P_c'
34//!
35//! Collision matrix (SAMMY manual eq. III.D.4):
36//!   W_cc' = δ_cc' + 2i·Ξ_cc'
37//!   U_cc' = Ω_c · W_cc' · Ω_c'    where Ω_c = exp(-iφ_c)
38//!
39//!   TRUTH SOURCE: SAMMY rml/mrml11.f lines 14-18 (W = I + 2i·XXXX)
40//!   and lines 84-88 (elastic formula consistent with e^{-2iφ}).
41//!   Unitarity: |U| ≤ 1 always; hard sphere (R=0) → U = exp(2iφ)·I  ✓
42//!
43//! Cross sections per spin group (J,π), summed over entrance neutron channels c0:
44//!   σ_total   = Σ_{c0} 2·(π/k²)·g_J·(1 - Re(U_{c0,c0}))
45//!   σ_elastic = Σ_{c0} (π/k²)·g_J·|1 - U_{c0,c0}|²
46//!   σ_fission = Σ_{c0} Σ_{c'∈fission} (π/k²)·g_J·|U_{c0,c'}|²
47//!   σ_capture = σ_total - σ_elastic - σ_fission
48//! ```
49//!
50//! ## Photon Channels
51//!
52//! For channels involving zero-rest-mass particles (photons, MA=0), the
53//! penetrability is set to 1.0 and phase shifts to 0.0, following the
54//! standard R-matrix convention (ENDF-6 Formats Manual §2.2.1.6 Note 4).
55//!
56//! ## SAMMY Reference
57//! - `rml/mrml01.f` — LRF=7 reader (Scan_File_2, particle pair loop)
58//! - `rml/mrml09.f` — Level matrix inversion (Yinvrs, Xspfa, Xspsl)
59//! - `rml/mrml11.f` — Cross-section calculation (Sectio, Setxqx)
60//! - SAMMY manual §3.1 (multi-channel R-matrix)
61
62use num_complex::Complex64;
63
64use nereids_core::constants::{LOG_FLOOR, NEAR_ZERO_FLOOR, PIVOT_FLOOR, QUANTUM_NUMBER_EPS};
65use nereids_endf::resonance::{ParticlePair, RmlData, SpinGroup};
66
67use crate::{channel, coulomb, penetrability};
68
69/// Pre-allocated workspace for RML cross-section evaluation.
70///
71/// Eliminates per-energy-point allocation of NCH×NCH complex matrices
72/// (`r_cplx`, `y_tilde`, `y_inv`, `xq`, `xxxx`, `u`) and per-channel
73/// vectors (`p_c`, `s_c`, `phi_c`, flags, etc.).
74///
75/// Flat `Vec<Complex64>` buffers store matrices in row-major order.
76/// For typical NCH=3-6, this avoids ~12 small heap allocations per
77/// energy point per spin group.
78struct RmlWorkspace {
79    // ── NCH×NCH complex matrix buffers (flat, row-major) ──────────────
80    r_cplx: Vec<Complex64>,
81    y_tilde: Vec<Complex64>,
82    y_inv: Vec<Complex64>,
83    xq: Vec<Complex64>,
84    xxxx: Vec<Complex64>,
85    u: Vec<Complex64>,
86    // ── Augmented matrix for Gauss-Jordan inversion (NCH × 2·NCH) ─────
87    aug: Vec<Complex64>,
88    // ── Temp row for elimination ───────────────────────────────────────
89    aug_tmp: Vec<Complex64>,
90    // ── Per-channel vectors ───────────────────────────────────────────
91    p_c: Vec<f64>,
92    s_c: Vec<f64>,
93    phi_c: Vec<f64>,
94    l_c: Vec<Complex64>,
95    sqrt_p: Vec<f64>,
96    omega: Vec<Complex64>,
97    is_entrance: Vec<bool>,
98    is_fission: Vec<bool>,
99    is_capture: Vec<bool>,
100    is_inelastic: Vec<bool>,
101    is_closed: Vec<bool>,
102    // ── Per-resonance scratch ─────────────────────────────────────────
103    gamma_vals: Vec<f64>,
104}
105
106impl RmlWorkspace {
107    fn new() -> Self {
108        Self {
109            r_cplx: Vec::new(),
110            y_tilde: Vec::new(),
111            y_inv: Vec::new(),
112            xq: Vec::new(),
113            xxxx: Vec::new(),
114            u: Vec::new(),
115            aug: Vec::new(),
116            aug_tmp: Vec::new(),
117            p_c: Vec::new(),
118            s_c: Vec::new(),
119            phi_c: Vec::new(),
120            l_c: Vec::new(),
121            sqrt_p: Vec::new(),
122            omega: Vec::new(),
123            is_entrance: Vec::new(),
124            is_fission: Vec::new(),
125            is_capture: Vec::new(),
126            is_inelastic: Vec::new(),
127            is_closed: Vec::new(),
128            gamma_vals: Vec::new(),
129        }
130    }
131
132    /// Resize all buffers for a spin group with `nch` channels and
133    /// `max_widths` resonance width entries, then zero them out.
134    fn resize_and_clear(&mut self, nch: usize, max_widths: usize) {
135        let nn = nch * nch;
136        let aug_len = nch * 2 * nch;
137
138        // Complex matrix buffers — resize then fill with zero.
139        resize_and_zero(&mut self.r_cplx, nn);
140        resize_and_zero(&mut self.y_tilde, nn);
141        resize_and_zero(&mut self.y_inv, nn);
142        resize_and_zero(&mut self.xq, nn);
143        resize_and_zero(&mut self.xxxx, nn);
144        resize_and_zero(&mut self.u, nn);
145        resize_and_zero(&mut self.aug, aug_len);
146        resize_and_zero(&mut self.aug_tmp, 2 * nch);
147
148        // Per-channel real/bool vectors.
149        resize_and_zero_f64(&mut self.p_c, nch);
150        resize_and_zero_f64(&mut self.s_c, nch);
151        resize_and_zero_f64(&mut self.phi_c, nch);
152        resize_and_zero(&mut self.l_c, nch);
153        resize_and_zero_f64(&mut self.sqrt_p, nch);
154        resize_and_zero(&mut self.omega, nch);
155        resize_and_zero_bool(&mut self.is_entrance, nch);
156        resize_and_zero_bool(&mut self.is_fission, nch);
157        resize_and_zero_bool(&mut self.is_capture, nch);
158        resize_and_zero_bool(&mut self.is_inelastic, nch);
159        resize_and_zero_bool(&mut self.is_closed, nch);
160
161        // Per-resonance scratch.
162        resize_and_zero_f64(&mut self.gamma_vals, max_widths);
163    }
164}
165
166fn resize_and_zero(buf: &mut Vec<Complex64>, len: usize) {
167    buf.clear();
168    buf.resize(len, Complex64::ZERO);
169}
170
171fn resize_and_zero_f64(buf: &mut Vec<f64>, len: usize) {
172    buf.clear();
173    buf.resize(len, 0.0);
174}
175
176fn resize_and_zero_bool(buf: &mut Vec<bool>, len: usize) {
177    buf.clear();
178    buf.resize(len, false);
179}
180
181/// Compute cross-section contributions from an LRF=7 energy range.
182///
183/// Returns `(total, elastic, capture, fission)` in barns.
184///
185/// Iterates over all spin groups (J,π), sums their contributions.
186/// A single `RmlWorkspace` is allocated once and reused across spin groups.
187pub fn cross_sections_for_rml_range(rml: &RmlData, energy_ev: f64) -> (f64, f64, f64, f64) {
188    let mut total = 0.0;
189    let mut elastic = 0.0;
190    let mut capture = 0.0;
191    let mut fission = 0.0;
192
193    let mut ws = RmlWorkspace::new();
194
195    for sg in &rml.spin_groups {
196        let (t, e, cap, fis) = spin_group_cross_sections(
197            sg,
198            &rml.particle_pairs,
199            energy_ev,
200            rml.awr,
201            rml.target_spin,
202            rml.krm,
203            &mut ws,
204        );
205        total += t;
206        elastic += e;
207        capture += cap;
208        fission += fis;
209    }
210
211    (total, elastic, capture, fission)
212}
213
214/// Cross-section contribution from a single spin group (J,π).
215///
216/// Returns (total, elastic, capture, fission) in barns.
217///
218/// The caller-owned `ws` workspace is resized and reused across spin groups
219/// for a given energy evaluation to avoid per-call heap allocations.
220fn spin_group_cross_sections(
221    sg: &SpinGroup,
222    particle_pairs: &[ParticlePair],
223    energy_ev: f64,
224    awr: f64,
225    target_spin: f64,
226    krm: u32,
227    ws: &mut RmlWorkspace,
228) -> (f64, f64, f64, f64) {
229    let nch = sg.channels.len();
230    if nch == 0 {
231        return (0.0, 0.0, 0.0, 0.0);
232    }
233
234    // KRM guard: the parser rejects KRM values other than 2 and 3 at load time,
235    // so reaching here with an unsupported KRM indicates a programming error.
236    // Panic rather than silently returning zero physics, which would look valid
237    // to callers.  Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml01.f KRM field.
238    assert!(
239        krm == 2 || krm == 3,
240        "spin_group_cross_sections called with unsupported KRM={krm}; \
241         should have been rejected at parse time"
242    );
243
244    // P2b: NRS=0 is valid — the R-matrix is zero but the hard-sphere phase shift
245    // still produces nonzero potential-scattering cross sections (σ_el = 4π|Ω-1|²/k²
246    // per spin group).  Do NOT return early here; the resonance loop simply executes
247    // zero iterations, leaving R = 0, which is exactly the hard-sphere limit.
248
249    // Resize workspace buffers for this spin group's channel count.
250    let max_widths = sg
251        .resonances
252        .iter()
253        .map(|r| r.widths.len())
254        .max()
255        .unwrap_or(nch)
256        .max(nch);
257    ws.resize_and_clear(nch, max_widths);
258
259    let g_j = channel::statistical_weight(sg.j, target_spin);
260    let pok2 = channel::pi_over_k_squared_barns(energy_ev, awr);
261
262    // Entrance-channel CM energy: E_cm = E_lab × AWR/(1+AWR).
263    // Each exit channel adds its Q-value to get its own available energy.
264    // Reference: SAMMY rml/mrml03.f Fxradi — channel thresholds via Q.
265    let e_cm = channel::lab_to_cm_energy(energy_ev, awr);
266
267    for (c, ch) in sg.channels.iter().enumerate() {
268        // P3: particle_pair_idx must be a valid index. The old `.min(len-1)` clamped
269        // silently, misclassifying any channel with an OOB index as the last pair.
270        // An OOB value indicates corrupted ENDF data; let Rust's bounds check panic.
271        let pp = &particle_pairs[ch.particle_pair_idx];
272        ws.is_entrance[c] = pp.mt == 2;
273        ws.is_fission[c] = pp.mt == 18;
274        ws.is_capture[c] = pp.mt == 102;
275        // Inelastic neutron channels (MT=51+): massive particle, not elastic/fission/capture.
276        // Their flux appears in σ_total (optical theorem) but must not be assigned to capture.
277        // Reference: ENDF MT number conventions, §3.4; SAMMY rml/mrml11.f Sectio.
278        ws.is_inelastic[c] =
279            pp.ma >= 0.5 && !ws.is_entrance[c] && !ws.is_fission[c] && !ws.is_capture[c];
280
281        if pp.ma < 0.5 {
282            // Photon channel (MA = 0): P=1, S=0, φ=0.
283            // Convention per ENDF-6 Formats Manual §2.2.1.6 Note 4.
284            // SAMMY: rml/mrml07.f sets penetrability = 1 for massless particles.
285            ws.p_c[c] = 1.0;
286            ws.s_c[c] = 0.0;
287            ws.phi_c[c] = 0.0;
288        } else {
289            // Massive particle channel: channel-specific kinematics (P1).
290            // E_c = E_cm + Q (CM kinetic energy in this exit channel).
291            // Reference: SAMMY rml/mrml03.f Fxradi — Zke = Twomhb*sqrt(Redmas*Factor)
292            let e_c = e_cm + pp.q;
293            if e_c <= 0.0 {
294                // Closed channel (below threshold): P_c = 0, φ_c = 0.
295                // S_c depends on SHF:
296                //   SHF=0: convention is S_c = B_c; L_c = 0 when B_c = 0 (common).
297                //   SHF=1: S_c is the analytic shift factor at imaginary argument
298                //     ρ = iκ, which is real and finite.  L_c = (S_c − B_c) is generally
299                //     non-zero and its dispersive contribution must be preserved.
300                // Reference: SAMMY rml/mrml07.f Pgh — PH = 1/(S−B+iP).
301                ws.p_c[c] = 0.0;
302                ws.phi_c[c] = 0.0;
303                ws.is_closed[c] = true;
304                // SHF=0: S_c = B_c so (S_c − B_c) = 0 in the level matrix.
305                // SHF=1: S_c is the analytic shift at imaginary argument ρ = iκ.
306                //   For non-Coulomb channels we use the Blatt-Weisskopf formula.
307                //   For Coulomb channels the imaginary-argument Coulomb shift is
308                //   not yet implemented; fall back to S_c = B_c.  This matches
309                //   SAMMY's convention: mrml07.f ELSE branch (Su ≤ Echan) sets
310                //   Elinvr=1/Elinvi=0 (i.e. L_c = 1) for all closed channels,
311                //   Coulomb and non-Coulomb alike, without calling Pghcou.
312                let is_coulomb = pp.za.abs() > 0.5 && pp.zb.abs() > 0.5;
313                ws.s_c[c] = if pp.shf == 1 && !is_coulomb {
314                    let redmas = pp.ma * pp.mb / (pp.ma + pp.mb);
315                    let kappa = channel::wave_number_from_cm(e_c.abs(), redmas);
316                    penetrability::shift_factor_closed(ch.l, kappa * ch.effective_radius)
317                } else {
318                    ch.boundary
319                };
320            } else {
321                // Channel wave number from reduced mass μ = MA·MB/(MA+MB).
322                // For elastic (MA=1, MB=AWR): k_c = wave_number(E_lab, AWR) [identical].
323                let redmas = pp.ma * pp.mb / (pp.ma + pp.mb);
324                let k_c = channel::wave_number_from_cm(e_c, redmas);
325                // APE (effective radius) for P_c, S_c; APT (true radius) for φ_c.
326                // Reference: SAMMY rml/mrml07.f (Pgh, Sinsix, Pf subroutines)
327                let rho_eff = k_c * ch.effective_radius;
328                let rho_true = k_c * ch.true_radius;
329                // ── Coulomb vs hard-sphere routing ───────────────────────────
330                // SAMMY rml/mrml07.f Pgh — `if (Zeta(I).NE.Zero)` branch.
331                // Both particles charged → Coulomb wave functions F_L / G_L.
332                // One neutral (za=0 or zb=0) → hard-sphere Blatt-Weisskopf.
333                if pp.za.abs() > 0.5 && pp.zb.abs() > 0.5 {
334                    // Coulomb channel (e.g. n+α→p+X, (n,p), fission fragments).
335                    // Two CF1+CF2 solves: rho_eff for P_c/S_c, rho_true for φ_c
336                    // (different radii — cannot reuse the same (F,G) pair).
337                    // Reference: SAMMY rml/mrml07.f Pgh — Pghcou, then Sinsix/Pf.
338                    let eta = coulomb::sommerfeld_eta(pp.za, pp.zb, pp.ma, pp.mb, e_c);
339                    // P_c/S_c depend only on rho_eff; φ_c depends only on rho_true.
340                    // The two solves are independent: a rho_true failure must not
341                    // close a channel that rho_eff confirmed is open.
342                    // Reference: SAMMY rml/mrml07.f Pgh — Pghcou (rho_eff),
343                    //   then Sinsix/Pf (rho_true).
344                    match coulomb::coulomb_wave_functions(ch.l, eta, rho_eff) {
345                        Some((f, g, fp, gp)) => {
346                            // rho_eff succeeded: channel is genuinely open.
347                            let fg_sq = f * f + g * g;
348                            ws.p_c[c] = rho_eff / fg_sq;
349                            // SHF=1: Coulomb shift ρ(F·F'+G·G')/(F²+G²).
350                            // SHF=0: S_c = B_c so (S_c − B_c) = 0 in level matrix.
351                            // Note: parser rejects Coulomb + SHF=1, so this arm is
352                            // only reachable if that validation is later relaxed.
353                            ws.s_c[c] = if pp.shf == 1 {
354                                rho_eff * (f * fp + g * gp) / fg_sq
355                            } else {
356                                ch.boundary
357                            };
358                            // φ_c from rho_true; if rho_true ≤ acch, default to 0
359                            // (hard-sphere limit φ → 0 as ρ → 0) without closing
360                            // the channel.
361                            ws.phi_c[c] = coulomb::coulomb_wave_functions(ch.l, eta, rho_true)
362                                .map_or(0.0, |(fl_t, gl_t, _, _)| fl_t.atan2(gl_t));
363                        }
364                        None => {
365                            // rho_eff ≤ acch (≈ 1e-8, SAMMY Coulfg threshold):
366                            // penetrability → 0 at threshold; treat as closed.
367                            // Reference: SAMMY coulomb/mrml08.f90 Coulfg — acch.
368                            ws.p_c[c] = 0.0;
369                            ws.s_c[c] = ch.boundary;
370                            ws.phi_c[c] = 0.0;
371                            ws.is_closed[c] = true;
372                        }
373                    }
374                } else {
375                    // Hard-sphere (Blatt-Weisskopf) channel.
376                    ws.p_c[c] = penetrability::penetrability(ch.l, rho_eff);
377                    // SHF=0: shift factor not calculated; S_c = B_c so (S_c - B_c) = 0
378                    // in the level matrix diagonal.
379                    // SHF=1: calculate S_c analytically (Blatt-Weisskopf).
380                    // Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml07.f Pgh (Ishift check)
381                    ws.s_c[c] = if pp.shf == 1 {
382                        penetrability::shift_factor(ch.l, rho_eff)
383                    } else {
384                        ch.boundary
385                    };
386                    ws.phi_c[c] = penetrability::phase_shift(ch.l, rho_true);
387                }
388            }
389        }
390    }
391
392    // ── R-matrix (complex for KRM=3, real for KRM=2) ─────────────────────────
393    // KRM=2 (standard R-matrix):
394    //   R_cc'(E) = Σ_n γ_nc · γ_nc' / (E_n - E)   [real, reduced amplitude widths]
395    //
396    // KRM=3 (Reich-Moore approximation):
397    //   R_cc'(E) = Σ_n γ_nc · γ_nc' / (Ẽ_n - E)   [complex, Ẽ_n = E_n - i·Γ_γn/2]
398    //   where γ_nc = √(Γ_nc / (2·P_c(E_n))) (partial width → reduced amplitude).
399    //   The imaginary shift makes capture implicit — |U| < 1, with missing flux
400    //   going to capture.
401    //
402    // Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml07.f Setr subroutine
403    // ws.r_cplx is already zeroed by resize_and_clear.
404    for res in &sg.resonances {
405        let e_tilde = if krm == 3 {
406            // KRM=3: convert formal partial widths to reduced amplitudes.
407            // γ_nc = √(|Γ_nc| / (2·P_c(E_n))).  Sign preserved from Γ_nc.
408            // For closed channels or P=0 (e.g. bound states at E_n<0): use
409            // γ_nc = √(|Γ_nc|) directly (SAMMY convention for bound states).
410            // Complex energy Ẽ_n = E_n - i·Γ_γ/2.
411            // Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml01.f
412            let e_tilde = Complex64::new(res.energy, -res.gamma_gamma / 2.0);
413            // P_c must be evaluated at the resonance energy E_n, not at the
414            // incident energy E.  γ_nc = √(Γ_nc / (2·P_c(E_n))) is a property
415            // of the resonance and must be energy-independent.  Using P_c(E)
416            // would make γ_nc depend on the evaluation point, distorting the
417            // resonance shape away from the peak.
418            // Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml01.f (reads Γ_nc then
419            // converts via P at resonance energy in Pgh subroutine).
420            for c in 0..nch {
421                let gamma_formal = res.widths[c];
422                let ch = &sg.channels[c];
423                let pp_c = &particle_pairs[ch.particle_pair_idx];
424                // P_c at the resonance energy E_n.  Returns None when the channel
425                // is closed at E_n (bound-state resonance), Some(P) otherwise.
426                //
427                // The fallback must be gated on whether e_cm_n ≤ 0, NOT on
428                // whether P is numerically small.  For genuinely open channels
429                // near threshold (high-l, small ρ), P is positive but tiny;
430                // a magnitude guard would replace √(|Γ|/(2P)) ≫ 1 with √|Γ| ≪ 1,
431                // underestimating the reduced amplitude by orders of magnitude.
432                // Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml01.f Pgh.
433                let p_at_en: Option<f64> = if pp_c.ma < 0.5 {
434                    Some(1.0) // photon: P = 1 by convention
435                } else {
436                    // P1: res.energy is lab-frame; convert to CM before adding Q.
437                    // Reference: SAMMY rml/mrml03.f Fxradi; ENDF-6 §2.2.1.6.
438                    let e_cm_n = channel::lab_to_cm_energy(res.energy, awr) + pp_c.q;
439                    if e_cm_n <= 0.0 {
440                        None // channel closed at resonance energy (bound state)
441                    } else {
442                        let redmas = pp_c.ma * pp_c.mb / (pp_c.ma + pp_c.mb);
443                        let k_cn = channel::wave_number_from_cm(e_cm_n, redmas);
444                        let rho_eff_n = k_cn * ch.effective_radius;
445                        // Must use the same penetrability type as the open-channel
446                        // block: Coulomb P_c(E_n) for charged pairs, hard-sphere
447                        // otherwise.  Mixing them produces inconsistent γ_nc
448                        // normalisation: γ_nc = √(Γ_nc / (2·P_c(E_n))).
449                        // SAMMY rml/mrml07.f Pgh — same Zeta check applies here.
450                        let p = if pp_c.za.abs() > 0.5 && pp_c.zb.abs() > 0.5 {
451                            // If rho_eff_n ≤ acch (SAMMY Coulfg threshold,
452                            // ≈ 1e-8) wave functions return None and P = 0.0
453                            // is used.  The filter(p > 0.0) below then maps
454                            // P = 0 → None, applying the closed-channel
455                            // √(|Γ|) normalisation — correct physical limit
456                            // for a resonance barely above its Coulomb
457                            // channel threshold.
458                            // Reference: SAMMY coulomb/mrml08.f90 Coulfg.
459                            let eta =
460                                coulomb::sommerfeld_eta(pp_c.za, pp_c.zb, pp_c.ma, pp_c.mb, e_cm_n);
461                            coulomb::coulomb_wave_functions(ch.l, eta, rho_eff_n)
462                                .map_or(0.0, |(fl, gl, _, _)| rho_eff_n / (fl * fl + gl * gl))
463                        } else {
464                            penetrability::penetrability(ch.l, rho_eff_n)
465                        };
466                        Some(p)
467                    }
468                };
469                // Guard: prevent division by zero if P_c = 0.  Covers both
470                // non-Coulomb channels at rho → 0 and Coulomb channels below
471                // SAMMY's acch threshold (coulomb_wave_functions returns None
472                // → P = 0.0).  Collapsing to None uses the closed-channel
473                // √(|Γ|) normalisation, which is the correct limit when the
474                // channel has effectively zero penetrability.
475                let p_at_en = p_at_en.filter(|&p| p > 0.0);
476                ws.gamma_vals[c] = match p_at_en {
477                    None => {
478                        // Closed or non-computable channel at E_n: formal width used
479                        // directly as reduced amplitude (SAMMY bound-state convention).
480                        gamma_formal.abs().sqrt().copysign(gamma_formal)
481                    }
482                    Some(p) => {
483                        // Open channel: γ = √(|Γ| / (2·P_c(E_n))) with sign of Γ.
484                        let magnitude = (gamma_formal.abs() / (2.0 * p)).sqrt();
485                        magnitude.copysign(gamma_formal)
486                    }
487                };
488            }
489            e_tilde
490        } else {
491            // KRM=2: widths are already reduced amplitudes; real denominator.
492            // P2: Guard only against exact IEEE 754 zero; complex infrastructure
493            // handles the Lorentzian width naturally via i·P_c in level matrix.
494            ws.gamma_vals[..nch].copy_from_slice(&res.widths[..nch]);
495            Complex64::new(res.energy, 0.0)
496        };
497
498        let denom = e_tilde - energy_ev;
499        // Near-pole regularization: add a tiny imaginary offset ε to the denominator
500        // so that evaluating exactly at E = E_n (where denom → 0 for real KRM=2 poles)
501        // gives a finite, physically meaningful result via the Cauchy principal value.
502        // For KRM=3, e_tilde already carries −iΓ_γ/2 so the denominator is never zero;
503        // the correction is negligible (ε << Γ_γ/2).
504        // Reference: Cauchy PV regularisation; SAMMY avoids the exact pole by perturbing
505        // the resonance energy during input processing.
506        let inv_denom = if denom.norm() < QUANTUM_NUMBER_EPS {
507            (denom + Complex64::new(0.0, QUANTUM_NUMBER_EPS)).inv()
508        } else {
509            denom.inv()
510        };
511        for c in 0..nch {
512            let gc = ws.gamma_vals[c];
513            for cp in 0..nch {
514                ws.r_cplx[c * nch + cp] += gc * ws.gamma_vals[cp] * inv_denom;
515            }
516        }
517    }
518
519    // ── L_c = (S_c - B_c) + i·P_c (per-channel level denominator) ───────────
520    // Reference: SAMMY rml/mrml07.f Pgh subroutine, "PH = 1/(S-B+IP)"
521    for c in 0..nch {
522        ws.l_c[c] = Complex64::new(ws.s_c[c] - sg.channels[c].boundary, ws.p_c[c]);
523    }
524
525    // ── Reduced level matrix Ỹ = L⁻¹ - R (SAMMY "Ymat") ─────────────────────
526    // Ỹ_cc'(E) = (1/L_c)·δ_cc' - R_cc'
527    // Reference: SAMMY rml/mrml07.f — "Ymat = (1/(S-B+IP) - Rmat)"
528    //
529    // NOTE: This is NOT (L - R). The SAMMY formulation inverts L⁻¹ - R, not
530    // L - R. Using L - R gives |U| = 3 for the hard sphere (R=0, A=iP,
531    // A⁻¹·P = -i/P·P = -i, W = 1+2i²·(−1)=3) — catastrophically wrong.
532    // Using L⁻¹ - R gives |U| = 1 for R=0 (Ỹ = 1/L, Ỹinv = L, XQ = L·0 = 0,
533    // XXXX = 0, W = 1, U = exp(2iφ)) — correct hard-sphere limit.
534    for c in 0..nch {
535        // L_c = (S_c − B_c) + i·P_c.
536        // For SHF=0 closed channels: S_c = B_c and P_c = 0 ⇒ L_c = 0.
537        // Correct limit: 1/L_c → ∞ ⇒ Ỹ[c,c] >> R[c,c] ⇒ Ỹ⁻¹[c,c] ≈ 0
538        // ⇒ channel decouples from U.  Setting 1/L_c = 0 (old bug) removes
539        // the diagonal and lets R dominate — wrong coupling / Ỹ singular.
540        //
541        // For SHF=1 or non-matching B_c, L_c is generally finite even when
542        // P_c = 0; the dispersive (real) shift must be preserved.  Do NOT
543        // force the sentinel just because the channel is sub-threshold; check
544        // whether |L_c| is actually near zero.
545        //
546        // Reference: SAMMY rml/mrml07.f — PH = 1/(S−B+iP).
547        let inv_l = if ws.l_c[c].norm_sqr() < NEAR_ZERO_FLOOR {
548            // |L_c|² < NEAR_ZERO_FLOOR: use finite-but-large sentinel so the diagonal
549            // dominates and the channel decouples without overflow in inversion.
550            Complex64::new(1e30, 0.0)
551        } else {
552            Complex64::new(1.0, 0.0) / ws.l_c[c]
553        };
554        for cp in 0..nch {
555            let diag = if c == cp { inv_l } else { Complex64::ZERO };
556            ws.y_tilde[c * nch + cp] = diag - ws.r_cplx[c * nch + cp];
557        }
558    }
559
560    // ── Invert Ỹ to get Ỹinv (SAMMY "Yinv") ─────────────────────────────────
561    // Reference: SAMMY rml/mrml09.f Yinvrs subroutine
562    if !invert_complex_matrix_flat(
563        &ws.y_tilde,
564        nch,
565        &mut ws.y_inv,
566        &mut ws.aug,
567        &mut ws.aug_tmp,
568    ) {
569        // Singular Ỹ matrix — regularize by adding a small real epsilon to
570        // the diagonal and retry.  This can happen when channels are
571        // near-degenerate (e.g. two channels at the same threshold).
572        // The epsilon is real-only to preserve Hermitian symmetry of the
573        // level matrix; an imaginary perturbation would break unitarity.
574        //
575        // Use a *relative* epsilon: ε = |diag| × QUANTUM_NUMBER_EPS, with a floor of
576        // NEAR_ZERO_FLOOR for zero diagonals.  A fixed absolute epsilon
577        // could be comparable to or larger than the diagonal value itself
578        // for high-L channels with very small penetrabilities (where
579        // 1/L_c ~ 1/P_c can be enormous, but R_cc' is also large, making
580        // the net diagonal small).  The relative approach perturbs the
581        // matrix by a fraction of its natural scale.
582        //
583        // Per-diagonal (not matrix-norm) regularization is intentional:
584        // each channel's diagonal element lives on its own physical scale
585        // (set by 1/L_c − R_cc), which can differ by orders of magnitude
586        // across channels (e.g. an s-wave elastic channel vs. a high-L
587        // fission channel).  A single matrix-norm epsilon would be
588        // dominated by the largest channel and could either over-perturb
589        // small channels or under-perturb large ones.  Per-diagonal
590        // epsilon ensures each channel is nudged proportionally to its
591        // own scale.
592
593        // Copy y_tilde into y_inv as a temp buffer for the regularized matrix.
594        ws.y_inv.copy_from_slice(&ws.y_tilde);
595        for i in 0..nch {
596            let diag_norm = ws.y_inv[i * nch + i].norm();
597            // Relative regularization (QUANTUM_NUMBER_EPS × diagonal) with an
598            // absolute floor of NEAR_ZERO_FLOOR for near-zero diagonals.
599            let eps = (diag_norm * QUANTUM_NUMBER_EPS).max(NEAR_ZERO_FLOOR);
600            ws.y_inv[i * nch + i] += Complex64::new(eps, 0.0);
601        }
602        // y_inv now holds the regularized y_tilde; copy it to y_tilde so the
603        // inversion reads from the regularized version.
604        ws.y_tilde.copy_from_slice(&ws.y_inv);
605        if !invert_complex_matrix_flat(
606            &ws.y_tilde,
607            nch,
608            &mut ws.y_inv,
609            &mut ws.aug,
610            &mut ws.aug_tmp,
611        ) {
612            return (0.0, 0.0, 0.0, 0.0); // truly degenerate
613        }
614    }
615
616    // ── XQ = Ỹinv · R (matrix product, SAMMY "Xqr/Xqi") ─────────────────────
617    // Reference: SAMMY rml/mrml11.f Setxqx — "Xqr(k,i) = (L**-1-R)**-1 * R"
618    for c in 0..nch {
619        for cp in 0..nch {
620            let mut sum = Complex64::ZERO;
621            for k in 0..nch {
622                sum += ws.y_inv[c * nch + k] * ws.r_cplx[k * nch + cp];
623            }
624            ws.xq[c * nch + cp] = sum;
625        }
626    }
627
628    // ── XXXX = (√P_c / L_c) · XQ · √P_c' ────────────────────────────────────
629    // Reference: SAMMY rml/mrml11.f Setxqx — "Xxxx = sqrt(P)/L * xq * sqrt(P)"
630    for c in 0..nch {
631        ws.sqrt_p[c] = ws.p_c[c].sqrt();
632    }
633    for c in 0..nch {
634        // For a closed channel: sqrt_p[c] = 0 and L_c = 0 (0/0 indeterminate).
635        // The full XXXX[c,cp] = (√P_c / L_c) · XQ[c,cp] · √P_c'.
636        // Since √P_c = 0 for any closed channel c, the entire row is zero
637        // regardless of the value of L_c. Setting sqrt_p_over_l = 0 is correct.
638        // (The Ỹ sentinel handles Ỹ inversion correctly; this row zeroing is
639        //  consistent: a closed channel contributes nothing to XXXX/U.)
640        // Guard: at exact channel threshold, both √P_c and L_c can be
641        // zero simultaneously, producing 0/0 = NaN.  The is_closed flag
642        // catches most cases, but a channel right at threshold might not
643        // be flagged closed yet have |L_c| ≈ 0.  The extra norm check
644        // prevents NaN propagation.
645        let sqrt_p_over_l = if ws.is_closed[c] || ws.l_c[c].norm() < PIVOT_FLOOR {
646            Complex64::ZERO
647        } else {
648            ws.sqrt_p[c] / ws.l_c[c]
649        };
650        for cp in 0..nch {
651            ws.xxxx[c * nch + cp] = sqrt_p_over_l * ws.xq[c * nch + cp] * ws.sqrt_p[cp];
652        }
653    }
654
655    // ── Collision matrix U = Ω · W · Ω, W = I + 2i·Ξ ────────────────────────
656    //
657    // Phase convention: Ω_c = exp(-iφ_c), NOT exp(+iφ_c).
658    //
659    // TRUTH SOURCE: SAMMY rml/mrml11.f lines 14-18:
660    //   "W(c,c') = delta(c,c') + 2i XXXX(c,c')" (eq. III.D.4)
661    // And mrml11.f lines 84-88, the elastic formula:
662    //   "sin²(φ)·(1-2Xi) - sin(2φ)·Xr + Xr²+Xi²"
663    // is consistent ONLY with U = e^{-2iφ}·(I+2iX), not e^{+2iφ}.
664    //
665    // For hard sphere (W=I): |1-e^{-2iφ}|² = |1-e^{+2iφ}|² = 4sin²φ,
666    // so the sign error is invisible in unitarity tests. It ONLY manifests
667    // when resonances are present (W ≠ I), producing wrong interference
668    // patterns in elastic and incorrect total from optical theorem.
669    //
670    // History: same class of bug as the MLBW e^{+2iφ} error fixed in
671    // slbw.rs (commit f0eadc1). The negative exponent is the ENDF/SAMMY
672    // convention; the positive exponent is a common error.
673    for c in 0..nch {
674        ws.omega[c] = Complex64::from_polar(1.0, -ws.phi_c[c]);
675    }
676
677    for c in 0..nch {
678        for cp in 0..nch {
679            let delta = if c == cp {
680                Complex64::new(1.0, 0.0)
681            } else {
682                Complex64::ZERO
683            };
684            let w_cc = delta + Complex64::new(0.0, 2.0) * ws.xxxx[c * nch + cp];
685            ws.u[c * nch + cp] = ws.omega[c] * w_cc * ws.omega[cp];
686        }
687    }
688
689    // ── Cross-sections (sum over entrance channels) ───────────────────────────
690    // Optical theorem gives σ_total = 2·(π/k²)·g_J·(1 - Re(U_cc)) per channel.
691    // Reference: SAMMY rml/mrml11.f Sectio subroutine; SAMMY manual §3.1 Eq. 3.4
692    let mut tot = 0.0;
693    let mut elas = 0.0;
694    let mut cap = 0.0;
695    let mut fis = 0.0;
696    let mut inel = 0.0; // inelastic neutron channels (MT=51+): tracked separately
697
698    // Whether this spin group has explicit capture (photon) channels in the
699    // level matrix.  KRM=2 with photon channels: yes.  KRM=3: no (capture is
700    // implicit via complex poles; no MT=102 channel appears in NCH).
701    let has_explicit_capture = ws.is_capture[..nch].iter().any(|&x| x);
702
703    for c0 in 0..nch {
704        if !ws.is_entrance[c0] {
705            continue;
706        }
707        let u_diag = ws.u[c0 * nch + c0];
708        // σ_total (optical theorem, per entrance channel)
709        tot += 2.0 * pok2 * g_j * (1.0 - u_diag.re);
710        // σ_elastic: |1 - U_{c0,c0}|²
711        elas += pok2 * g_j * (Complex64::new(1.0, 0.0) - u_diag).norm_sqr();
712
713        for cp in 0..nch {
714            if ws.is_fission[cp] {
715                // σ_fission: |U_{c0,c'}|² for fission channels c'
716                fis += pok2 * g_j * ws.u[c0 * nch + cp].norm_sqr();
717            }
718            if has_explicit_capture && ws.is_capture[cp] {
719                // σ_capture (explicit): |U_{c0,c'}|² for photon channels c'.
720                // Avoids lumping inelastic neutron channels (MT=51+) into capture.
721                // Reference: SAMMY rml/mrml11.f Sectio — explicit sum over γ channels.
722                cap += pok2 * g_j * ws.u[c0 * nch + cp].norm_sqr();
723            }
724            if ws.is_inelastic[cp] {
725                // σ_inelastic: |U_{c0,c'}|² for inelastic neutron channels (MT=51+).
726                // Tracked separately so KRM=3 capture residual excludes this flux.
727                // Reference: ENDF MT conventions §3.4; SAMMY rml/mrml11.f Sectio.
728                inel += pok2 * g_j * ws.u[c0 * nch + cp].norm_sqr();
729            }
730        }
731    }
732
733    if krm == 3 && !has_explicit_capture {
734        // KRM=3 (Reich-Moore approximation): capture is implicit via complex poles
735        // (Ẽ_n = E_n - i·Γγ/2).  Flux not going to elastic, fission, or inelastic
736        // channels is capture.  Inelastic flux must be excluded; folding it into
737        // capture would mislabel σ_capture when MT=51+ channels are present.
738        // Clamp to ≥0 for floating-point safety near pole energies.
739        // Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml11.f Sectio.
740        cap = (tot - elas - fis - inel).max(0.0);
741    }
742    // KRM=2: capture was accumulated explicitly above from MT=102 channels.
743    // Do NOT add residual flux — it may include inelastic (MT=51+) contributions
744    // and would mislabel them as capture, biasing channel-resolved fits.
745    // Reference: SAMMY rml/mrml11.f Sectio (explicit γ-channel sum for KRM=2).
746
747    (tot, elas, cap, fis)
748}
749
750// ── Complex Gauss-Jordan Elimination (flat-buffer version) ──────────────────
751//
752// Inverts an n×n complex matrix using Gauss-Jordan elimination with partial
753// pivoting. Returns false if the matrix is singular (pivot magnitude < LOG_FLOOR).
754//
755// For LRF=7 isotopes relevant to VENUS imaging, NCH ≤ 6, so O(n³) is fast.
756// SAMMY uses a specialized complex symmetric factorization (Xspfa/Xspsl in
757// rml/mrml10.f), but Gauss-Jordan is correct and sufficient for our purposes.
758//
759// All buffers are caller-provided to avoid per-call allocation:
760// - `a`: input matrix (flat row-major, n×n)
761// - `out`: output inverse (flat row-major, n×n)
762// - `aug`: augmented matrix workspace (flat row-major, n×2n)
763// - `tmp`: temporary row buffer (length 2n)
764
765fn invert_complex_matrix_flat(
766    a: &[Complex64],
767    n: usize,
768    out: &mut [Complex64],
769    aug: &mut [Complex64],
770    tmp: &mut [Complex64],
771) -> bool {
772    let w = 2 * n; // augmented row width
773
774    // Build augmented matrix [A | I] of size n × 2n
775    for r in 0..n {
776        for c in 0..n {
777            aug[r * w + c] = a[r * n + c];
778        }
779        for c in 0..n {
780            aug[r * w + n + c] = if c == r {
781                Complex64::new(1.0, 0.0)
782            } else {
783                Complex64::ZERO
784            };
785        }
786    }
787
788    for col in 0..n {
789        // Partial pivoting: find row with largest magnitude in this column
790        let mut best = col;
791        let mut best_norm = aug[col * w + col].norm();
792        for r in (col + 1)..n {
793            let norm = aug[r * w + col].norm();
794            if norm > best_norm {
795                best = r;
796                best_norm = norm;
797            }
798        }
799        // Swap rows col and best in aug
800        if best != col {
801            for j in 0..w {
802                aug.swap(col * w + j, best * w + j);
803            }
804        }
805
806        let pivot = aug[col * w + col];
807        if pivot.norm() < LOG_FLOOR {
808            return false; // singular
809        }
810
811        // Scale pivot row so leading entry becomes 1
812        let inv_pivot = pivot.inv();
813        for j in 0..w {
814            aug[col * w + j] *= inv_pivot;
815        }
816
817        // Eliminate this column from all other rows.
818        // Copy pivot row to tmp to avoid aliasing issues.
819        tmp[..w].copy_from_slice(&aug[col * w..col * w + w]);
820        for row in 0..n {
821            if row == col {
822                continue;
823            }
824            let factor = aug[row * w + col];
825            if factor.norm() < LOG_FLOOR {
826                continue;
827            }
828            for j in 0..w {
829                aug[row * w + j] -= factor * tmp[j];
830            }
831        }
832    }
833
834    // Extract the right half (the inverse)
835    for r in 0..n {
836        for c in 0..n {
837            out[r * n + c] = aug[r * w + n + c];
838        }
839    }
840    true
841}
842
843// ── Legacy wrapper for tests (allocating version) ───────────────────────────
844#[cfg(test)]
845fn invert_complex_matrix(a: &[Vec<Complex64>], n: usize) -> Option<Vec<Vec<Complex64>>> {
846    let flat_a: Vec<Complex64> = a.iter().flat_map(|row| row.iter().copied()).collect();
847    let mut flat_out = vec![Complex64::ZERO; n * n];
848    let mut aug = vec![Complex64::ZERO; n * 2 * n];
849    let mut tmp = vec![Complex64::ZERO; 2 * n];
850    if invert_complex_matrix_flat(&flat_a, n, &mut flat_out, &mut aug, &mut tmp) {
851        Some(
852            (0..n)
853                .map(|r| flat_out[r * n..(r + 1) * n].to_vec())
854                .collect(),
855        )
856    } else {
857        None
858    }
859}
860
861#[cfg(test)]
862mod tests {
863    use super::*;
864
865    #[test]
866    fn test_identity_inversion() {
867        let n = 3;
868        let a: Vec<Vec<Complex64>> = (0..n)
869            .map(|r| {
870                (0..n)
871                    .map(|c| {
872                        if r == c {
873                            Complex64::new(1.0, 0.0)
874                        } else {
875                            Complex64::ZERO
876                        }
877                    })
878                    .collect()
879            })
880            .collect();
881        let inv = invert_complex_matrix(&a, n).unwrap();
882        for (r, row) in inv.iter().enumerate() {
883            for (c, val) in row.iter().enumerate() {
884                let expected = if r == c { 1.0 } else { 0.0 };
885                assert!(
886                    (val.re - expected).abs() < 1e-12,
887                    "inv[{r}][{c}].re = {}, expected {expected}",
888                    val.re
889                );
890                assert!(
891                    val.im.abs() < 1e-12,
892                    "inv[{r}][{c}].im = {} should be 0",
893                    val.im
894                );
895            }
896        }
897    }
898
899    #[test]
900    fn test_2x2_complex_inversion() {
901        // A = [[2+i, 1], [0, 3-2i]]  → A⁻¹ = [[1/(2+i), -1/((2+i)(3-2i))], [0, 1/(3-2i)]]
902        let a00 = Complex64::new(2.0, 1.0);
903        let a01 = Complex64::new(1.0, 0.0);
904        let a11 = Complex64::new(3.0, -2.0);
905        let a = vec![vec![a00, a01], vec![Complex64::ZERO, a11]];
906        let inv = invert_complex_matrix(&a, 2).unwrap();
907
908        // Verify A · A⁻¹ ≈ I
909        let i00 = a00 * inv[0][0] + a01 * inv[1][0];
910        let i01 = a00 * inv[0][1] + a01 * inv[1][1];
911        let i11 = a11 * inv[1][1];
912        assert!((i00.re - 1.0).abs() < 1e-12, "i00.re = {}", i00.re);
913        assert!(i00.im.abs() < 1e-12, "i00.im = {}", i00.im);
914        assert!(i01.norm() < 1e-12, "i01 = {}", i01);
915        assert!((i11.re - 1.0).abs() < 1e-12, "i11.re = {}", i11.re);
916    }
917
918    #[test]
919    fn test_singular_returns_none() {
920        let a = vec![
921            vec![Complex64::new(1.0, 0.0), Complex64::new(2.0, 0.0)],
922            vec![Complex64::new(2.0, 0.0), Complex64::new(4.0, 0.0)],
923        ];
924        assert!(invert_complex_matrix(&a, 2).is_none());
925    }
926
927    /// Hard-sphere unitarity check: with R = 0, U must equal exp(2iφ)·I
928    /// and σ_total = 2·(π/k²)·g_J·(1 − cos 2φ) ≥ 0.
929    ///
930    /// This test is purely local (no network) and guards against the
931    /// classic sign error where U = 3·exp(2iφ)·I (|U| = 3) that arises
932    /// when using A = L − R instead of SAMMY's Ỹ = L⁻¹ − R.
933    #[test]
934    fn test_hard_sphere_unitarity() {
935        use nereids_endf::resonance::{ParticlePair, RmlChannel, RmlData, SpinGroup};
936
937        // Minimal synthetic LRF=7 / KRM=2 with a single elastic channel
938        // and NO resonances.  Result must be pure hard-sphere scattering.
939        let pp = ParticlePair {
940            ma: 1.0,
941            mb: 184.0,
942            za: 0.0,  // neutron charge Z=0
943            zb: 74.0, // W-184 charge Z=74 (ENDF LRF=7 stores charge directly)
944            ia: 0.5,
945            ib: 0.0,
946            q: 0.0,
947            pnt: 1,
948            shf: 0, // SHF=0 → S_c = B_c → L_c = iP_c
949            mt: 2,
950            pa: 1.0,
951            pb: 1.0,
952        };
953        let channel = RmlChannel {
954            particle_pair_idx: 0,
955            l: 0,
956            channel_spin: 0.5,
957            boundary: 0.0,
958            effective_radius: 8.3,
959            true_radius: 8.3,
960        };
961        let sg = SpinGroup {
962            j: 0.5,
963            parity: 1.0,
964            channels: vec![channel],
965            resonances: vec![], // no resonances: pure hard sphere
966            has_background_correction: false,
967        };
968        let rml = RmlData {
969            target_spin: 0.0,
970            awr: 183.0,
971            scattering_radius: 8.3,
972            krm: 2,
973            particle_pairs: vec![pp],
974            spin_groups: vec![sg],
975        };
976
977        // Evaluate at several energies.  σ_total must be non-negative,
978        // and σ_capture/σ_fission must be zero (no absorption channels).
979        //
980        // Note: cap is computed as (tot - elas - fis); since tot and elas are
981        // calculated via different floating-point paths they may differ by
982        // ~1e-15 × pok2 (where pok2 can be ~1e5 b at low energy), giving a
983        // residual |cap| ~ 1e-10 b.  Use a relative tolerance of 1e-9.
984        for &e_ev in &[10.0, 50.0, 100.0, 500.0, 1000.0] {
985            let (tot, elas, cap, fis) = cross_sections_for_rml_range(&rml, e_ev);
986            assert!(
987                tot >= 0.0,
988                "hard sphere σ_total < 0 at {e_ev} eV: {tot:.6} b"
989            );
990            let tol = 1e-9 * tot.abs().max(1.0);
991            assert!(
992                cap.abs() < tol,
993                "hard sphere σ_capture ≠ 0 at {e_ev} eV: {cap:.3e} b (tol={tol:.3e})"
994            );
995            assert!(
996                fis.abs() < tol,
997                "hard sphere σ_fission ≠ 0 at {e_ev} eV: {fis:.3e} b (tol={tol:.3e})"
998            );
999            // σ_elastic ≈ σ_total (capture=fission=0)
1000            assert!(
1001                (tot - elas).abs() < tol,
1002                "σ_total ≠ σ_elastic at {e_ev} eV: tot={tot:.6}, elas={elas:.6}"
1003            );
1004        }
1005    }
1006
1007    /// Coulomb exit channels route through coulomb::coulomb_penetrability,
1008    /// not the hard-sphere Blatt-Weisskopf functions.
1009    ///
1010    /// Constructs a 2-channel spin group:
1011    ///   ch0: neutron (za=0)  + target — hard-sphere entrance channel
1012    ///   ch1: α (za=2)        + O-16 (zb=8) — Coulomb exit, Q=+50 eV (always open)
1013    ///
1014    /// ENDF LRF=7 stores charge Z directly in ZA/ZB (neutron=0, alpha=2, O-16=8).
1015    ///
1016    /// Verifies σ_total ≥ 0 (physics sanity) and no panic at both an open
1017    /// and a closed Coulomb channel (Q very negative).
1018    ///
1019    /// SAMMY ref: rml/mrml07.f Pgh — `if (Zeta(I).NE.Zero)` branch.
1020    #[test]
1021    fn test_coulomb_channel_open_and_closed_no_panic() {
1022        use nereids_endf::resonance::{ParticlePair, RmlChannel, RmlData, SpinGroup};
1023
1024        // Entrance channel: neutron (Z=0) + W-184 target (Z=74).
1025        // ENDF LRF=7 stores charge directly: neutron za=0, W-184 zb=74.
1026        let pp_entrance = ParticlePair {
1027            ma: 1.0,
1028            mb: 184.0,
1029            za: 0.0,  // neutron charge Z=0
1030            zb: 74.0, // W-184 charge Z=74
1031            ia: 0.5,
1032            ib: 0.0,
1033            q: 0.0,
1034            pnt: 1,
1035            shf: 0,
1036            mt: 2,
1037            pa: 1.0,
1038            pb: 1.0,
1039        };
1040
1041        // Coulomb exit channel: α(Z=2) + O-16(Z=8), Q=+50 eV → always open.
1042        // sommerfeld_eta(2, 8, ...) gives η > 0, confirming Coulomb branch.
1043        let pp_coulomb_open = ParticlePair {
1044            ma: 4.0,
1045            mb: 16.0,
1046            za: 2.0, // alpha charge Z=2
1047            zb: 8.0, // O-16 charge Z=8
1048            ia: 0.0,
1049            ib: 0.0,
1050            q: 50.0, // Q > 0 → e_c = e_cm + 50 > 0 for all positive energies
1051            pnt: 1,
1052            shf: 0,
1053            mt: 22, // (n,α)
1054            pa: 1.0,
1055            pb: 1.0,
1056        };
1057
1058        // Coulomb exit channel with Q very negative → closed at all reasonable energies.
1059        let pp_coulomb_closed = ParticlePair {
1060            ma: 4.0,
1061            mb: 16.0,
1062            za: 2.0, // alpha charge Z=2
1063            zb: 8.0, // O-16 charge Z=8
1064            ia: 0.0,
1065            ib: 0.0,
1066            q: -1e6, // far below threshold
1067            pnt: 1,
1068            shf: 0,
1069            mt: 22,
1070            pa: 1.0,
1071            pb: 1.0,
1072        };
1073
1074        // Build and evaluate the open-channel case.
1075        for (desc, pp_exit, expect_positive_total) in [
1076            ("open Coulomb exit", &pp_coulomb_open, true),
1077            ("closed Coulomb exit", &pp_coulomb_closed, false),
1078        ] {
1079            let ch0 = RmlChannel {
1080                particle_pair_idx: 0,
1081                l: 0,
1082                channel_spin: 0.5,
1083                boundary: 0.0,
1084                effective_radius: 8.3,
1085                true_radius: 8.3,
1086            };
1087            let ch1 = RmlChannel {
1088                particle_pair_idx: 1,
1089                l: 0,
1090                channel_spin: 0.5,
1091                boundary: 0.0,
1092                effective_radius: 5.0,
1093                true_radius: 5.0,
1094            };
1095            let sg = SpinGroup {
1096                j: 0.5,
1097                parity: 1.0,
1098                channels: vec![ch0, ch1],
1099                resonances: vec![],
1100                has_background_correction: false,
1101            };
1102            let rml = RmlData {
1103                target_spin: 0.0,
1104                awr: 183.0,
1105                scattering_radius: 8.3,
1106                krm: 2,
1107                particle_pairs: vec![pp_entrance.clone(), pp_exit.clone()],
1108                spin_groups: vec![sg],
1109            };
1110
1111            let (tot, _elas, _cap, _fis) = cross_sections_for_rml_range(&rml, 100.0);
1112            assert!(tot >= 0.0, "{desc}: σ_total = {tot:.6} b must be ≥ 0");
1113            if expect_positive_total {
1114                // Hard-sphere entrance channel alone gives positive σ_total
1115                // (the Coulomb channel merely adds a second channel but no resonances).
1116                assert!(
1117                    tot > 0.0,
1118                    "{desc}: σ_total = {tot} b should be > 0 (hard-sphere entrance channel)"
1119                );
1120            }
1121        }
1122    }
1123
1124    /// Singular Y-matrix regularization: construct a KRM=2 spin group where
1125    /// the R-matrix nearly cancels L⁻¹ at the resonance energy, making
1126    /// the Y-matrix nearly singular.  The relative-epsilon regularization
1127    /// must produce finite, non-negative cross-sections (no NaN/Inf).
1128    #[test]
1129    fn test_rml_singular_y_matrix_regularization() {
1130        use nereids_endf::resonance::{ParticlePair, RmlChannel, RmlData, RmlResonance, SpinGroup};
1131
1132        let pp = ParticlePair {
1133            ma: 1.0,
1134            mb: 184.0,
1135            za: 0.0,
1136            zb: 74.0,
1137            ia: 0.5,
1138            ib: 0.0,
1139            q: 0.0,
1140            pnt: 1,
1141            shf: 0,
1142            mt: 2,
1143            pa: 1.0,
1144            pb: 1.0,
1145        };
1146        let ch = RmlChannel {
1147            particle_pair_idx: 0,
1148            l: 0,
1149            channel_spin: 0.5,
1150            boundary: 0.0,
1151            effective_radius: 8.3,
1152            true_radius: 8.3,
1153        };
1154        // KRM=2: widths are reduced amplitudes γ_nc.
1155        // A very large reduced amplitude γ at E_r makes R ≈ γ²/(E_r - E)
1156        // huge near E_r, potentially cancelling L⁻¹ on the diagonal.
1157        let sg = SpinGroup {
1158            j: 0.5,
1159            parity: 1.0,
1160            channels: vec![ch],
1161            resonances: vec![RmlResonance {
1162                energy: 100.0,
1163                gamma_gamma: 0.0,  // KRM=2 has no implicit capture
1164                widths: vec![1e5], // very large reduced amplitude
1165            }],
1166            has_background_correction: false,
1167        };
1168        let rml = RmlData {
1169            target_spin: 0.0,
1170            awr: 183.0,
1171            scattering_radius: 8.3,
1172            krm: 2,
1173            particle_pairs: vec![pp],
1174            spin_groups: vec![sg],
1175        };
1176
1177        // Evaluate exactly at the resonance energy (worst case for singularity).
1178        let (tot, elas, cap, _fis) = cross_sections_for_rml_range(&rml, 100.0);
1179        assert!(
1180            tot.is_finite() && tot >= 0.0,
1181            "σ_total must be finite and ≥ 0 near singular Y-matrix, got {tot}"
1182        );
1183        assert!(
1184            elas.is_finite() && elas >= 0.0,
1185            "σ_elastic must be finite and ≥ 0, got {elas}"
1186        );
1187        assert!(cap.is_finite(), "σ_capture must be finite, got {cap}");
1188    }
1189
1190    /// Verify W-184 cross-sections show resonance structure.
1191    ///
1192    /// Downloads W-184 ENDF/B-VIII.0, parses LRF=7 parameters,
1193    /// then checks that σ_total at the first resonance (~101.9 eV) is
1194    /// significantly larger than the background off-resonance.
1195    ///
1196    /// Run with: cargo test -p nereids-physics -- --ignored test_w184_cross_section_peak
1197    #[test]
1198    #[ignore = "requires network: downloads W-184 ENDF from IAEA (~50 kB)"]
1199    fn test_w184_cross_section_peak() {
1200        use crate::reich_moore::cross_sections_at_energy;
1201        use nereids_core::types::Isotope;
1202        use nereids_endf::parser::parse_endf_file2;
1203        use nereids_endf::retrieval::{EndfLibrary, EndfRetriever};
1204
1205        let retriever = EndfRetriever::new();
1206        let isotope = Isotope::new(74, 184).unwrap();
1207        let (_, text) = retriever
1208            .get_endf_file(&isotope, EndfLibrary::EndfB8_0, 7437)
1209            .expect("Failed to download W-184 ENDF/B-VIII.0");
1210
1211        let data = parse_endf_file2(&text).expect("Failed to parse W-184 ENDF");
1212
1213        // W-184 ENDF/B-VIII.0 (KRM=3, 3 spin groups J=1/2+, J=1/2-, J=3/2-):
1214        //   First positive resonance in J=1/2+ spin group: ~101.9 eV
1215        //   Background between the -386 eV bound state and 101.9 eV resonance: ~0.07 b
1216        // Test that σ_total at the 101.9 eV resonance peak >> background at 50 eV.
1217        let xs_on_res = cross_sections_at_energy(&data, 101.9);
1218        let xs_off_res = cross_sections_at_energy(&data, 50.0);
1219
1220        // Background must be non-negative (guards against the A=L-R sign error
1221        // that gave σ(50 eV) = −0.228 b).
1222        assert!(
1223            xs_off_res.total >= 0.0,
1224            "σ_total at 50 eV must be non-negative (hard-sphere background), got {:.4} b",
1225            xs_off_res.total
1226        );
1227        assert!(
1228            xs_on_res.total > 0.0,
1229            "σ_total at 101.9 eV should be positive, got {}",
1230            xs_on_res.total
1231        );
1232        assert!(
1233            xs_on_res.capture >= 0.0,
1234            "σ_capture at 101.9 eV should be non-negative, got {}",
1235            xs_on_res.capture
1236        );
1237        assert!(
1238            xs_on_res.total > xs_off_res.total * 5.0,
1239            "Resonance peak at 101.9 eV should be >5× the 50 eV background: \
1240             σ(101.9)={:.3} vs σ(50.0)={:.3} barns",
1241            xs_on_res.total,
1242            xs_off_res.total
1243        );
1244
1245        println!(
1246            "W-184 σ_total: {:.3} b at 101.9 eV (resonance), {:.3} b at 50.0 eV (background)",
1247            xs_on_res.total, xs_off_res.total
1248        );
1249        println!(
1250            "W-184 σ_capture: {:.4} b, σ_elastic: {:.4} b at 101.9 eV",
1251            xs_on_res.capture, xs_on_res.elastic
1252        );
1253    }
1254}