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}