Skip to main content

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 (eq. III.D.4 in SAMMY-manual-R3 numbering, as cited by
36//! `rml/mrml11.f`; R8: W is Eqs. (II B1.3)-(II B1.4), and U = Ω·W·Ω′ is
37//! Eq. (II A.4) with Ω from Eq. (II A.5)):
38//!   W_cc' = δ_cc' + 2i·Ξ_cc'
39//!   U_cc' = Ω_c · W_cc' · Ω_c'    where Ω_c = exp(-iφ_c)
40//!
41//!   TRUTH SOURCE: SAMMY rml/mrml11.f lines 14-18 (W = I + 2i·XXXX)
42//!   and lines 84-88 (elastic formula consistent with e^{-2iφ}).
43//!   Unitarity: |U| ≤ 1 always; hard sphere (R=0) → U = exp(2iφ)·I  ✓
44//!
45//! Cross sections per spin group (J,π), summed over entrance neutron channels c0:
46//!   σ_total   = Σ_{c0} 2·(π/k²)·g_J·(1 - Re(U_{c0,c0}))
47//!   σ_elastic = Σ_{c0} (π/k²)·g_J·|1 - U_{c0,c0}|²
48//!   σ_fission = Σ_{c0} Σ_{c'∈fission} (π/k²)·g_J·|U_{c0,c'}|²
49//!   σ_capture = σ_total - σ_elastic - σ_fission
50//! ```
51//!
52//! ## No-penetrability channels (PNT / Lpent = 0)
53//!
54//! The branch is keyed on the per-pair `PNT` (SAMMY `Lpent`) flag, NOT on
55//! particle mass. For a `PNT=0` channel — the photon/eliminated channel always,
56//! plus any pair flagged no-penetrability — the penetrability is set to 1.0, the
57//! shift to `S_c = B_c` (so `S_c − B_c = 0`), and the phase to 0.0. This encodes
58//! SAMMY's `Ymat(2,Ii) -= 1` (`rml/mrml07.f:118-122`): `L_c = (S_c−B_c)+iP_c = i`
59//! so `1/L_c = −i`.
60//!
61//! ## SAMMY Reference
62//! - `rml/mrml01.f` — LRF=7 reader (Scan_File_2, particle pair loop)
63//! - `rml/mrml09.f` — Level matrix inversion (Yinvrs, Xspfa, Xspsl)
64//! - `rml/mrml11.f` — Cross-section calculation (Sectio, Setxqx)
65//! - SAMMY manual §3.1 (multi-channel R-matrix)
66
67use num_complex::Complex64;
68
69use nereids_core::constants::{LOG_FLOOR, NEAR_ZERO_FLOOR, PIVOT_FLOOR, QUANTUM_NUMBER_EPS};
70use nereids_endf::resonance::{ParticlePair, RmlData, SpinGroup};
71
72use crate::{channel, coulomb, penetrability};
73
74/// Pre-allocated workspace for RML cross-section evaluation.
75///
76/// Eliminates per-energy-point allocation of NCH×NCH complex matrices
77/// (`r_cplx`, `y_tilde`, `y_inv`, `xq`, `xxxx`, `u`) and per-channel
78/// vectors (`p_c`, `s_c`, `phi_c`, flags, etc.).
79///
80/// Flat `Vec<Complex64>` buffers store matrices in row-major order.
81/// For typical NCH=3-6, this avoids ~12 small heap allocations per
82/// energy point per spin group.
83struct RmlWorkspace {
84    // ── NCH×NCH complex matrix buffers (flat, row-major) ──────────────
85    r_cplx: Vec<Complex64>,
86    y_tilde: Vec<Complex64>,
87    y_inv: Vec<Complex64>,
88    xq: Vec<Complex64>,
89    xxxx: Vec<Complex64>,
90    u: Vec<Complex64>,
91    // ── Augmented matrix for Gauss-Jordan inversion (NCH × 2·NCH) ─────
92    aug: Vec<Complex64>,
93    // ── Temp row for elimination ───────────────────────────────────────
94    aug_tmp: Vec<Complex64>,
95    // ── Per-channel vectors ───────────────────────────────────────────
96    p_c: Vec<f64>,
97    s_c: Vec<f64>,
98    phi_c: Vec<f64>,
99    l_c: Vec<Complex64>,
100    sqrt_p: Vec<f64>,
101    omega: Vec<Complex64>,
102    is_entrance: Vec<bool>,
103    is_fission: Vec<bool>,
104    is_capture: Vec<bool>,
105    is_inelastic: Vec<bool>,
106    is_closed: Vec<bool>,
107    // ── Per-resonance scratch ─────────────────────────────────────────
108    gamma_vals: Vec<f64>,
109}
110
111impl RmlWorkspace {
112    fn new() -> Self {
113        Self {
114            r_cplx: Vec::new(),
115            y_tilde: Vec::new(),
116            y_inv: Vec::new(),
117            xq: Vec::new(),
118            xxxx: Vec::new(),
119            u: Vec::new(),
120            aug: Vec::new(),
121            aug_tmp: Vec::new(),
122            p_c: Vec::new(),
123            s_c: Vec::new(),
124            phi_c: Vec::new(),
125            l_c: Vec::new(),
126            sqrt_p: Vec::new(),
127            omega: Vec::new(),
128            is_entrance: Vec::new(),
129            is_fission: Vec::new(),
130            is_capture: Vec::new(),
131            is_inelastic: Vec::new(),
132            is_closed: Vec::new(),
133            gamma_vals: Vec::new(),
134        }
135    }
136
137    /// Resize all buffers for a spin group with `nch` channels and
138    /// `max_widths` resonance width entries, then zero them out.
139    fn resize_and_clear(&mut self, nch: usize, max_widths: usize) {
140        let nn = nch * nch;
141        let aug_len = nch * 2 * nch;
142
143        // Complex matrix buffers — resize then fill with zero.
144        resize_and_zero(&mut self.r_cplx, nn);
145        resize_and_zero(&mut self.y_tilde, nn);
146        resize_and_zero(&mut self.y_inv, nn);
147        resize_and_zero(&mut self.xq, nn);
148        resize_and_zero(&mut self.xxxx, nn);
149        resize_and_zero(&mut self.u, nn);
150        resize_and_zero(&mut self.aug, aug_len);
151        resize_and_zero(&mut self.aug_tmp, 2 * nch);
152
153        // Per-channel real/bool vectors.
154        resize_and_zero_f64(&mut self.p_c, nch);
155        resize_and_zero_f64(&mut self.s_c, nch);
156        resize_and_zero_f64(&mut self.phi_c, nch);
157        resize_and_zero(&mut self.l_c, nch);
158        resize_and_zero_f64(&mut self.sqrt_p, nch);
159        resize_and_zero(&mut self.omega, nch);
160        resize_and_zero_bool(&mut self.is_entrance, nch);
161        resize_and_zero_bool(&mut self.is_fission, nch);
162        resize_and_zero_bool(&mut self.is_capture, nch);
163        resize_and_zero_bool(&mut self.is_inelastic, nch);
164        resize_and_zero_bool(&mut self.is_closed, nch);
165
166        // Per-resonance scratch.
167        resize_and_zero_f64(&mut self.gamma_vals, max_widths);
168    }
169}
170
171fn resize_and_zero(buf: &mut Vec<Complex64>, len: usize) {
172    buf.clear();
173    buf.resize(len, Complex64::ZERO);
174}
175
176fn resize_and_zero_f64(buf: &mut Vec<f64>, len: usize) {
177    buf.clear();
178    buf.resize(len, 0.0);
179}
180
181fn resize_and_zero_bool(buf: &mut Vec<bool>, len: usize) {
182    buf.clear();
183    buf.resize(len, false);
184}
185
186/// Compute cross-section contributions from an LRF=7 energy range.
187///
188/// Returns `(total, elastic, capture, fission)` in barns.
189///
190/// Iterates over all spin groups (J,π), sums their contributions.
191/// A single `RmlWorkspace` is allocated once and reused across spin groups.
192///
193/// # Panics
194/// Panics if `energy_ev` is not finite or is non-positive.  This is a
195/// defensive guard at the public boundary; the Python wrapper and the
196/// SAMMY-style dispatcher already validate the energy grid via
197/// `validate_energy_grid`, so this assertion only fires for direct
198/// callers (other Rust crates, tests) that bypass the grid check.
199pub fn cross_sections_for_rml_range(rml: &RmlData, energy_ev: f64) -> (f64, f64, f64, f64) {
200    // Defensive input validation at the public boundary (issue #558).
201    // See `urr::urr_cross_sections` for rationale.  Catches malformed
202    // energies before any spin-group iteration, where empty spin-group
203    // vecs would otherwise silently return (0, 0, 0, 0) for NaN/∞.
204    assert!(
205        energy_ev.is_finite() && energy_ev > 0.0,
206        "expected positive finite energy_ev, got {energy_ev}"
207    );
208
209    let mut total = 0.0;
210    let mut elastic = 0.0;
211    let mut capture = 0.0;
212    let mut fission = 0.0;
213
214    let mut ws = RmlWorkspace::new();
215
216    for sg in &rml.spin_groups {
217        let (t, e, cap, fis) = spin_group_cross_sections(
218            sg,
219            &rml.particle_pairs,
220            energy_ev,
221            rml.awr,
222            rml.target_spin,
223            rml.krm,
224            &mut ws,
225        );
226        total += t;
227        elastic += e;
228        capture += cap;
229        fission += fis;
230    }
231
232    (total, elastic, capture, fission)
233}
234
235/// Cross-section contribution from a single spin group (J,π).
236///
237/// Returns (total, elastic, capture, fission) in barns.
238///
239/// The caller-owned `ws` workspace is resized and reused across spin groups
240/// for a given energy evaluation to avoid per-call heap allocations.
241fn spin_group_cross_sections(
242    sg: &SpinGroup,
243    particle_pairs: &[ParticlePair],
244    energy_ev: f64,
245    awr: f64,
246    target_spin: f64,
247    krm: u32,
248    ws: &mut RmlWorkspace,
249) -> (f64, f64, f64, f64) {
250    let nch = sg.channels.len();
251    if nch == 0 {
252        return (0.0, 0.0, 0.0, 0.0);
253    }
254
255    // KRM guard: the parser rejects KRM values other than 2 and 3 at load time,
256    // so reaching here with an unsupported KRM indicates a programming error.
257    // Panic rather than silently returning zero physics, which would look valid
258    // to callers.  Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml01.f KRM field.
259    assert!(
260        krm == 2 || krm == 3,
261        "spin_group_cross_sections called with unsupported KRM={krm}; \
262         should have been rejected at parse time"
263    );
264
265    // P2b: NRS=0 is valid — the R-matrix is zero but the hard-sphere phase shift
266    // still produces nonzero potential-scattering cross sections (σ_el = 4π|Ω-1|²/k²
267    // per spin group).  Do NOT return early here; the resonance loop simply executes
268    // zero iterations, leaving R = 0, which is exactly the hard-sphere limit.
269
270    // Resize workspace buffers for this spin group's channel count.
271    let max_widths = sg
272        .resonances
273        .iter()
274        .map(|r| r.widths.len())
275        .max()
276        .unwrap_or(nch)
277        .max(nch);
278    ws.resize_and_clear(nch, max_widths);
279
280    let g_j = channel::statistical_weight(sg.j, target_spin);
281    let pok2 = channel::pi_over_k_squared_barns(energy_ev, awr);
282
283    // Entrance-channel CM energy: E_cm = E_lab × AWR/(1+AWR).
284    // Each exit channel adds its Q-value to get its own available energy.
285    // Reference: SAMMY rml/mrml03.f Fxradi — channel thresholds via Q.
286    let e_cm = channel::lab_to_cm_energy(energy_ev, awr);
287
288    for (c, ch) in sg.channels.iter().enumerate() {
289        // P3: particle_pair_idx must be a valid index. The old `.min(len-1)` clamped
290        // silently, misclassifying any channel with an OOB index as the last pair.
291        // An OOB value indicates corrupted ENDF data; let Rust's bounds check panic.
292        let pp = &particle_pairs[ch.particle_pair_idx];
293        ws.is_entrance[c] = pp.mt == 2;
294        ws.is_fission[c] = pp.mt == 18;
295        ws.is_capture[c] = pp.mt == 102;
296        // Inelastic neutron channels (MT=51+): massive particle, not elastic/fission/capture.
297        // Their flux appears in σ_total (optical theorem) but must not be assigned to capture.
298        // Reference: ENDF MT number conventions, §3.4; SAMMY rml/mrml11.f Sectio.
299        ws.is_inelastic[c] =
300            pp.ma >= 0.5 && !ws.is_entrance[c] && !ws.is_fission[c] && !ws.is_capture[c];
301
302        // SAMMY rml/mrml07.f:118-122 (and mrml03.f:235) branch on the per-pair
303        // penetrability flag Lpent (= pp.pnt), NOT on particle mass.  PNT≠1 is
304        // the "no penetrability" branch: it covers photon/eliminated channels
305        // (MA=0) and any pair the evaluation flags PNT=0.  SAMMY implements it as
306        // `Ymat(2,Ii) -= 1`; NEREIDS encodes the same thing as P_c=1, S_c=B_c
307        // (so S_c−B_c=0), φ_c=0 → L_c = (S_c−B_c)+iP_c = i → 1/L_c = −i, i.e. −1
308        // on the imaginary level-matrix diagonal.  The parser guarantees a
309        // massless pair carries PNT=0, so the massive-kinematics branch below
310        // (which needs a nonzero reduced mass) is never reached by a photon.
311        if pp.pnt != 1 {
312            ws.p_c[c] = 1.0;
313            ws.s_c[c] = ch.boundary;
314            ws.phi_c[c] = 0.0;
315        } else {
316            // Massive particle channel: channel-specific kinematics (P1).
317            // E_c = E_cm + Q (CM kinetic energy in this exit channel).
318            // Reference: SAMMY rml/mrml03.f Fxradi — Zke = Twomhb*sqrt(Redmas*Factor)
319            let e_c = e_cm + pp.q;
320            if e_c <= 0.0 {
321                // Closed channel (below threshold): P_c = 0, φ_c = 0.
322                // S_c depends on SHF:
323                //   SHF=0: convention is S_c = B_c; L_c = 0 when B_c = 0 (common).
324                //   SHF=1: S_c is the analytic shift factor at imaginary argument
325                //     ρ = iκ, which is real and finite.  L_c = (S_c − B_c) is generally
326                //     non-zero and its dispersive contribution must be preserved.
327                // Reference: SAMMY rml/mrml07.f Pgh — PH = 1/(S−B+iP).
328                ws.p_c[c] = 0.0;
329                ws.phi_c[c] = 0.0;
330                ws.is_closed[c] = true;
331                // SHF=0: S_c = B_c so (S_c − B_c) = 0 in the level matrix.
332                // SHF=1: S_c is the analytic shift at imaginary argument ρ = iκ.
333                //   For non-Coulomb channels we use the Blatt-Weisskopf formula.
334                //   For Coulomb channels the imaginary-argument Coulomb shift is
335                //   not yet implemented; fall back to S_c = B_c.  This matches
336                //   SAMMY's convention: mrml07.f ELSE branch (Su ≤ Echan) sets
337                //   Elinvr=1/Elinvi=0 (i.e. L_c = 1) for all closed channels,
338                //   Coulomb and non-Coulomb alike, without calling Pghcou.
339                let is_coulomb = pp.za.abs() > 0.5 && pp.zb.abs() > 0.5;
340                ws.s_c[c] = if pp.shf == 1 && !is_coulomb {
341                    let redmas = pp.ma * pp.mb / (pp.ma + pp.mb);
342                    let kappa = channel::wave_number_from_cm(e_c.abs(), redmas);
343                    // Shift factor uses APT (true radius), per SAMMY rml/mrml07.f
344                    // Rho = Zkte·Ex with Zkte = Z·Rdtru (mrml03.f:174-177).
345                    penetrability::shift_factor_closed(ch.l, kappa * ch.true_radius)
346                } else {
347                    ch.boundary
348                };
349            } else {
350                // Channel wave number from reduced mass μ = MA·MB/(MA+MB).
351                // For elastic (MA=1, MB=AWR): k_c = wave_number(E_lab, AWR) [identical].
352                let redmas = pp.ma * pp.mb / (pp.ma + pp.mb);
353                let k_c = channel::wave_number_from_cm(e_c, redmas);
354                // SAMMY radius convention (rml/mrml07.f:118-166, mrml03.f:174-177):
355                //   Rho  = Zkte·Ex  (Zkte = Z·Rdtru = APT, true radius)
356                //   Rhof = Zkfe·Ex  (Zkfe = Z·Rdeff = APE, effective radius)
357                // Penetrability P and shift S always use APT (Rho).  The EFFECTIVE
358                // radius (APE, Rhof) drives the phase φ ONLY for the non-Coulomb
359                // (hard-sphere) branch, via Sinsix (mrml07.f:131-137).  For a Coulomb
360                // channel SAMMY passes Rho (APT) to Pghcou for P, S AND φ; Rhof/APE is
361                // computed but never used (mrml07.f:144-161 — both Pghcou calls pass
362                // Rho).  Radius roles confirmed by an independent SAMMY-source
363                // derivation; cf. PLEIADES models.py:385-386.
364                let rho_pen = k_c * ch.true_radius; // APT → P_c, S_c, and Coulomb φ_c
365                let rho_phase = k_c * ch.effective_radius; // APE → non-Coulomb φ_c only
366                // ── Coulomb vs hard-sphere routing ───────────────────────────
367                // SAMMY rml/mrml07.f Pgh — `if (Zeta(I).NE.Zero)` branch.
368                // Both particles charged → Coulomb wave functions F_L / G_L.
369                // One neutral (za=0 or zb=0) → hard-sphere Blatt-Weisskopf.
370                if pp.za.abs() > 0.5 && pp.zb.abs() > 0.5 {
371                    // Coulomb channel (e.g. n+α→p+X, (n,p), fission fragments).
372                    // SAMMY computes P_c, S_c AND φ_c from a single radius Rho (APT)
373                    // via Pghcou; the effective radius (Rhof/APE) is NOT used for a
374                    // Coulomb channel (mrml07.f:144-161 — both Pghcou calls pass Rho).
375                    let eta = coulomb::sommerfeld_eta(pp.za, pp.zb, pp.ma, pp.mb, e_c);
376                    match coulomb::coulomb_wave_functions(ch.l, eta, rho_pen) {
377                        Some((f, g, fp, gp)) => {
378                            // rho_pen (APT) succeeded: channel is genuinely open.
379                            let fg_sq = f * f + g * g;
380                            ws.p_c[c] = rho_pen / fg_sq;
381                            // SHF=1: Coulomb shift ρ(F·F'+G·G')/(F²+G²).
382                            // SHF=0: S_c = B_c so (S_c − B_c) = 0 in level matrix.
383                            // Note: parser rejects Coulomb + SHF=1, so this arm is
384                            // only reachable if that validation is later relaxed.
385                            ws.s_c[c] = if pp.shf == 1 {
386                                rho_pen * (f * fp + g * gp) / fg_sq
387                            } else {
388                                ch.boundary
389                            };
390                            // φ_c = atan2(F_L, G_L) from the SAME (F,G) solve at rho_pen
391                            // (APT).  SAMMY's second Pghcou call reuses Rho, never Rhof,
392                            // so the Coulomb phase is independent of APE.
393                            //
394                            // No dedicated regression test exists for this radius
395                            // choice because it is unobservable in the angle-integrated
396                            // cross-sections NEREIDS computes: an exit channel's phase
397                            // enters only via Ω_c = e^{-iφ_c}, and σ_total uses the
398                            // entrance U_{c0,c0} (entrance φ only) while σ_reaction uses
399                            // |U_{c0,c'}|² (phase-independent magnitude).  Coulomb
400                            // channels are always exit channels (entrance is mt=2,
401                            // non-Coulomb).  The fix is for SAMMY-faithfulness and would
402                            // matter only if angular distributions were added.
403                            ws.phi_c[c] = f.atan2(g);
404                        }
405                        None => {
406                            // rho_pen ≤ acch (≈ 1e-8, SAMMY Coulfg threshold):
407                            // penetrability → 0 at threshold; treat as closed.
408                            // Reference: SAMMY coulomb/mrml08.f90 Coulfg — acch.
409                            ws.p_c[c] = 0.0;
410                            ws.s_c[c] = ch.boundary;
411                            ws.phi_c[c] = 0.0;
412                            ws.is_closed[c] = true;
413                        }
414                    }
415                } else {
416                    // Hard-sphere (Blatt-Weisskopf) channel.
417                    ws.p_c[c] = penetrability::penetrability(ch.l, rho_pen);
418                    // SHF=0: shift factor not calculated; S_c = B_c so (S_c - B_c) = 0
419                    // in the level matrix diagonal.
420                    // SHF=1: calculate S_c analytically (Blatt-Weisskopf).
421                    // Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml07.f Pgh (Ishift check)
422                    ws.s_c[c] = if pp.shf == 1 {
423                        penetrability::shift_factor(ch.l, rho_pen)
424                    } else {
425                        ch.boundary
426                    };
427                    ws.phi_c[c] = penetrability::phase_shift(ch.l, rho_phase);
428                }
429            }
430        }
431    }
432
433    // ── R-matrix (complex for KRM=3, real for KRM=2) ─────────────────────────
434    // KRM=2 (standard R-matrix):
435    //   R_cc'(E) = Σ_n γ_nc · γ_nc' / (E_n - E)   [real, reduced amplitude widths]
436    //
437    // KRM=3 (Reich-Moore approximation):
438    //   R_cc'(E) = Σ_n γ_nc · γ_nc' / (Ẽ_n - E)   [complex, Ẽ_n = E_n - i·Γ_γn/2]
439    //   where γ_nc = √(Γ_nc / (2·P_c(E_n))) (partial width → reduced amplitude).
440    //   The imaginary shift makes capture implicit — |U| < 1, with missing flux
441    //   going to capture.
442    //
443    // Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml07.f Setr subroutine
444    // ws.r_cplx is already zeroed by resize_and_clear.
445    for res in &sg.resonances {
446        let e_tilde = if krm == 3 {
447            // KRM=3 (Reich-Moore): convert formal partial widths Γ_nc to reduced
448            // amplitudes γ_nc and form the complex pole energy Ẽ_n = E_n − i·Γ_γ/2
449            // (the −iΓ_γ/2 makes capture implicit).  Reference: ENDF-6 §2.2.1.6;
450            // SAMMY rml/mrml01.f, rml/mrml03.f (Betset).
451            let e_tilde = Complex64::new(res.energy, -res.gamma_gamma / 2.0);
452            // γ_nc is a property of the resonance, evaluated at the resonance
453            // energy E_n (not the incident energy E), so it is energy-independent.
454            // SAMMY Betset (rml/mrml03.f:235-274) branches on the per-pair
455            // penetrability flag Lpent (= pp.pnt), NOT on particle mass:
456            //   PNT=0: γ_nc = √(½|Γ|)                                 (mrml03.f:236)
457            //   PNT=1: γ_nc = √(½|Γ| / P),  P at ρ = κ·APT, κ from |Eres−Echan|
458            //                                                         (mrml03.f:241-273)
459            // The |Eres−Echan| absolute value means bound/subthreshold resonances
460            // (e_cm_n ≤ 0) still receive a real, positive penetrability — they are
461            // NOT treated as closed channels.  The sign of Γ is preserved either way.
462            for c in 0..nch {
463                let gamma_formal = res.widths[c];
464                let ch = &sg.channels[c];
465                let pp_c = &particle_pairs[ch.particle_pair_idx];
466                let magnitude = if pp_c.pnt != 1 {
467                    // No-penetrability branch (Lpent=0): photon/eliminated channels
468                    // and any pair flagged PNT=0.  γ = √(½|Γ|).  mrml03.f:236.
469                    (0.5 * gamma_formal.abs()).sqrt()
470                } else {
471                    // Penetrability branch (Lpent=1).  ρ = κ·APT evaluated at the
472                    // absolute CM-energy separation |Eres−Echan| = |e_cm_n|, so a
473                    // bound state (e_cm_n < 0) still gets a real P.  Radius is APT
474                    // (true_radius) per Betset's Zkte = Z·Rdtru (mrml03.f:244).
475                    let e_cm_n = channel::lab_to_cm_energy(res.energy, awr) + pp_c.q;
476                    let ex = e_cm_n.abs();
477                    let redmas = pp_c.ma * pp_c.mb / (pp_c.ma + pp_c.mb);
478                    let k_cn = channel::wave_number_from_cm(ex, redmas);
479                    let rho_pen_n = k_cn * ch.true_radius; // APT
480                    // Same Coulomb-vs-hard-sphere routing as the open-channel block
481                    // so the normalisation is self-consistent.  SAMMY rml/mrml07.f
482                    // Pgh — same Zeta check applies here.
483                    let p = if pp_c.za.abs() > 0.5 && pp_c.zb.abs() > 0.5 {
484                        let eta = coulomb::sommerfeld_eta(pp_c.za, pp_c.zb, pp_c.ma, pp_c.mb, ex);
485                        coulomb::coulomb_wave_functions(ch.l, eta, rho_pen_n)
486                            .map_or(0.0, |(fl, gl, _, _)| rho_pen_n / (fl * fl + gl * gl))
487                    } else {
488                        penetrability::penetrability(ch.l, rho_pen_n)
489                    };
490                    // P = 0 only at the exact channel threshold (Ex = 0) or below
491                    // the Coulomb acch cutoff — a measure-zero singularity.  Fall
492                    // back to the no-penetrability form √(½|Γ|) to keep γ finite.
493                    if p > 0.0 {
494                        (0.5 * gamma_formal.abs() / p).sqrt()
495                    } else {
496                        (0.5 * gamma_formal.abs()).sqrt()
497                    }
498                };
499                ws.gamma_vals[c] = magnitude.copysign(gamma_formal);
500            }
501            e_tilde
502        } else {
503            // KRM=2: widths are already reduced amplitudes; real denominator.
504            // P2: Guard only against exact IEEE 754 zero; complex infrastructure
505            // handles the Lorentzian width naturally via i·P_c in level matrix.
506            ws.gamma_vals[..nch].copy_from_slice(&res.widths[..nch]);
507            Complex64::new(res.energy, 0.0)
508        };
509
510        let denom = e_tilde - energy_ev;
511        // Near-pole regularization: add a tiny imaginary offset ε to the denominator
512        // so that evaluating exactly at E = E_n (where denom → 0 for real KRM=2 poles)
513        // gives a finite, physically meaningful result via the Cauchy principal value.
514        // For KRM=3, e_tilde already carries −iΓ_γ/2 so the denominator is never zero;
515        // the correction is negligible (ε << Γ_γ/2).
516        // Reference: Cauchy PV regularisation; SAMMY avoids the exact pole by perturbing
517        // the resonance energy during input processing.
518        let inv_denom = if denom.norm() < QUANTUM_NUMBER_EPS {
519            (denom + Complex64::new(0.0, QUANTUM_NUMBER_EPS)).inv()
520        } else {
521            denom.inv()
522        };
523        for c in 0..nch {
524            // SAMMY gates the R-matrix accumulation on BOTH channels being
525            // open: rml/mrml07.f Setr lines 67-71 —
526            //   IF (Su.GT.Echan(K) .AND. Su.GT.Echan(L) .AND.
527            //     Beta(KL,Ires).NE.Zero) THEN
528            //       Rmat(1,KL) = Rmat(1,KL) + Alphar(Ires)*Beta(KL,Ires)
529            //       ...
530            // `Su.GT.Echan(K)` is exactly the open-channel test (line 118), which
531            // NEREIDS encodes as `!ws.is_closed[K]` (set in the channel-setup
532            // loop above for e_c ≤ 0 and the Coulomb-threshold case).  A channel
533            // below its threshold contributes nothing to R, including off-diagonal
534            // terms via a shared resonance width.  Skipping the whole row when c
535            // is closed avoids forming R[c,cp] for any cp.
536            if ws.is_closed[c] {
537                continue;
538            }
539            let gc = ws.gamma_vals[c];
540            for cp in 0..nch {
541                // Gate the off-/on-diagonal term on the partner channel cp also
542                // being open (the Su.GT.Echan(L) half of the SAMMY condition).
543                // (The Beta≠0 half is automatic: a zero width product adds 0.)
544                if ws.is_closed[cp] {
545                    continue;
546                }
547                ws.r_cplx[c * nch + cp] += gc * ws.gamma_vals[cp] * inv_denom;
548            }
549        }
550    }
551
552    // ── L_c = (S_c - B_c) + i·P_c (per-channel level denominator) ───────────
553    // Reference: SAMMY rml/mrml07.f Pgh subroutine, "PH = 1/(S-B+IP)"
554    for c in 0..nch {
555        ws.l_c[c] = Complex64::new(ws.s_c[c] - sg.channels[c].boundary, ws.p_c[c]);
556    }
557
558    // ── Reduced level matrix Ỹ = L⁻¹ - R (SAMMY "Ymat") ─────────────────────
559    // Ỹ_cc'(E) = (1/L_c)·δ_cc' - R_cc'
560    // Reference: SAMMY rml/mrml07.f — "Ymat = (1/(S-B+IP) - Rmat)"
561    //
562    // NOTE: This is NOT (L - R). The SAMMY formulation inverts L⁻¹ - R, not
563    // L - R. Using L - R gives |U| = 3 for the hard sphere (R=0, A=iP,
564    // A⁻¹·P = -i/P·P = -i, W = 1+2i²·(−1)=3) — catastrophically wrong.
565    // Using L⁻¹ - R gives |U| = 1 for R=0 (Ỹ = 1/L, Ỹinv = L, XQ = L·0 = 0,
566    // XXXX = 0, W = 1, U = exp(2iφ)) — correct hard-sphere limit.
567    for c in 0..nch {
568        // L_c = (S_c − B_c) + i·P_c.
569        // For SHF=0 closed channels: S_c = B_c and P_c = 0 ⇒ L_c = 0.
570        // Correct limit: 1/L_c → ∞ ⇒ Ỹ[c,c] >> R[c,c] ⇒ Ỹ⁻¹[c,c] ≈ 0
571        // ⇒ channel decouples from U.  Setting 1/L_c = 0 (old bug) removes
572        // the diagonal and lets R dominate — wrong coupling / Ỹ singular.
573        //
574        // For SHF=1 or non-matching B_c, L_c is generally finite even when
575        // P_c = 0; the dispersive (real) shift must be preserved.  Do NOT
576        // force the sentinel just because the channel is sub-threshold; check
577        // whether |L_c| is actually near zero.
578        //
579        // Reference: SAMMY rml/mrml07.f — PH = 1/(S−B+iP).
580        let inv_l = if ws.l_c[c].norm_sqr() < NEAR_ZERO_FLOOR {
581            // |L_c|² < NEAR_ZERO_FLOOR: use finite-but-large sentinel so the diagonal
582            // dominates and the channel decouples without overflow in inversion.
583            Complex64::new(1e30, 0.0)
584        } else {
585            Complex64::new(1.0, 0.0) / ws.l_c[c]
586        };
587        for cp in 0..nch {
588            let diag = if c == cp { inv_l } else { Complex64::ZERO };
589            ws.y_tilde[c * nch + cp] = diag - ws.r_cplx[c * nch + cp];
590        }
591    }
592
593    // ── Invert Ỹ to get Ỹinv (SAMMY "Yinv") ─────────────────────────────────
594    // Reference: SAMMY rml/mrml09.f Yinvrs subroutine
595    if !invert_complex_matrix_flat(
596        &ws.y_tilde,
597        nch,
598        &mut ws.y_inv,
599        &mut ws.aug,
600        &mut ws.aug_tmp,
601    ) {
602        // Singular Ỹ matrix — regularize by adding a small real epsilon to
603        // the diagonal and retry.  This can happen when channels are
604        // near-degenerate (e.g. two channels at the same threshold).
605        // The epsilon is real-only to preserve Hermitian symmetry of the
606        // level matrix; an imaginary perturbation would break unitarity.
607        //
608        // Use a *relative* epsilon: ε = |diag| × QUANTUM_NUMBER_EPS, with a floor of
609        // NEAR_ZERO_FLOOR for zero diagonals.  A fixed absolute epsilon
610        // could be comparable to or larger than the diagonal value itself
611        // for high-L channels with very small penetrabilities (where
612        // 1/L_c ~ 1/P_c can be enormous, but R_cc' is also large, making
613        // the net diagonal small).  The relative approach perturbs the
614        // matrix by a fraction of its natural scale.
615        //
616        // Per-diagonal (not matrix-norm) regularization is intentional:
617        // each channel's diagonal element lives on its own physical scale
618        // (set by 1/L_c − R_cc), which can differ by orders of magnitude
619        // across channels (e.g. an s-wave elastic channel vs. a high-L
620        // fission channel).  A single matrix-norm epsilon would be
621        // dominated by the largest channel and could either over-perturb
622        // small channels or under-perturb large ones.  Per-diagonal
623        // epsilon ensures each channel is nudged proportionally to its
624        // own scale.
625
626        // Copy y_tilde into y_inv as a temp buffer for the regularized matrix.
627        ws.y_inv.copy_from_slice(&ws.y_tilde);
628        for i in 0..nch {
629            let diag_norm = ws.y_inv[i * nch + i].norm();
630            // Relative regularization (QUANTUM_NUMBER_EPS × diagonal) with an
631            // absolute floor of NEAR_ZERO_FLOOR for near-zero diagonals.
632            let eps = (diag_norm * QUANTUM_NUMBER_EPS).max(NEAR_ZERO_FLOOR);
633            ws.y_inv[i * nch + i] += Complex64::new(eps, 0.0);
634        }
635        // y_inv now holds the regularized y_tilde; copy it to y_tilde so the
636        // inversion reads from the regularized version.
637        ws.y_tilde.copy_from_slice(&ws.y_inv);
638        if !invert_complex_matrix_flat(
639            &ws.y_tilde,
640            nch,
641            &mut ws.y_inv,
642            &mut ws.aug,
643            &mut ws.aug_tmp,
644        ) {
645            return (0.0, 0.0, 0.0, 0.0); // truly degenerate
646        }
647    }
648
649    // ── XQ = Ỹinv · R (matrix product, SAMMY "Xqr/Xqi") ─────────────────────
650    // Reference: SAMMY rml/mrml11.f Setxqx — "Xqr(k,i) = (L**-1-R)**-1 * R"
651    for c in 0..nch {
652        for cp in 0..nch {
653            let mut sum = Complex64::ZERO;
654            for k in 0..nch {
655                sum += ws.y_inv[c * nch + k] * ws.r_cplx[k * nch + cp];
656            }
657            ws.xq[c * nch + cp] = sum;
658        }
659    }
660
661    // ── XXXX = (√P_c / L_c) · XQ · √P_c' ────────────────────────────────────
662    // Reference: SAMMY rml/mrml11.f Setxqx — "Xxxx = sqrt(P)/L * xq * sqrt(P)"
663    for c in 0..nch {
664        ws.sqrt_p[c] = ws.p_c[c].sqrt();
665    }
666    for c in 0..nch {
667        // For a closed channel: sqrt_p[c] = 0 and L_c = 0 (0/0 indeterminate).
668        // The full XXXX[c,cp] = (√P_c / L_c) · XQ[c,cp] · √P_c'.
669        // Since √P_c = 0 for any closed channel c, the entire row is zero
670        // regardless of the value of L_c. Setting sqrt_p_over_l = 0 is correct.
671        // (The Ỹ sentinel handles Ỹ inversion correctly; this row zeroing is
672        //  consistent: a closed channel contributes nothing to XXXX/U.)
673        // Guard: at exact channel threshold, both √P_c and L_c can be
674        // zero simultaneously, producing 0/0 = NaN.  The is_closed flag
675        // catches most cases, but a channel right at threshold might not
676        // be flagged closed yet have |L_c| ≈ 0.  The extra norm check
677        // prevents NaN propagation.
678        let sqrt_p_over_l = if ws.is_closed[c] || ws.l_c[c].norm() < PIVOT_FLOOR {
679            Complex64::ZERO
680        } else {
681            ws.sqrt_p[c] / ws.l_c[c]
682        };
683        for cp in 0..nch {
684            ws.xxxx[c * nch + cp] = sqrt_p_over_l * ws.xq[c * nch + cp] * ws.sqrt_p[cp];
685        }
686    }
687
688    // ── Collision matrix U = Ω · W · Ω, W = I + 2i·Ξ ────────────────────────
689    //
690    // Phase convention: Ω_c = exp(-iφ_c), NOT exp(+iφ_c).
691    //
692    // TRUTH SOURCE: SAMMY rml/mrml11.f lines 14-18:
693    //   "W(c,c') = delta(c,c') + 2i XXXX(c,c')" (eq. III.D.4)
694    // And mrml11.f lines 84-88, the elastic formula:
695    //   "sin²(φ)·(1-2Xi) - sin(2φ)·Xr + Xr²+Xi²"
696    // is consistent ONLY with U = e^{-2iφ}·(I+2iX), not e^{+2iφ}.
697    //
698    // For hard sphere (W=I): |1-e^{-2iφ}|² = |1-e^{+2iφ}|² = 4sin²φ,
699    // so the sign error is invisible in unitarity tests. It ONLY manifests
700    // when resonances are present (W ≠ I), producing wrong interference
701    // patterns in elastic and incorrect total from optical theorem.
702    //
703    // History: same class of bug as the MLBW e^{+2iφ} error fixed in
704    // slbw.rs (commit f0eadc1). The negative exponent is the ENDF/SAMMY
705    // convention; the positive exponent is a common error.
706    for c in 0..nch {
707        ws.omega[c] = Complex64::from_polar(1.0, -ws.phi_c[c]);
708    }
709
710    for c in 0..nch {
711        for cp in 0..nch {
712            let delta = if c == cp {
713                Complex64::new(1.0, 0.0)
714            } else {
715                Complex64::ZERO
716            };
717            let w_cc = delta + Complex64::new(0.0, 2.0) * ws.xxxx[c * nch + cp];
718            ws.u[c * nch + cp] = ws.omega[c] * w_cc * ws.omega[cp];
719        }
720    }
721
722    // ── Cross-sections (sum over entrance channels) ───────────────────────────
723    // Optical theorem gives σ_total = 2·(π/k²)·g_J·(1 - Re(U_cc)) per channel.
724    // Reference: SAMMY rml/mrml11.f Sectio subroutine; SAMMY manual §3.1 Eq. 3.4
725    let mut tot = 0.0;
726    let mut elas = 0.0;
727    let mut cap = 0.0;
728    let mut fis = 0.0;
729    let mut inel = 0.0; // inelastic neutron channels (MT=51+): tracked separately
730
731    // Whether this spin group has explicit capture (photon) channels in the
732    // level matrix.  KRM=2 with photon channels: yes.  KRM=3: no (capture is
733    // implicit via complex poles; no MT=102 channel appears in NCH).
734    let has_explicit_capture = ws.is_capture[..nch].iter().any(|&x| x);
735
736    for c0 in 0..nch {
737        if !ws.is_entrance[c0] {
738            continue;
739        }
740        let u_diag = ws.u[c0 * nch + c0];
741        // σ_total (optical theorem, per entrance channel)
742        tot += 2.0 * pok2 * g_j * (1.0 - u_diag.re);
743        // σ_elastic: |1 - U_{c0,c0}|²
744        elas += pok2 * g_j * (Complex64::new(1.0, 0.0) - u_diag).norm_sqr();
745
746        for cp in 0..nch {
747            if ws.is_fission[cp] {
748                // σ_fission: |U_{c0,c'}|² for fission channels c'
749                fis += pok2 * g_j * ws.u[c0 * nch + cp].norm_sqr();
750            }
751            if has_explicit_capture && ws.is_capture[cp] {
752                // σ_capture (explicit): |U_{c0,c'}|² for photon channels c'.
753                // Avoids lumping inelastic neutron channels (MT=51+) into capture.
754                // Reference: SAMMY rml/mrml11.f Sectio — explicit sum over γ channels.
755                cap += pok2 * g_j * ws.u[c0 * nch + cp].norm_sqr();
756            }
757            if ws.is_inelastic[cp] {
758                // σ_inelastic: |U_{c0,c'}|² for inelastic neutron channels (MT=51+).
759                // Tracked separately so KRM=3 capture residual excludes this flux.
760                // Reference: ENDF MT conventions §3.4; SAMMY rml/mrml11.f Sectio.
761                inel += pok2 * g_j * ws.u[c0 * nch + cp].norm_sqr();
762            }
763        }
764    }
765
766    if krm == 3 && !has_explicit_capture {
767        // KRM=3 (Reich-Moore approximation): capture is implicit via complex poles
768        // (Ẽ_n = E_n - i·Γγ/2).  Flux not going to elastic, fission, or inelastic
769        // channels is capture.  Inelastic flux must be excluded; folding it into
770        // capture would mislabel σ_capture when MT=51+ channels are present.
771        // Clamp to ≥0 for floating-point safety near pole energies.
772        // Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml11.f Sectio.
773        cap = (tot - elas - fis - inel).max(0.0);
774    }
775    // KRM=2: capture was accumulated explicitly above from MT=102 channels.
776    // Do NOT add residual flux — it may include inelastic (MT=51+) contributions
777    // and would mislabel them as capture, biasing channel-resolved fits.
778    // Reference: SAMMY rml/mrml11.f Sectio (explicit γ-channel sum for KRM=2).
779
780    (tot, elas, cap, fis)
781}
782
783// ── Complex Gauss-Jordan Elimination (flat-buffer version) ──────────────────
784//
785// Inverts an n×n complex matrix using Gauss-Jordan elimination with partial
786// pivoting. Returns false if the matrix is singular (pivot magnitude < LOG_FLOOR).
787//
788// For LRF=7 isotopes relevant to VENUS imaging, NCH ≤ 6, so O(n³) is fast.
789// SAMMY uses a specialized complex symmetric factorization (Xspfa/Xspsl in
790// rml/mrml10.f), but Gauss-Jordan is correct and sufficient for our purposes.
791//
792// All buffers are caller-provided to avoid per-call allocation:
793// - `a`: input matrix (flat row-major, n×n)
794// - `out`: output inverse (flat row-major, n×n)
795// - `aug`: augmented matrix workspace (flat row-major, n×2n)
796// - `tmp`: temporary row buffer (length 2n)
797
798fn invert_complex_matrix_flat(
799    a: &[Complex64],
800    n: usize,
801    out: &mut [Complex64],
802    aug: &mut [Complex64],
803    tmp: &mut [Complex64],
804) -> bool {
805    let w = 2 * n; // augmented row width
806
807    // Build augmented matrix [A | I] of size n × 2n
808    for r in 0..n {
809        for c in 0..n {
810            aug[r * w + c] = a[r * n + c];
811        }
812        for c in 0..n {
813            aug[r * w + n + c] = if c == r {
814                Complex64::new(1.0, 0.0)
815            } else {
816                Complex64::ZERO
817            };
818        }
819    }
820
821    for col in 0..n {
822        // Partial pivoting: find row with largest magnitude in this column
823        let mut best = col;
824        let mut best_norm = aug[col * w + col].norm();
825        for r in (col + 1)..n {
826            let norm = aug[r * w + col].norm();
827            if norm > best_norm {
828                best = r;
829                best_norm = norm;
830            }
831        }
832        // Swap rows col and best in aug
833        if best != col {
834            for j in 0..w {
835                aug.swap(col * w + j, best * w + j);
836            }
837        }
838
839        let pivot = aug[col * w + col];
840        if pivot.norm() < LOG_FLOOR {
841            return false; // singular
842        }
843
844        // Scale pivot row so leading entry becomes 1
845        let inv_pivot = pivot.inv();
846        for j in 0..w {
847            aug[col * w + j] *= inv_pivot;
848        }
849
850        // Eliminate this column from all other rows.
851        // Copy pivot row to tmp to avoid aliasing issues.
852        tmp[..w].copy_from_slice(&aug[col * w..col * w + w]);
853        for row in 0..n {
854            if row == col {
855                continue;
856            }
857            let factor = aug[row * w + col];
858            if factor.norm() < LOG_FLOOR {
859                continue;
860            }
861            for j in 0..w {
862                aug[row * w + j] -= factor * tmp[j];
863            }
864        }
865    }
866
867    // Extract the right half (the inverse)
868    for r in 0..n {
869        for c in 0..n {
870            out[r * n + c] = aug[r * w + n + c];
871        }
872    }
873    true
874}
875
876// ── Legacy wrapper for tests (allocating version) ───────────────────────────
877#[cfg(test)]
878fn invert_complex_matrix(a: &[Vec<Complex64>], n: usize) -> Option<Vec<Vec<Complex64>>> {
879    let flat_a: Vec<Complex64> = a.iter().flat_map(|row| row.iter().copied()).collect();
880    let mut flat_out = vec![Complex64::ZERO; n * n];
881    let mut aug = vec![Complex64::ZERO; n * 2 * n];
882    let mut tmp = vec![Complex64::ZERO; 2 * n];
883    if invert_complex_matrix_flat(&flat_a, n, &mut flat_out, &mut aug, &mut tmp) {
884        Some(
885            (0..n)
886                .map(|r| flat_out[r * n..(r + 1) * n].to_vec())
887                .collect(),
888        )
889    } else {
890        None
891    }
892}
893
894#[cfg(test)]
895mod tests {
896    use super::*;
897
898    #[test]
899    fn test_identity_inversion() {
900        let n = 3;
901        let a: Vec<Vec<Complex64>> = (0..n)
902            .map(|r| {
903                (0..n)
904                    .map(|c| {
905                        if r == c {
906                            Complex64::new(1.0, 0.0)
907                        } else {
908                            Complex64::ZERO
909                        }
910                    })
911                    .collect()
912            })
913            .collect();
914        let inv = invert_complex_matrix(&a, n).unwrap();
915        for (r, row) in inv.iter().enumerate() {
916            for (c, val) in row.iter().enumerate() {
917                let expected = if r == c { 1.0 } else { 0.0 };
918                assert!(
919                    (val.re - expected).abs() < 1e-12,
920                    "inv[{r}][{c}].re = {}, expected {expected}",
921                    val.re
922                );
923                assert!(
924                    val.im.abs() < 1e-12,
925                    "inv[{r}][{c}].im = {} should be 0",
926                    val.im
927                );
928            }
929        }
930    }
931
932    #[test]
933    fn test_2x2_complex_inversion() {
934        // A = [[2+i, 1], [0, 3-2i]]  → A⁻¹ = [[1/(2+i), -1/((2+i)(3-2i))], [0, 1/(3-2i)]]
935        let a00 = Complex64::new(2.0, 1.0);
936        let a01 = Complex64::new(1.0, 0.0);
937        let a11 = Complex64::new(3.0, -2.0);
938        let a = vec![vec![a00, a01], vec![Complex64::ZERO, a11]];
939        let inv = invert_complex_matrix(&a, 2).unwrap();
940
941        // Verify A · A⁻¹ ≈ I
942        let i00 = a00 * inv[0][0] + a01 * inv[1][0];
943        let i01 = a00 * inv[0][1] + a01 * inv[1][1];
944        let i11 = a11 * inv[1][1];
945        assert!((i00.re - 1.0).abs() < 1e-12, "i00.re = {}", i00.re);
946        assert!(i00.im.abs() < 1e-12, "i00.im = {}", i00.im);
947        assert!(i01.norm() < 1e-12, "i01 = {}", i01);
948        assert!((i11.re - 1.0).abs() < 1e-12, "i11.re = {}", i11.re);
949    }
950
951    #[test]
952    fn test_singular_returns_none() {
953        let a = vec![
954            vec![Complex64::new(1.0, 0.0), Complex64::new(2.0, 0.0)],
955            vec![Complex64::new(2.0, 0.0), Complex64::new(4.0, 0.0)],
956        ];
957        assert!(invert_complex_matrix(&a, 2).is_none());
958    }
959
960    /// Hard-sphere unitarity check: with R = 0, U must equal exp(2iφ)·I
961    /// and σ_total = 2·(π/k²)·g_J·(1 − cos 2φ) ≥ 0.
962    ///
963    /// This test is purely local (no network) and guards against the
964    /// classic sign error where U = 3·exp(2iφ)·I (|U| = 3) that arises
965    /// when using A = L − R instead of SAMMY's Ỹ = L⁻¹ − R.
966    #[test]
967    fn test_hard_sphere_unitarity() {
968        use nereids_endf::resonance::{ParticlePair, RmlChannel, RmlData, SpinGroup};
969
970        // Minimal synthetic LRF=7 / KRM=2 with a single elastic channel
971        // and NO resonances.  Result must be pure hard-sphere scattering.
972        let pp = ParticlePair {
973            ma: 1.0,
974            mb: 184.0,
975            za: 0.0,  // neutron charge Z=0
976            zb: 74.0, // W-184 charge Z=74 (ENDF LRF=7 stores charge directly)
977            ia: 0.5,
978            ib: 0.0,
979            q: 0.0,
980            pnt: 1,
981            shf: 0, // SHF=0 → S_c = B_c → L_c = iP_c
982            mt: 2,
983            pa: 1.0,
984            pb: 1.0,
985        };
986        let channel = RmlChannel {
987            particle_pair_idx: 0,
988            l: 0,
989            channel_spin: 0.5,
990            boundary: 0.0,
991            effective_radius: 8.3,
992            true_radius: 8.3,
993        };
994        let sg = SpinGroup {
995            j: 0.5,
996            parity: 1.0,
997            channels: vec![channel],
998            resonances: vec![], // no resonances: pure hard sphere
999            has_background_correction: false,
1000        };
1001        let rml = RmlData {
1002            target_spin: 0.0,
1003            awr: 183.0,
1004            scattering_radius: 8.3,
1005            krm: 2,
1006            particle_pairs: vec![pp],
1007            spin_groups: vec![sg],
1008        };
1009
1010        // Evaluate at several energies.  σ_total must be non-negative,
1011        // and σ_capture/σ_fission must be zero (no absorption channels).
1012        //
1013        // Note: cap is computed as (tot - elas - fis); since tot and elas are
1014        // calculated via different floating-point paths they may differ by
1015        // ~1e-15 × pok2 (where pok2 can be ~1e5 b at low energy), giving a
1016        // residual |cap| ~ 1e-10 b.  Use a relative tolerance of 1e-9.
1017        for &e_ev in &[10.0, 50.0, 100.0, 500.0, 1000.0] {
1018            let (tot, elas, cap, fis) = cross_sections_for_rml_range(&rml, e_ev);
1019            assert!(
1020                tot >= 0.0,
1021                "hard sphere σ_total < 0 at {e_ev} eV: {tot:.6} b"
1022            );
1023            let tol = 1e-9 * tot.abs().max(1.0);
1024            assert!(
1025                cap.abs() < tol,
1026                "hard sphere σ_capture ≠ 0 at {e_ev} eV: {cap:.3e} b (tol={tol:.3e})"
1027            );
1028            assert!(
1029                fis.abs() < tol,
1030                "hard sphere σ_fission ≠ 0 at {e_ev} eV: {fis:.3e} b (tol={tol:.3e})"
1031            );
1032            // σ_elastic ≈ σ_total (capture=fission=0)
1033            assert!(
1034                (tot - elas).abs() < tol,
1035                "σ_total ≠ σ_elastic at {e_ev} eV: tot={tot:.6}, elas={elas:.6}"
1036            );
1037        }
1038    }
1039
1040    /// Coulomb exit channels route through coulomb::coulomb_penetrability,
1041    /// not the hard-sphere Blatt-Weisskopf functions.
1042    ///
1043    /// Constructs a 2-channel spin group:
1044    ///   ch0: neutron (za=0)  + target — hard-sphere entrance channel
1045    ///   ch1: α (za=2)        + O-16 (zb=8) — Coulomb exit, Q=+50 eV (always open)
1046    ///
1047    /// ENDF LRF=7 stores charge Z directly in ZA/ZB (neutron=0, alpha=2, O-16=8).
1048    ///
1049    /// Verifies σ_total ≥ 0 (physics sanity) and no panic at both an open
1050    /// and a closed Coulomb channel (Q very negative).
1051    ///
1052    /// SAMMY ref: rml/mrml07.f Pgh — `if (Zeta(I).NE.Zero)` branch.
1053    #[test]
1054    fn test_coulomb_channel_open_and_closed_no_panic() {
1055        use nereids_endf::resonance::{ParticlePair, RmlChannel, RmlData, SpinGroup};
1056
1057        // Entrance channel: neutron (Z=0) + W-184 target (Z=74).
1058        // ENDF LRF=7 stores charge directly: neutron za=0, W-184 zb=74.
1059        let pp_entrance = ParticlePair {
1060            ma: 1.0,
1061            mb: 184.0,
1062            za: 0.0,  // neutron charge Z=0
1063            zb: 74.0, // W-184 charge Z=74
1064            ia: 0.5,
1065            ib: 0.0,
1066            q: 0.0,
1067            pnt: 1,
1068            shf: 0,
1069            mt: 2,
1070            pa: 1.0,
1071            pb: 1.0,
1072        };
1073
1074        // Coulomb exit channel: α(Z=2) + O-16(Z=8), Q=+50 eV → always open.
1075        // sommerfeld_eta(2, 8, ...) gives η > 0, confirming Coulomb branch.
1076        let pp_coulomb_open = ParticlePair {
1077            ma: 4.0,
1078            mb: 16.0,
1079            za: 2.0, // alpha charge Z=2
1080            zb: 8.0, // O-16 charge Z=8
1081            ia: 0.0,
1082            ib: 0.0,
1083            q: 50.0, // Q > 0 → e_c = e_cm + 50 > 0 for all positive energies
1084            pnt: 1,
1085            shf: 0,
1086            mt: 22, // (n,α)
1087            pa: 1.0,
1088            pb: 1.0,
1089        };
1090
1091        // Coulomb exit channel with Q very negative → closed at all reasonable energies.
1092        let pp_coulomb_closed = ParticlePair {
1093            ma: 4.0,
1094            mb: 16.0,
1095            za: 2.0, // alpha charge Z=2
1096            zb: 8.0, // O-16 charge Z=8
1097            ia: 0.0,
1098            ib: 0.0,
1099            q: -1e6, // far below threshold
1100            pnt: 1,
1101            shf: 0,
1102            mt: 22,
1103            pa: 1.0,
1104            pb: 1.0,
1105        };
1106
1107        // Build and evaluate the open-channel case.
1108        for (desc, pp_exit, expect_positive_total) in [
1109            ("open Coulomb exit", &pp_coulomb_open, true),
1110            ("closed Coulomb exit", &pp_coulomb_closed, false),
1111        ] {
1112            let ch0 = RmlChannel {
1113                particle_pair_idx: 0,
1114                l: 0,
1115                channel_spin: 0.5,
1116                boundary: 0.0,
1117                effective_radius: 8.3,
1118                true_radius: 8.3,
1119            };
1120            let ch1 = RmlChannel {
1121                particle_pair_idx: 1,
1122                l: 0,
1123                channel_spin: 0.5,
1124                boundary: 0.0,
1125                effective_radius: 5.0,
1126                true_radius: 5.0,
1127            };
1128            let sg = SpinGroup {
1129                j: 0.5,
1130                parity: 1.0,
1131                channels: vec![ch0, ch1],
1132                resonances: vec![],
1133                has_background_correction: false,
1134            };
1135            let rml = RmlData {
1136                target_spin: 0.0,
1137                awr: 183.0,
1138                scattering_radius: 8.3,
1139                krm: 2,
1140                particle_pairs: vec![pp_entrance.clone(), pp_exit.clone()],
1141                spin_groups: vec![sg],
1142            };
1143
1144            let (tot, _elas, _cap, _fis) = cross_sections_for_rml_range(&rml, 100.0);
1145            assert!(tot >= 0.0, "{desc}: σ_total = {tot:.6} b must be ≥ 0");
1146            if expect_positive_total {
1147                // Hard-sphere entrance channel alone gives positive σ_total
1148                // (the Coulomb channel merely adds a second channel but no resonances).
1149                assert!(
1150                    tot > 0.0,
1151                    "{desc}: σ_total = {tot} b should be > 0 (hard-sphere entrance channel)"
1152                );
1153            }
1154        }
1155    }
1156
1157    /// Fix #6 (non-vacuous): a CLOSED channel must contribute nothing to the
1158    /// R-matrix, even when a resonance carries a shared off-diagonal width that
1159    /// couples it to an OPEN channel.
1160    ///
1161    /// SAMMY gates the R accumulation on both channels open (rml/mrml07.f Setr
1162    /// lines 67-71: `IF (Su.GT.Echan(K) .AND. Su.GT.Echan(L) ...)`).  Before
1163    /// this fix the un-gated loop formed R[open,closed] = γ_open·γ_closed/(E_n−E)
1164    /// ≠ 0, which leaks into the open-channel cross-section through the level-
1165    /// matrix inversion (XQ = Ỹ⁻¹·R picks up Ỹ⁻¹[0,1]·R[1,0]).
1166    ///
1167    /// This test (unlike the `resonances: vec![]` no-panic test, where R ≡ 0)
1168    /// carries a resonance with a large off-diagonal width into a closed
1169    /// channel, and asserts:
1170    ///   (a) the gated cross-section equals the open-only-submatrix result
1171    ///       (the same group with the closed channel's width zeroed); and
1172    ///   (b) it differs from the pre-fix un-gated value (reconstructed here by
1173    ///       building the full R including the closed-channel coupling and
1174    ///       running the rest of the pipeline), so the gate is load-bearing.
1175    #[test]
1176    fn test_rml_closed_channel_excluded_from_rmatrix() {
1177        use nereids_endf::resonance::{ParticlePair, RmlChannel, RmlData, RmlResonance, SpinGroup};
1178
1179        // Entrance: neutron (Z=0) + target (Z=74), l=0 so the open channel has
1180        // full penetrability P_0 = ρ and a healthy cross-section.
1181        let pp_entrance = ParticlePair {
1182            ma: 1.0,
1183            mb: 183.0,
1184            za: 0.0,
1185            zb: 74.0,
1186            ia: 0.5,
1187            ib: 0.0,
1188            q: 0.0,
1189            pnt: 1,
1190            shf: 0, // l=0: shift is 0 regardless; L_0 = i·P_0 (finite, nonzero)
1191            mt: 2,
1192            pa: 1.0,
1193            pb: 1.0,
1194        };
1195        // Inelastic neutron exit channel (MT=51), massive, with a very negative
1196        // Q so e_c = e_cm + Q < 0 → CLOSED at the test energy.  l=1 with SHF=1 so
1197        // the closed channel keeps a finite analytic shift S_1(iκ) = −1/(1−(κa)²)
1198        // and L_c does NOT collapse to the 1/L_c → 1e30 decoupling sentinel.
1199        // That sentinel (used for L_c ≈ 0) would otherwise numerically swamp the
1200        // off-diagonal leak and make the test vacuous; a finite L_c keeps
1201        // Ỹ⁻¹[0,1]·R[1,0] observable in the open channel.
1202        let pp_closed = ParticlePair {
1203            ma: 1.0,
1204            mb: 183.0,
1205            za: 0.0,
1206            zb: 74.0,
1207            ia: 0.5,
1208            ib: 0.0,
1209            // E_cm ≈ 100·183/184 ≈ 99.5 eV at E_lab=100; Q=−200 → e_c ≈ −100.5 eV
1210            // (CLOSED), with |e_c| comparable so S_1(iκ) is moderate (not a pole).
1211            q: -200.0,
1212            pnt: 1,
1213            shf: 1,
1214            mt: 51,
1215            pa: 1.0,
1216            pb: 1.0,
1217        };
1218
1219        let ch_open = RmlChannel {
1220            particle_pair_idx: 0,
1221            l: 0,
1222            channel_spin: 0.5,
1223            boundary: 0.0,
1224            effective_radius: 8.3,
1225            true_radius: 8.3,
1226        };
1227        let ch_closed = RmlChannel {
1228            particle_pair_idx: 1,
1229            l: 1,
1230            channel_spin: 0.5,
1231            boundary: 0.0,
1232            effective_radius: 8.3,
1233            true_radius: 8.3,
1234        };
1235
1236        // KRM=2: widths ARE reduced amplitudes (copied verbatim), so the closed
1237        // channel keeps a nonzero γ and the off-diagonal coupling γ_0·γ_1 is
1238        // real and large pre-fix.
1239        let res_full = RmlResonance {
1240            energy: 120.0,
1241            gamma_gamma: 0.0,       // KRM=2: no implicit capture
1242            widths: vec![3.0, 5.0], // [open, closed]; large shared off-diagonal
1243        };
1244        // Open-only submatrix reference: zero the closed channel's width.
1245        let res_open_only = RmlResonance {
1246            energy: 120.0,
1247            gamma_gamma: 0.0,
1248            widths: vec![3.0, 0.0],
1249        };
1250
1251        let make_rml = |res: RmlResonance| RmlData {
1252            target_spin: 0.0,
1253            awr: 183.0,
1254            scattering_radius: 8.3,
1255            krm: 2,
1256            particle_pairs: vec![pp_entrance.clone(), pp_closed.clone()],
1257            spin_groups: vec![SpinGroup {
1258                j: 0.5,
1259                parity: 1.0,
1260                channels: vec![ch_open.clone(), ch_closed.clone()],
1261                resonances: vec![res],
1262                has_background_correction: false,
1263            }],
1264        };
1265
1266        // Evaluate off the pole (E ≠ E_n) so the result is well-conditioned.
1267        let energy = 100.0;
1268        let rml_full = make_rml(res_full.clone());
1269        let rml_open_only = make_rml(res_open_only);
1270
1271        let (tot_g, elas_g, cap_g, fis_g) = cross_sections_for_rml_range(&rml_full, energy);
1272        let (tot_ref, elas_ref, cap_ref, fis_ref) =
1273            cross_sections_for_rml_range(&rml_open_only, energy);
1274
1275        // (a) Gated full-width result == open-only-submatrix result.
1276        for (g, r, name) in [
1277            (tot_g, tot_ref, "total"),
1278            (elas_g, elas_ref, "elastic"),
1279            (cap_g, cap_ref, "capture"),
1280            (fis_g, fis_ref, "fission"),
1281        ] {
1282            assert!(
1283                (g - r).abs() <= 1e-9 * r.abs().max(1.0),
1284                "{name}: gated σ ({g}) must equal open-only-submatrix σ ({r}); \
1285                 a closed channel must not couple into R"
1286            );
1287        }
1288
1289        // (b) The gate is load-bearing: reconstruct the PRE-FIX un-gated value
1290        // by computing the full collision matrix WITHOUT the both-open gate, and
1291        // confirm it differs from the gated result at the open entrance channel.
1292        let ungated_tot = ungated_total_for_two_channel(&rml_full, energy);
1293        // For this configuration the leak is large (gated ≈ 2.62 b vs un-gated
1294        // ≈ 5.54 b, a >100% difference), so the gate is decisively load-bearing.
1295        assert!(
1296            (ungated_tot - tot_g).abs() > 1e-3,
1297            "un-gated σ_total ({ungated_tot}) should differ from gated σ_total \
1298             ({tot_g}); if equal, the off-diagonal closed-channel coupling does \
1299             not affect the open channel and the test is vacuous"
1300        );
1301    }
1302
1303    /// Reconstruct the pre-fix (un-gated) σ_total for a 2-channel KRM=2 spin
1304    /// group: builds the full complex R-matrix INCLUDING the closed-channel
1305    /// off-diagonal coupling, then runs the same Ỹ⁻¹/XXXX/U pipeline as the
1306    /// production code.  Used only to prove the Fix #6 gate is load-bearing.
1307    fn ungated_total_for_two_channel(rml: &RmlData, energy_ev: f64) -> f64 {
1308        use num_complex::Complex64;
1309
1310        let awr = rml.awr;
1311        let sg = &rml.spin_groups[0];
1312        let nch = sg.channels.len();
1313        assert_eq!(nch, 2, "helper is specialised to 2 channels");
1314        let pps = &rml.particle_pairs;
1315
1316        let e_cm = channel::lab_to_cm_energy(energy_ev, awr);
1317        let mut p_c = vec![0.0f64; nch];
1318        let mut s_c = vec![0.0f64; nch];
1319        let mut phi_c = vec![0.0f64; nch];
1320        let mut is_closed = vec![false; nch];
1321        let mut is_entrance = vec![false; nch];
1322
1323        for (c, ch) in sg.channels.iter().enumerate() {
1324            let pp = &pps[ch.particle_pair_idx];
1325            is_entrance[c] = pp.mt == 2;
1326            let e_c = e_cm + pp.q;
1327            if e_c <= 0.0 {
1328                // Closed channel.  Honour SHF like production (lines 338-346):
1329                // SHF=1 → finite analytic shift at imaginary argument, so L_c is
1330                // finite and the off-diagonal R coupling is NOT swamped by the
1331                // 1/L_c → 1e30 sentinel.  This is what makes the leak observable.
1332                p_c[c] = 0.0;
1333                phi_c[c] = 0.0;
1334                is_closed[c] = true;
1335                let is_coulomb = pp.za.abs() > 0.5 && pp.zb.abs() > 0.5;
1336                s_c[c] = if pp.shf == 1 && !is_coulomb {
1337                    let redmas = pp.ma * pp.mb / (pp.ma + pp.mb);
1338                    let kappa = channel::wave_number_from_cm(e_c.abs(), redmas);
1339                    penetrability::shift_factor_closed(ch.l, kappa * ch.true_radius)
1340                } else {
1341                    ch.boundary
1342                };
1343            } else {
1344                let redmas = pp.ma * pp.mb / (pp.ma + pp.mb);
1345                let k_c = channel::wave_number_from_cm(e_c, redmas);
1346                let rho_pen = k_c * ch.true_radius;
1347                let rho_phase = k_c * ch.effective_radius;
1348                p_c[c] = penetrability::penetrability(ch.l, rho_pen);
1349                s_c[c] = if pp.shf == 1 {
1350                    penetrability::shift_factor(ch.l, rho_pen)
1351                } else {
1352                    ch.boundary
1353                };
1354                phi_c[c] = penetrability::phase_shift(ch.l, rho_phase);
1355            }
1356        }
1357
1358        // Full R-matrix, NO both-open gate (the pre-fix behaviour).
1359        let mut r = vec![Complex64::ZERO; nch * nch];
1360        for res in &sg.resonances {
1361            let denom = Complex64::new(res.energy, 0.0) - Complex64::new(energy_ev, 0.0);
1362            let inv_denom = if denom.norm() < QUANTUM_NUMBER_EPS {
1363                (denom + Complex64::new(0.0, QUANTUM_NUMBER_EPS)).inv()
1364            } else {
1365                denom.inv()
1366            };
1367            for c in 0..nch {
1368                let gc = res.widths[c];
1369                for cp in 0..nch {
1370                    r[c * nch + cp] += gc * res.widths[cp] * inv_denom;
1371                }
1372            }
1373        }
1374
1375        // L_c, Ỹ = diag(1/L_c) − R.
1376        let mut y = vec![Complex64::ZERO; nch * nch];
1377        for c in 0..nch {
1378            let l_c = Complex64::new(s_c[c] - sg.channels[c].boundary, p_c[c]);
1379            let inv_l = if l_c.norm_sqr() < NEAR_ZERO_FLOOR {
1380                Complex64::new(1e30, 0.0)
1381            } else {
1382                Complex64::new(1.0, 0.0) / l_c
1383            };
1384            for cp in 0..nch {
1385                let diag = if c == cp { inv_l } else { Complex64::ZERO };
1386                y[c * nch + cp] = diag - r[c * nch + cp];
1387            }
1388        }
1389
1390        // Invert Ỹ (2×2 closed form).
1391        let det = y[0] * y[3] - y[1] * y[2];
1392        let yinv = [y[3] / det, -y[1] / det, -y[2] / det, y[0] / det];
1393
1394        // XQ = Ỹ⁻¹·R.
1395        let mut xq = [Complex64::ZERO; 4];
1396        for c in 0..nch {
1397            for cp in 0..nch {
1398                let mut sum = Complex64::ZERO;
1399                for k in 0..nch {
1400                    sum += yinv[c * nch + k] * r[k * nch + cp];
1401                }
1402                xq[c * nch + cp] = sum;
1403            }
1404        }
1405
1406        // XXXX, with the closed-channel √P-kill (matches production line 655).
1407        let sqrt_p: Vec<Complex64> = p_c.iter().map(|&p| Complex64::new(p, 0.0).sqrt()).collect();
1408        let mut xxxx = [Complex64::ZERO; 4];
1409        for c in 0..nch {
1410            let l_c = Complex64::new(s_c[c] - sg.channels[c].boundary, p_c[c]);
1411            let sqrt_p_over_l = if is_closed[c] || l_c.norm() < PIVOT_FLOOR {
1412                Complex64::ZERO
1413            } else {
1414                sqrt_p[c] / l_c
1415            };
1416            for cp in 0..nch {
1417                xxxx[c * nch + cp] = sqrt_p_over_l * xq[c * nch + cp] * sqrt_p[cp];
1418            }
1419        }
1420
1421        // U and σ_total via the optical theorem on the open entrance channel.
1422        let omega: Vec<Complex64> = phi_c
1423            .iter()
1424            .map(|&phi| Complex64::from_polar(1.0, -phi))
1425            .collect();
1426        let pok2 = channel::pi_over_k_squared_barns(energy_ev, awr);
1427        // Use the SAME statistical weight as production so the only difference
1428        // between this helper and `cross_sections_for_rml_range` is the gate.
1429        let g_j = channel::statistical_weight(sg.j, rml.target_spin);
1430        let mut tot = 0.0;
1431        for c0 in 0..nch {
1432            if !is_entrance[c0] {
1433                continue;
1434            }
1435            let w_cc = Complex64::new(1.0, 0.0) + Complex64::new(0.0, 2.0) * xxxx[c0 * nch + c0];
1436            let u_diag = omega[c0] * w_cc * omega[c0];
1437            tot += 2.0 * pok2 * g_j * (1.0 - u_diag.re);
1438        }
1439        tot
1440    }
1441
1442    /// Singular Y-matrix regularization: construct a KRM=2 spin group where
1443    /// the R-matrix nearly cancels L⁻¹ at the resonance energy, making
1444    /// the Y-matrix nearly singular.  The relative-epsilon regularization
1445    /// must produce finite, non-negative cross-sections (no NaN/Inf).
1446    #[test]
1447    fn test_rml_singular_y_matrix_regularization() {
1448        use nereids_endf::resonance::{ParticlePair, RmlChannel, RmlData, RmlResonance, SpinGroup};
1449
1450        let pp = ParticlePair {
1451            ma: 1.0,
1452            mb: 184.0,
1453            za: 0.0,
1454            zb: 74.0,
1455            ia: 0.5,
1456            ib: 0.0,
1457            q: 0.0,
1458            pnt: 1,
1459            shf: 0,
1460            mt: 2,
1461            pa: 1.0,
1462            pb: 1.0,
1463        };
1464        let ch = RmlChannel {
1465            particle_pair_idx: 0,
1466            l: 0,
1467            channel_spin: 0.5,
1468            boundary: 0.0,
1469            effective_radius: 8.3,
1470            true_radius: 8.3,
1471        };
1472        // KRM=2: widths are reduced amplitudes γ_nc.
1473        // A very large reduced amplitude γ at E_r makes R ≈ γ²/(E_r - E)
1474        // huge near E_r, potentially cancelling L⁻¹ on the diagonal.
1475        let sg = SpinGroup {
1476            j: 0.5,
1477            parity: 1.0,
1478            channels: vec![ch],
1479            resonances: vec![RmlResonance {
1480                energy: 100.0,
1481                gamma_gamma: 0.0,  // KRM=2 has no implicit capture
1482                widths: vec![1e5], // very large reduced amplitude
1483            }],
1484            has_background_correction: false,
1485        };
1486        let rml = RmlData {
1487            target_spin: 0.0,
1488            awr: 183.0,
1489            scattering_radius: 8.3,
1490            krm: 2,
1491            particle_pairs: vec![pp],
1492            spin_groups: vec![sg],
1493        };
1494
1495        // Evaluate exactly at the resonance energy (worst case for singularity).
1496        let (tot, elas, cap, _fis) = cross_sections_for_rml_range(&rml, 100.0);
1497        assert!(
1498            tot.is_finite() && tot >= 0.0,
1499            "σ_total must be finite and ≥ 0 near singular Y-matrix, got {tot}"
1500        );
1501        assert!(
1502            elas.is_finite() && elas >= 0.0,
1503            "σ_elastic must be finite and ≥ 0, got {elas}"
1504        );
1505        assert!(cap.is_finite(), "σ_capture must be finite, got {cap}");
1506    }
1507
1508    // ─── Defensive input validation at the public boundary ──────────────────
1509    //
1510    // `cross_sections_for_rml_range` is `pub`, so it can be called directly
1511    // from any crate without going through the Python wrapper's
1512    // `validate_energy_grid`.  An empty `spin_groups` vec would otherwise
1513    // silently return (0, 0, 0, 0) for malformed energies, hiding caller
1514    // bugs.  The entry assertion fires before any spin-group iteration.
1515    // See issue #558.
1516
1517    fn make_empty_rml() -> nereids_endf::resonance::RmlData {
1518        nereids_endf::resonance::RmlData {
1519            target_spin: 0.0,
1520            awr: 183.0,
1521            scattering_radius: 8.3,
1522            krm: 2,
1523            particle_pairs: vec![],
1524            spin_groups: vec![],
1525        }
1526    }
1527
1528    #[test]
1529    #[should_panic(expected = "expected positive finite energy_ev")]
1530    fn rml_for_range_panics_on_zero_energy() {
1531        let rml = make_empty_rml();
1532        let _ = cross_sections_for_rml_range(&rml, 0.0);
1533    }
1534
1535    #[test]
1536    #[should_panic(expected = "expected positive finite energy_ev")]
1537    fn rml_for_range_panics_on_negative_energy() {
1538        let rml = make_empty_rml();
1539        let _ = cross_sections_for_rml_range(&rml, -1.0);
1540    }
1541
1542    #[test]
1543    #[should_panic(expected = "expected positive finite energy_ev")]
1544    fn rml_for_range_panics_on_nan_energy() {
1545        let rml = make_empty_rml();
1546        let _ = cross_sections_for_rml_range(&rml, f64::NAN);
1547    }
1548
1549    #[test]
1550    #[should_panic(expected = "expected positive finite energy_ev")]
1551    fn rml_for_range_panics_on_infinite_energy() {
1552        let rml = make_empty_rml();
1553        let _ = cross_sections_for_rml_range(&rml, f64::INFINITY);
1554    }
1555
1556    // ─────────────────────────────────────────────────────────────────────────
1557    // LRF=7 KRM=3 SAMMY-parity regression tests.
1558    //
1559    // These guard three SAMMY-divergence bugs that were INVISIBLE to every prior
1560    // RML test: all existing fixtures set effective_radius == true_radius (so the
1561    // APE/APT roles were indistinguishable), used all-positive resonance energies
1562    // (so the KRM=3 bound-state path never ran), and used PNT=1 (so the PNT flag
1563    // was never exercised).  The fixtures below deliberately use APE ≠ APT, a
1564    // negative-energy (bound) resonance, and a PNT toggle, with an explicit
1565    // non-vacuity guard in each test proving the fix is observable.
1566    //
1567    // Oracle: a hand re-derivation of the single-channel single-resonance elastic
1568    // U-matrix algebra (mirrors `spin_group_cross_sections`) using NEREIDS
1569    // primitives but the CORRECT SAMMY radius convention — APT (true_radius) for
1570    // P_c/S_c, APE (effective_radius) for the phase φ_c (SAMMY rml/mrml07.f:128-134,
1571    // mrml03.f:174-177,244).  Same house pattern as slbw_elastic_oracle.rs.
1572
1573    /// Parameters for a single-channel elastic KRM=3 fixture.  Named fields (vs a
1574    /// long positional arg list) keep call sites unambiguous; `..Default::default()`
1575    /// lets each test override only the field it varies.
1576    #[derive(Clone, Copy)]
1577    struct ElasticKrm3 {
1578        l: u32,
1579        ape: f64,
1580        apt: f64,
1581        e_res: f64,
1582        gamma_n: f64,
1583        gamma_gamma: f64,
1584        pnt: i32,
1585        shf: i32,
1586    }
1587
1588    impl Default for ElasticKrm3 {
1589        fn default() -> Self {
1590            Self {
1591                l: 0,
1592                ape: 8.3,
1593                apt: 5.0,
1594                e_res: 100.0,
1595                gamma_n: 2.0,
1596                gamma_gamma: 0.5,
1597                pnt: 1,
1598                shf: 0,
1599            }
1600        }
1601    }
1602
1603    /// Build a single-channel (elastic, neutron + W-184), single-resonance KRM=3
1604    /// spin group with independent effective (APE) and true (APT) radii.
1605    fn rml_elastic_krm3(f: ElasticKrm3) -> RmlData {
1606        use nereids_endf::resonance::{RmlChannel, RmlResonance};
1607        let pp = ParticlePair {
1608            ma: 1.0,
1609            mb: 184.0,
1610            za: 0.0,  // neutron Z=0 → non-Coulomb (hard-sphere) entrance channel
1611            zb: 74.0, // W-184 Z=74
1612            ia: 0.5,
1613            ib: 0.0,
1614            q: 0.0,
1615            pnt: f.pnt,
1616            shf: f.shf,
1617            mt: 2,
1618            pa: 1.0,
1619            pb: 1.0,
1620        };
1621        let channel = RmlChannel {
1622            particle_pair_idx: 0,
1623            l: f.l,
1624            channel_spin: 0.5,
1625            boundary: 0.0,
1626            effective_radius: f.ape,
1627            true_radius: f.apt,
1628        };
1629        let sg = SpinGroup {
1630            j: 0.5,
1631            parity: 1.0,
1632            channels: vec![channel],
1633            resonances: vec![RmlResonance {
1634                energy: f.e_res,
1635                widths: vec![f.gamma_n],
1636                gamma_gamma: f.gamma_gamma,
1637            }],
1638            has_background_correction: false,
1639        };
1640        RmlData {
1641            target_spin: 0.0,
1642            awr: 183.0,
1643            scattering_radius: f.apt,
1644            krm: 3,
1645            particle_pairs: vec![pp],
1646            spin_groups: vec![sg],
1647        }
1648    }
1649
1650    /// SAMMY Betset reduced-width amplitude for the single elastic channel,
1651    /// branching on PNT exactly as `mrml03.f:235-274`: PNT=1 evaluates the
1652    /// penetrability at |Eres−Echan| using APT; PNT≠1 uses √(½|Γ|).
1653    fn oracle_reduced_width(rml: &RmlData) -> f64 {
1654        let pp = &rml.particle_pairs[0];
1655        let ch = &rml.spin_groups[0].channels[0];
1656        let res = &rml.spin_groups[0].resonances[0];
1657        let g = res.widths[0];
1658        if pp.pnt != 1 {
1659            return (0.5 * g.abs()).sqrt().copysign(g);
1660        }
1661        let e_cm_n = channel::lab_to_cm_energy(res.energy, rml.awr) + pp.q;
1662        let redmas = pp.ma * pp.mb / (pp.ma + pp.mb);
1663        let k_cn = channel::wave_number_from_cm(e_cm_n.abs(), redmas);
1664        let p = penetrability::penetrability(ch.l, k_cn * ch.true_radius); // APT
1665        if p > 0.0 {
1666            (0.5 * g.abs() / p).sqrt().copysign(g)
1667        } else {
1668            (0.5 * g.abs()).sqrt().copysign(g)
1669        }
1670    }
1671
1672    /// Single-channel single-resonance elastic U-matrix cross-section oracle for a
1673    /// given reduced-width amplitude `gamma`.  Mirrors `spin_group_cross_sections`:
1674    /// PNT=1 uses the SAMMY radius convention (APT→P/S, APE→φ); PNT≠1 uses the
1675    /// no-penetrability encoding (P=1, S=B_c, φ=0).  Returns
1676    /// (σ_total, σ_elastic, σ_capture) in barns.
1677    fn oracle_xs(rml: &RmlData, energy_ev: f64, gamma: f64) -> (f64, f64, f64) {
1678        let sg = &rml.spin_groups[0];
1679        let pp = &rml.particle_pairs[0];
1680        let ch = &sg.channels[0];
1681        let res = &sg.resonances[0];
1682        let g_j = channel::statistical_weight(sg.j, rml.target_spin);
1683        let pok2 = channel::pi_over_k_squared_barns(energy_ev, rml.awr);
1684        let (p, s, phi) = if pp.pnt != 1 {
1685            // No-penetrability branch (matches the evaluator's eval-path): P=1,
1686            // S=B_c, φ=0 → L_c = i, the Ymat(2,Ii) -= 1 encoding.
1687            (1.0, ch.boundary, 0.0)
1688        } else {
1689            let redmas = pp.ma * pp.mb / (pp.ma + pp.mb);
1690            let e_c = channel::lab_to_cm_energy(energy_ev, rml.awr) + pp.q;
1691            let k_c = channel::wave_number_from_cm(e_c, redmas);
1692            let p = penetrability::penetrability(ch.l, k_c * ch.true_radius); // APT
1693            let s = if pp.shf == 1 {
1694                penetrability::shift_factor(ch.l, k_c * ch.true_radius) // APT
1695            } else {
1696                ch.boundary
1697            };
1698            let phi = penetrability::phase_shift(ch.l, k_c * ch.effective_radius); // APE
1699            (p, s, phi)
1700        };
1701        let e_tilde = Complex64::new(res.energy, -res.gamma_gamma / 2.0);
1702        let r = Complex64::new(gamma * gamma, 0.0) / (e_tilde - energy_ev);
1703        let l_c = Complex64::new(s - ch.boundary, p);
1704        let y_tilde = l_c.inv() - r;
1705        let xq = y_tilde.inv() * r;
1706        let sqrt_p = p.sqrt();
1707        let xxxx = (sqrt_p / l_c) * xq * sqrt_p;
1708        let w = Complex64::new(1.0, 0.0) + Complex64::new(0.0, 2.0) * xxxx;
1709        let omega = Complex64::from_polar(1.0, -phi);
1710        let u = omega * w * omega;
1711        let tot = 2.0 * pok2 * g_j * (1.0 - u.re);
1712        let elas = pok2 * g_j * (Complex64::new(1.0, 0.0) - u).norm_sqr();
1713        (tot, elas, (tot - elas).max(0.0))
1714    }
1715
1716    fn rml_rel_err(a: f64, b: f64) -> f64 {
1717        (a - b).abs() / b.abs().max(1e-300)
1718    }
1719
1720    /// Shared-primitive tolerance: the oracle and evaluator use the same P_l/φ_l
1721    /// primitives and identical algebra, so any disagreement is FP noise.
1722    const RML_ORACLE_REL_TOL: f64 = 1e-9;
1723
1724    /// F2: APT (true_radius) drives P_c/S_c, APE (effective_radius) drives φ_c.
1725    /// The evaluator must match the correct-convention oracle; the explicit
1726    /// non-vacuity guard proves that swapping the two radii changes the result,
1727    /// so the match is meaningful and not a degenerate APE==APT pass.
1728    #[test]
1729    fn rml_lrf7_krm3_radius_roles_match_sammy_oracle() {
1730        // Two channel configs exercise both radius-bearing paths:
1731        //   l=0, shf=0 → P_c and φ_c (S_c = B_c is inert)
1732        //   l=1, shf=1 → P_c, S_c (analytic shift factor) and φ_c
1733        for &(l, shf, label) in &[(0u32, 0i32, "l=0,shf=0"), (1u32, 1i32, "l=1,shf=1")] {
1734            let rml = rml_elastic_krm3(ElasticKrm3 {
1735                l,
1736                shf,
1737                ..Default::default()
1738            });
1739            // APE/APT exchanged
1740            let swapped = rml_elastic_krm3(ElasticKrm3 {
1741                l,
1742                shf,
1743                ape: 5.0,
1744                apt: 8.3,
1745                ..Default::default()
1746            });
1747            for &e in &[50.0, 90.0, 110.0, 200.0] {
1748                let (tot, elas, cap, _fis) = cross_sections_for_rml_range(&rml, e);
1749                let gamma = oracle_reduced_width(&rml);
1750                let (otot, oelas, ocap) = oracle_xs(&rml, e, gamma);
1751                assert!(
1752                    rml_rel_err(tot, otot) < RML_ORACLE_REL_TOL,
1753                    "[{label}] σ_total @ {e} eV: eval={tot}, oracle={otot}"
1754                );
1755                assert!(
1756                    rml_rel_err(elas, oelas) < RML_ORACLE_REL_TOL,
1757                    "[{label}] σ_elastic @ {e} eV: eval={elas}, oracle={oelas}"
1758                );
1759                assert!(
1760                    rml_rel_err(cap, ocap) < RML_ORACLE_REL_TOL,
1761                    "[{label}] σ_capture @ {e} eV: eval={cap}, oracle={ocap}"
1762                );
1763                // Non-vacuity: the swapped-radius convention must give a materially
1764                // different σ_total, so matching the correct one above is meaningful.
1765                // For l=1,shf=1 the swap moves the shift-factor radius too.
1766                let g_sw = oracle_reduced_width(&swapped);
1767                let (stot, _, _) = oracle_xs(&swapped, e, g_sw);
1768                assert!(
1769                    rml_rel_err(otot, stot) > 0.01,
1770                    "[{label}] APE/APT swap unobservable at {e} eV (otot={otot}, swapped={stot}); test would be vacuous"
1771                );
1772            }
1773        }
1774    }
1775
1776    /// F1: a KRM=3 bound-state resonance (E_res < 0) must receive a real
1777    /// penetrability at |Eres−Echan| and the √(½|Γ|/P) normalisation — NOT the
1778    /// old √|Γ| fallback.  The non-vacuity guard asserts the evaluator does NOT
1779    /// match the buggy √|Γ| reduced width.
1780    #[test]
1781    fn rml_lrf7_krm3_bound_state_uses_penetrability_not_sqrt_gamma() {
1782        let rml = rml_elastic_krm3(ElasticKrm3 {
1783            e_res: -50.0,
1784            ..Default::default()
1785        });
1786        let gamma = oracle_reduced_width(&rml);
1787        // Old (buggy) behaviour: γ = √|Γ| with no ½ factor and no penetrability.
1788        let gamma_buggy = {
1789            let g = rml.spin_groups[0].resonances[0].widths[0];
1790            g.abs().sqrt().copysign(g)
1791        };
1792        for &e in &[10.0, 50.0, 150.0] {
1793            let (tot, elas, cap, _fis) = cross_sections_for_rml_range(&rml, e);
1794            let (otot, oelas, ocap) = oracle_xs(&rml, e, gamma);
1795            assert!(
1796                rml_rel_err(tot, otot) < RML_ORACLE_REL_TOL,
1797                "bound σ_total @ {e} eV: eval={tot}, oracle={otot}"
1798            );
1799            assert!(
1800                rml_rel_err(elas, oelas) < RML_ORACLE_REL_TOL,
1801                "bound σ_elastic @ {e} eV: eval={elas}, oracle={oelas}"
1802            );
1803            assert!(
1804                rml_rel_err(cap, ocap) < RML_ORACLE_REL_TOL,
1805                "bound σ_capture @ {e} eV: eval={cap}, oracle={ocap}"
1806            );
1807            // Non-vacuity: the pre-fix √|Γ| reduced width gives a materially
1808            // different result, so the match above proves the new normalisation.
1809            let (btot, _, _) = oracle_xs(&rml, e, gamma_buggy);
1810            assert!(
1811                rml_rel_err(otot, btot) > 0.01,
1812                "√|Γ| vs √(½|Γ|/P) indistinguishable at {e} eV (otot={otot}, buggy={btot}); test would be vacuous"
1813            );
1814        }
1815    }
1816
1817    /// F3: the per-pair PNT flag must change the physics.  Before the fix the
1818    /// evaluator branched on particle mass and ignored PNT entirely, so toggling
1819    /// PNT on a massive channel produced identical cross-sections.
1820    #[test]
1821    fn rml_lrf7_pnt_flag_is_respected() {
1822        let with_pen = rml_elastic_krm3(ElasticKrm3::default());
1823        let no_pen = rml_elastic_krm3(ElasticKrm3 {
1824            pnt: 0,
1825            ..Default::default()
1826        });
1827        for &e in &[50.0, 110.0, 200.0] {
1828            let (tot1, _, _, _) = cross_sections_for_rml_range(&with_pen, e);
1829            let (tot0, elas0, cap0, _) = cross_sections_for_rml_range(&no_pen, e);
1830            assert!(
1831                tot0.is_finite() && tot1.is_finite(),
1832                "PNT toggle produced non-finite σ at {e} eV: PNT=1 {tot1}, PNT=0 {tot0}"
1833            );
1834            // Pin the PNT=0 path against the oracle: this covers BOTH the
1835            // no-penetrability channel setup (P=1, S=B, φ=0) AND the √(½|Γ|)
1836            // width conversion, so the test fails if either regresses — not just
1837            // if the totals happen to differ.
1838            let g0 = oracle_reduced_width(&no_pen);
1839            let (otot0, oelas0, ocap0) = oracle_xs(&no_pen, e, g0);
1840            assert!(
1841                rml_rel_err(tot0, otot0) < RML_ORACLE_REL_TOL,
1842                "PNT=0 σ_total @ {e} eV: eval={tot0}, oracle={otot0}"
1843            );
1844            assert!(
1845                rml_rel_err(elas0, oelas0) < RML_ORACLE_REL_TOL,
1846                "PNT=0 σ_elastic @ {e} eV: eval={elas0}, oracle={oelas0}"
1847            );
1848            assert!(
1849                rml_rel_err(cap0, ocap0) < RML_ORACLE_REL_TOL,
1850                "PNT=0 σ_capture @ {e} eV: eval={cap0}, oracle={ocap0}"
1851            );
1852            // Non-vacuity: the PNT toggle must change σ (pre-fix, branching on
1853            // particle mass, gave identical results regardless of PNT).
1854            assert!(
1855                rml_rel_err(tot1, tot0) > 0.01,
1856                "PNT flag ignored at {e} eV: PNT=1 σ_total={tot1}, PNT=0 σ_total={tot0} (should differ)"
1857            );
1858        }
1859    }
1860}