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