Skip to main content

nereids_physics/
coulomb.rs

1//! Coulomb wave functions via Steed's continued-fraction method.
2//!
3//! Provides F_L(η,ρ), G_L(η,ρ), F'_L, G'_L and the derived
4//! penetrability P_L, shift S_L, and phase φ_L for charged-particle
5//! exit channels in LRF=7 (R-Matrix Limited) cross-section calculations.
6//!
7//! ## SAMMY References
8//! - `coulomb/mrml08.f90` Coulfg — Steed's CF1+CF2 algorithm (Barnett 1981)
9//! - `rml/mrml07.f` Pgh subroutine — `if (Zeta.NE.Zero)` branch
10//! - Barnett, CPC 21 (1981) 297–314
11//!
12//! ## Limit η → 0
13//! When η = 0, F_0 = sin ρ, G_0 = cos ρ, reproducing hard-sphere
14//! (Blatt-Weisskopf) penetrabilities from `penetrability.rs`.
15
16use nereids_core::constants::{self, NEAR_ZERO_FLOOR};
17
18/// Sommerfeld parameter η for a Coulomb channel.
19///
20/// η = Z_a · Z_b · α · √(m_n·c² · μ̃ / (2·E_c))
21///
22/// where:
23/// - Z_a, Z_b are the charge numbers stored in `ParticlePair.za`/`.zb`
24///   (ENDF LRF=7 stores the charge Z directly: neutron/photon = 0,
25///   proton = 1, alpha = 2; see SAMMY rml/mrml03.f Zeta formula)
26/// - α = fine-structure constant ≈ 1/137.036
27/// - μ̃ = MA·MB/(MA+MB) is the reduced mass in neutron mass units
28/// - E_c is the CM kinetic energy in this channel (eV, must be > 0)
29/// - m_n·c² in eV
30///
31/// ## SAMMY Reference
32/// `rml/mrml03.f` — `Zeta = Etac * Kza * Kzb * Redmas / Zke`
33/// where `Kza`/`Kzb` are the charges read directly from the ENDF file.
34///
35/// # Arguments
36/// * `za`, `zb` — Charge numbers Z of the two particles (from `ParticlePair.za`/`.zb`).
37///   Neutron and photon: 0; proton: 1; alpha: 2; oxygen-16: 8.
38/// * `ma`, `mb` — Particle masses in neutron mass units.
39/// * `e_cm_ev` — CM kinetic energy in eV (must be > 0).
40pub fn sommerfeld_eta(za: f64, zb: f64, ma: f64, mb: f64, e_cm_ev: f64) -> f64 {
41    if e_cm_ev <= 0.0 {
42        return 0.0;
43    }
44    // ENDF LRF=7 stores charge Z directly in the particle-pair ZA/ZB fields.
45    // Neutron/photon: Z=0; proton: Z=1; alpha: Z=2.
46    // Reference: SAMMY rml/mrml03.f — Docoul = Kzb*Kza (product of charges).
47    if za == 0.0 || zb == 0.0 {
48        return 0.0; // one particle is neutral → no Coulomb interaction
49    }
50    let alpha = 1.0 / 137.035_999_084; // CODATA 2018 fine-structure constant
51    let mn_ev = constants::NEUTRON_MASS_MEV * 1e6; // MeV → eV
52    let reduced_mass = ma * mb / (ma + mb); // μ̃ in neutron mass units
53    // η = Z_a · Z_b · α · √(m_n · μ̃ / (2 · E_c))
54    za * zb * alpha * (mn_ev * reduced_mass / (2.0 * e_cm_ev)).sqrt()
55}
56
57/// Coulomb wave functions (F_L, G_L, F'_L, G'_L) via Steed's CF1+CF2 method.
58///
59/// Implements Barnett's Coulfg algorithm (CPC 21, 1981, 297–314) as adapted in
60/// SAMMY `coulomb/mrml08.f90`. Returns `None` if ρ ≤ 0 or the continued
61/// fractions fail to converge (nuclear-physics ρ values always converge).
62///
63/// The returned derivatives are with respect to ρ.
64///
65/// ## SAMMY Reference
66/// `coulomb/mrml08.f90` Coulfg subroutine.
67///
68/// # Arguments
69/// * `l`   — Orbital angular momentum quantum number L.
70/// * `eta` — Sommerfeld parameter η (0 for neutral particles).
71/// * `rho` — Dimensionless channel parameter ρ = k·a (must be > 0).
72///
73/// # Returns
74/// `Some((F_L, G_L, F'_L, G'_L))` on success, `None` if ρ is too small or
75/// the continued fractions diverge.
76pub fn coulomb_wave_functions(l: u32, eta: f64, rho: f64) -> Option<(f64, f64, f64, f64)> {
77    // Steed's CF1+CF2 algorithm.
78    // Faithful Rust translation of SAMMY coulomb/mrml08.f90 Coulfg
79    // (Barnett, CPC 21, 1981, 297–314).
80    const ACCUR: f64 = 1e-16;
81    const ABORT: f64 = 2e4;
82    const TM30: f64 = 1e-30;
83    let acc = ACCUR;
84    let acch = ACCUR.sqrt(); // ≈ 1e-8
85
86    // ρ must be positive; below acch the asymptotic CF2 is unreliable.
87    // (SAMMY Coulfg line 281: IF (Xx.LE.Acch) GO TO 100)
88    if rho <= acch {
89        return None;
90    }
91
92    let lll = l as usize;
93    // SAMMY uses Llmax = Lll + 2 as the downward recurrence starting point.
94    let llmax = lll + 2;
95    let n = llmax + 1; // array size; indices 0..=llmax
96    let xi = 1.0 / rho; // 1/ρ
97    let xll = llmax as f64; // Llmax as f64
98
99    // Working arrays (0-indexed):
100    //   fc[k]  = unnormalized F_k → normalized F_k after upward pass
101    //   fcp[k] = unnormalized F'_k → normalized F'_k
102    //   rl_store[k] = sqrt(1 + (η/k)^2), stored at k=Xl during CF1 downward
103    //                 recurrence; used for upward G recurrence.
104    let mut fc = vec![0.0f64; n];
105    let mut fcp = vec![0.0f64; n];
106    let mut rl_store = vec![0.0f64; n]; // index 0 unused; 1..=llmax used
107
108    // ─────────────────────────────────────────────────────────────────────────
109    // CF1: Steed's upward continued fraction for F'(Llmax)/F(Llmax).
110    //
111    // We iterate k = Llmax+1, Llmax+2, … until |Df| < |F|·Acc.
112    // At the end, f = F'_{Llmax}/F_{Llmax}.
113    //
114    // SAMMY Coulfg lines 298–344.
115    // ─────────────────────────────────────────────────────────────────────────
116    let mut fcl = 1.0f64; // tracks sign of unnormalized F (SAMMY Fcl)
117    let mut pk = xll + 1.0; // k = Llmax + 1 (start of CF1)
118    let px = pk + ABORT; // iteration limit
119
120    // First step (initialization of CF1).
121    let ek0 = eta / pk;
122    let mut f = (ek0 + pk * xi) * fcl + (fcl - 1.0) * xi;
123    let mut pk1 = pk + 1.0;
124
125    let (mut d, mut df) = if (eta * rho + pk * pk1).abs() <= acc {
126        // Near-zero denominator fixup (SAMMY lines 308–313).
127        fcl = (1.0 + ek0 * ek0) / (1.0 + (eta / pk1) * (eta / pk1));
128        pk += 2.0;
129        pk1 = pk + 1.0;
130        let ek = eta / pk;
131        let d = 1.0 / ((pk + pk1) * (xi + ek / pk1));
132        let df = -fcl * (1.0 + ek * ek) * d;
133        (d, df)
134    } else {
135        let d = 1.0 / ((pk + pk1) * (xi + ek0 / pk1));
136        let df = -fcl * (1.0 + ek0 * ek0) * d;
137        // Track sign of Fcl (SAMMY lines 316–318)
138        if fcl != 1.0 {
139            fcl = -1.0;
140        }
141        if d < 0.0 {
142            fcl = -fcl;
143        }
144        (d, df)
145    };
146    f += df;
147
148    // CF1 main loop (Steed modified Lentz, SAMMY lines 320–343).
149    // SAMMY: Pk = Pk1; Pk1 = Pk1 + One  (advance to next k before each step).
150    let mut p_count: u32 = 0;
151    loop {
152        pk = pk1; // advance: current step uses previous pk1
153        pk1 = pk + 1.0; // next step's pk1
154        let ek = eta / pk;
155        let tk = (pk + pk1) * (xi + ek / pk1);
156        d = tk - d * (1.0 + ek * ek);
157        if d.abs() <= acch {
158            // D too small — count consecutive small denominators.
159            //
160            // INTENTIONAL DEPARTURE from SAMMY (coulomb/mrml08.f90 lines 331–337):
161            // SAMMY uses a cumulative counter Pcount that increments on every
162            // small denominator and never resets, bailing out after more than
163            // 2 total (i.e., on the third small denominator).
164            // We reset on healthy denominators, counting *consecutive* small
165            // denominators instead, and also bail out after more than 2
166            // consecutive (on the third).  This is more lenient: a single
167            // healthy denominator between two small ones does not trigger
168            // failure.  In nuclear-physics-relevant (η, ρ) regimes, both
169            // strategies converge identically; the difference only matters
170            // for exotic parameters outside our use cases.
171            //
172            // NOTE: If a future comparison against SAMMY shows a discrepancy
173            // in Coulomb wave functions for extreme parameters, this p_count
174            // reset policy is the first place to check.
175            p_count += 1;
176            if p_count > 2 {
177                return None; // CF1 failed
178            }
179        } else {
180            // Denominator is healthy — reset the consecutive-small counter.
181            p_count = 0;
182        }
183        d = 1.0 / d;
184        if d < 0.0 {
185            fcl = -fcl;
186        }
187        df *= d * tk - 1.0;
188        f += df;
189        if pk > px {
190            return None; // CF1 exceeded iteration limit
191        }
192        if df.abs() < f.abs() * acc {
193            break; // converged
194        }
195    }
196    // After CF1: f = F'_{Llmax}/F_{Llmax}, fcl = sign of F_{Llmax}.
197
198    // ─────────────────────────────────────────────────────────────────────────
199    // Downward recurrence: L = Llmax → 0.
200    //
201    // Builds unnormalized F_l and F'_l for all l, and stores RL values
202    // (= sqrt(1 + (η/l)^2)) needed for upward G recurrence.
203    //
204    // Recurrence (from Abramowitz & Stegun 14.2.2/14.2.3):
205    //   F_{l-1} = (S_l · F_l + F'_l) / R_l
206    //   F'_{l-1} = S_l · F_{l-1} − R_l · F_l
207    // where S_l = η/l + l/ρ, R_l = sqrt(1 + (η/l)^2).
208    //
209    // SAMMY Coulfg lines 349–375.
210    // ─────────────────────────────────────────────────────────────────────────
211    fcl *= TM30; // scale down to avoid overflow during recurrence
212    let mut fpl = fcl * f; // F'_{Llmax} (unnormalized)
213    fcp[llmax] = fpl;
214    fc[llmax] = fcl;
215
216    let mut xl = xll; // starts at Llmax, decrements to 1
217    for lp in 1..=llmax {
218        let el = eta / xl;
219        let rl = (1.0 + el * el).sqrt(); // R_{Xl}
220        let sl = el + xl * xi; // S_{Xl}
221        let l_idx = llmax - lp; // 0-indexed destination (llmax-1 down to 0)
222        let fcl1 = (fcl * sl + fpl) / rl;
223        fpl = fcl1 * sl - fcl * rl;
224        fcl = fcl1;
225        fc[l_idx] = fcl;
226        fcp[l_idx] = fpl;
227        // Store R_{Xl} at index Xl (used in upward G recurrence at Xl=l+1).
228        rl_store[xl as usize] = rl;
229        xl -= 1.0;
230    }
231    if fcl == 0.0 {
232        fcl = acc; // avoid zero divide (SAMMY line 370)
233    }
234    f = fpl / fcl; // F'_0 / F_0 at L = 0
235
236    // ─────────────────────────────────────────────────────────────────────────
237    // CF2: Steed's algorithm for P + iQ at L = 0.
238    //
239    // P + iQ = (f + iρ) · (F_0 + iG_0) ⁻¹   (Wronskian formulation)
240    //
241    // SAMMY Coulfg lines 393–433.
242    // ─────────────────────────────────────────────────────────────────────────
243    // For Xlm = 0: E2mm1 = η² + 0·1 = η².
244    let wi = 2.0 * eta; // constant increment for Ai
245    let mut pk = 0.0f64;
246    let mut p = 0.0f64;
247    let mut q = 1.0 - eta * xi;
248    let mut ar = -(eta * eta); // = -E2mm1 for L=0
249    let mut ai = eta;
250    let br = 2.0 * (rho - eta); // fixed throughout CF2
251    let bi0 = 2.0; // initial Bi
252    let mut bi = bi0;
253    let denom0 = br * br + bi0 * bi0;
254    let mut dr = br / denom0;
255    let mut di = -bi0 / denom0;
256    let mut dp = -xi * (ar * di + ai * dr);
257    let mut dq = xi * (ar * dr - ai * di);
258
259    loop {
260        p += dp;
261        q += dq;
262        pk += 2.0;
263        ar += pk;
264        ai += wi;
265        bi += 2.0;
266        let d_re = ar * dr - ai * di + br;
267        let d_im = ai * dr + ar * di + bi;
268        let c = 1.0 / (d_re * d_re + d_im * d_im);
269        dr = c * d_re;
270        di = -c * d_im;
271        let a = br * dr - bi * di - 1.0;
272        let b = bi * dr + br * di;
273        let c2 = dp * a - dq * b;
274        dq = dp * b + dq * a;
275        dp = c2;
276        if pk > 2.0 * ABORT {
277            return None; // CF2 failed to converge
278        }
279        if dp.abs() + dq.abs() < (p.abs() + q.abs()) * acc {
280            break; // converged
281        }
282    }
283
284    // Degenerate check: Q too small (SAMMY Coulfg line 429 → error 130)
285    let acc4 = acc * 1e3; // Acc * Ten2 * Ten2 * 0.1 in SAMMY
286    if q.abs() <= acc4 * p.abs() {
287        return None;
288    }
289
290    // ─────────────────────────────────────────────────────────────────────────
291    // Normalization: compute F_0, G_0 from Wronskian and CF2 output.
292    //
293    // Gam = (f − P) / Q  [G_0 = Gam · F_0]
294    // W   = 1 / sqrt[(f−P)·Gam + Q]       [normalization factor]
295    // F_0 = W · sign(Fcl), G_0 = Gam · F_0
296    //
297    // SAMMY Coulfg lines 430–448.
298    // ─────────────────────────────────────────────────────────────────────────
299    let gam = (f - p) / q;
300    let w = 1.0 / ((f - p) * gam + q).abs().sqrt();
301    let fcm = w.copysign(fcl); // = |W| · sign(Fcl)  (SAMMY: dSIGN(W, Fcl))
302    let gcl0 = fcm * gam; // G_0
303    let gpl0 = gcl0 * (p - q / gam); // G'_0
304    let fcp0 = fcm * f; // F'_0
305    fc[0] = fcm;
306    fcp[0] = fcp0;
307
308    // Fast return for L = 0: no upward recurrence needed.
309    if lll == 0 {
310        return Some((fcm, gcl0, fcp0, gpl0));
311    }
312
313    // ─────────────────────────────────────────────────────────────────────────
314    // Upward G recurrence: L = 0 → Lll.
315    // Simultaneously renormalize stored F values.
316    //
317    // Recurrence (from Abramowitz & Stegun 14.2.2/14.2.3, reversed):
318    //   G_{l+1} = (S_{l+1} · G_l − G'_l) / R_{l+1}
319    //   G'_{l+1} = R_{l+1} · G_l − S_{l+1} · G_{l+1}
320    // where S_{l+1} = η/(l+1) + (l+1)/ρ, R_{l+1} = rl_store[l+1].
321    //
322    // SAMMY Coulfg lines 454–467.
323    // ─────────────────────────────────────────────────────────────────────────
324    // Renormalization factor for stored (unnormalized) F values.
325    // (SAMMY: W = W / dABS(Fcl), then Fc(L+1) = W * Fc(L+1))
326    let w_f = w / fcl.abs();
327
328    let mut gcl = gcl0;
329    let mut gpl = gpl0;
330    for k in 1..=lll {
331        let xl_k = k as f64;
332        let el = eta / xl_k;
333        let rl = rl_store[k]; // R_{k} stored during downward recurrence
334        let sl = el + xl_k * xi;
335        let gcl_new = (sl * gcl - gpl) / rl;
336        gpl = rl * gcl - sl * gcl_new;
337        gcl = gcl_new;
338        // Renormalize F at this level.
339        fc[k] *= w_f;
340        fcp[k] *= w_f;
341    }
342
343    Some((fc[lll], gcl, fcp[lll], gpl))
344}
345
346/// Coulomb penetrability P_L = ρ / (F_L² + G_L²).
347///
348/// Returns 0 if the wave functions cannot be computed (ρ too small, or the
349/// Coulomb fractions fail to converge — only occurs far outside the
350/// nuclear-physics domain).
351///
352/// ## SAMMY Reference
353/// `coulomb/mrml08.f90` Coulfg output: `Pcoul = Xx / (Fc(L)^2 + Gc(L)^2)`
354pub fn coulomb_penetrability(l: u32, eta: f64, rho: f64) -> f64 {
355    match coulomb_wave_functions(l, eta, rho) {
356        Some((fl, gl, _, _)) => rho / (fl * fl + gl * gl),
357        None => {
358            // Coulomb wave functions failed (ρ too small).
359            if eta.abs() < NEAR_ZERO_FLOOR {
360                // Neutral channel (η ≈ 0): fall back to the non-Coulomb
361                // (hard-sphere) penetrability, which is exact when η = 0.
362                crate::penetrability::penetrability(l, rho)
363            } else {
364                // Charged-particle channel (η > 0): the Coulomb barrier
365                // completely suppresses penetrability at very small ρ.
366                // Using the neutron fallback here would overestimate P_L
367                // by many orders of magnitude.
368                0.0
369            }
370        }
371    }
372}
373
374/// Coulomb shift factor S_L = ρ · (F_L·F'_L + G_L·G'_L) / (F_L² + G_L²).
375///
376/// Returns 0 if the wave functions cannot be computed.
377///
378/// ## SAMMY Reference
379/// `coulomb/mrml08.f90` Coulfg output:
380/// `Scoul = Xx * (Fc(L)*Fcp(L)+Gc(L)*Gcp(L)) / (Fc(L)^2+Gc(L)^2)`
381#[expect(dead_code, reason = "reserved for future charged-particle channels")]
382pub(crate) fn coulomb_shift(l: u32, eta: f64, rho: f64) -> f64 {
383    match coulomb_wave_functions(l, eta, rho) {
384        Some((fl, gl, flp, glp)) => {
385            let asq = fl * fl + gl * gl;
386            rho * (fl * flp + gl * glp) / asq
387        }
388        None => 0.0,
389    }
390}
391
392/// Coulomb hard-sphere phase: returns (sin φ_L, cos φ_L) where φ_L = atan2(F_L, G_L).
393///
394/// Returns (0, 1) (φ = 0) if the wave functions cannot be computed.
395///
396/// ## SAMMY Reference
397/// `coulomb/mrml08.f90` Coulfg output:
398/// `Sinphi = Fc(L)/A, Cosphi = Gc(L)/A` where `A = sqrt(Fc^2 + Gc^2)`
399#[expect(dead_code, reason = "reserved for future charged-particle channels")]
400pub(crate) fn coulomb_phase(l: u32, eta: f64, rho: f64) -> (f64, f64) {
401    match coulomb_wave_functions(l, eta, rho) {
402        Some((fl, gl, _, _)) => {
403            let a = (fl * fl + gl * gl).sqrt();
404            (fl / a, gl / a) // (sin φ, cos φ)
405        }
406        None => (0.0, 1.0),
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use crate::penetrability;
414
415    /// η = 0, L = 0: F_0 → sin ρ, G_0 → cos ρ (hard-sphere limit).
416    ///
417    /// SAMMY's convention has G_0 = +cos ρ (so atan2(F_0, G_0) = ρ, the
418    /// hard-sphere phase shift).  The Wronskian F·G' − G·F' = −1 in this
419    /// convention (W(G, F) = +1 in the standard A&S sense).
420    ///
421    /// Reference: Abramowitz & Stegun §14.1 (η → 0 limit of Coulomb functions);
422    /// SAMMY coulomb/mrml08.f90 Coulfg phase convention.
423    #[test]
424    fn coulomb_zero_eta_l0_matches_hard_sphere() {
425        // CF1+CF2 achieves ~1e-13 for large ρ; tolerance 1e-7 covers small ρ too.
426        for &rho in &[0.5f64, 1.0, 2.0, 5.0, 10.0] {
427            let (fl, gl, _, _) =
428                coulomb_wave_functions(0, 0.0, rho).expect("Should compute for rho > 0");
429            let f_exact = rho.sin();
430            let g_exact = rho.cos(); // SAMMY convention: G_0 = +cos ρ
431            assert!(
432                (fl - f_exact).abs() < 1e-7,
433                "F_0(η=0, ρ={rho}): got {fl}, expected {f_exact}"
434            );
435            assert!(
436                (gl - g_exact).abs() < 1e-7,
437                "G_0(η=0, ρ={rho}): got {gl}, expected {g_exact}"
438            );
439        }
440    }
441
442    /// η = 0, P_0 = ρ (matches hard-sphere penetrability).
443    ///
444    /// P_0 = ρ / (F_0² + G_0²) = ρ / (sin²ρ + cos²ρ) = ρ.
445    #[test]
446    fn coulomb_penetrability_zero_eta_l0() {
447        for &rho in &[0.1f64, 0.5, 1.0, 3.0] {
448            let p_coulomb = coulomb_penetrability(0, 0.0, rho);
449            let p_hard = penetrability::penetrability(0, rho); // = ρ
450            assert!(
451                (p_coulomb - p_hard).abs() < 1e-8,
452                "P_0(η=0, ρ={rho}): Coulomb={p_coulomb}, hard-sphere={p_hard}"
453            );
454        }
455    }
456
457    /// η = 0, P_1 = ρ³/(1+ρ²) (matches hard-sphere L=1 penetrability).
458    #[test]
459    fn coulomb_penetrability_zero_eta_l1() {
460        for &rho in &[0.1f64, 0.5, 1.0, 3.0] {
461            let p_coulomb = coulomb_penetrability(1, 0.0, rho);
462            let p_hard = penetrability::penetrability(1, rho);
463            assert!(
464                (p_coulomb - p_hard).abs() / (p_hard + 1e-30) < 1e-6,
465                "P_1(η=0, ρ={rho}): Coulomb={p_coulomb}, hard-sphere={p_hard}"
466            );
467        }
468    }
469
470    /// A neutral particle (Z=0) → η = 0.
471    #[test]
472    fn sommerfeld_eta_neutral_is_zero() {
473        // Neutron (Z=0) on U-238 (Z=92): η must be zero.
474        // ENDF LRF=7 stores charge directly: neutron Z=0, U-238 Z=92.
475        let eta = sommerfeld_eta(0.0, 92.0, 1.0, 236.006, 1e4);
476        assert_eq!(eta, 0.0, "Neutral particle should give η=0");
477    }
478
479    /// Proton on O-16 at E_cm = 1 MeV: known η ≈ 1.22.
480    ///
481    /// Formula: η = Z_a·Z_b·α·√(m_n·μ̃/(2·E_cm))
482    /// For p+O16: Z_a=1, Z_b=8, μ̃ = 1·16/17 ≈ 0.9412, E_cm = 1e6 eV.
483    /// η = 1·8 · (1/137.036) · √(939565420 · 0.9412 / (2 · 1e6))
484    ///   = 8/137.036 · √(442.3)  ≈ 0.0584 · 21.03 ≈ 1.22
485    ///
486    /// ENDF LRF=7 stores charge directly: proton Z=1, O-16 Z=8.
487    /// Reference: standard nuclear physics textbook value for p+O-16 at 1 MeV ≈ 1.22.
488    #[test]
489    fn sommerfeld_eta_proton_oxygen_known_value() {
490        let za_proton = 1.0f64; // proton charge Z=1
491        let zb_oxygen = 8.0f64; // O-16 charge Z=8
492        let ma = 1.0f64; // proton mass in neutron mass units
493        let mb = 16.0f64; // O-16 mass in neutron mass units (approx)
494        let e_cm_ev = 1.0e6f64; // 1 MeV in eV
495        let eta = sommerfeld_eta(za_proton, zb_oxygen, ma, mb, e_cm_ev);
496        // Expected: ~1.22 (standard nuclear physics reference)
497        assert!(
498            (eta - 1.22).abs() < 0.05,
499            "p+O-16 at 1 MeV: η={eta}, expected ~1.22"
500        );
501    }
502
503    /// Wronskian condition: |F_L · G'_L − G_L · F'_L| = 1 for all L, η, ρ.
504    ///
505    /// SAMMY uses G_0 = +cos ρ (η=0 hard-sphere), so the Wronskian
506    /// F·G' − G·F' = −1 (not +1).  This is the defining identity of the
507    /// Barnett normalization in SAMMY's phase convention: W(G, F) = +1.
508    ///
509    /// We check the magnitude = 1 to remain convention-agnostic.
510    #[test]
511    fn coulomb_wronskian_identity() {
512        for &l in &[0u32, 1, 2, 3] {
513            for &eta in &[0.0f64, 0.5, 2.0, 5.0] {
514                for &rho in &[0.5f64, 1.0, 5.0, 20.0] {
515                    if let Some((fl, gl, flp, glp)) = coulomb_wave_functions(l, eta, rho) {
516                        let wronskian = fl * glp - gl * flp;
517                        assert!(
518                            (wronskian.abs() - 1.0).abs() < 1e-8,
519                            "Wronskian at L={l}, η={eta}, ρ={rho}: |W|={}, expected 1",
520                            wronskian.abs()
521                        );
522                    }
523                }
524            }
525        }
526    }
527
528    /// Coulomb penetrability fallback at η = 0: when ρ is too small for
529    /// the CF1+CF2 algorithm (ρ ≤ acch ≈ 1e-8), the code falls back to
530    /// hard-sphere penetrability for η = 0 (neutral channels).
531    /// Verify the fallback produces the correct hard-sphere result.
532    #[test]
533    fn coulomb_penetrability_fallback_eta_zero() {
534        // ρ = 1e-10 is below acch (≈ 1e-8), so coulomb_wave_functions
535        // returns None.  The fallback for η ≈ 0 should use hard-sphere.
536        let rho_tiny = 1e-10;
537        for &l in &[0u32, 1, 2] {
538            let p_coulomb = coulomb_penetrability(l, 0.0, rho_tiny);
539            let p_hard = penetrability::penetrability(l, rho_tiny);
540            assert!(
541                (p_coulomb - p_hard).abs() < 1e-30 + 1e-6 * p_hard,
542                "Fallback mismatch at L={l}, ρ={rho_tiny}: Coulomb={p_coulomb}, hard-sphere={p_hard}"
543            );
544        }
545    }
546
547    /// Coulomb penetrability for charged particles at tiny ρ (below acch):
548    /// should return 0.0 (Coulomb barrier suppresses penetrability), NOT
549    /// the hard-sphere fallback.
550    #[test]
551    fn coulomb_penetrability_charged_tiny_rho_returns_zero() {
552        let rho_tiny = 1e-10;
553        let eta = 5.0; // charged channel
554        for &l in &[0u32, 1, 2] {
555            let p = coulomb_penetrability(l, eta, rho_tiny);
556            assert_eq!(
557                p, 0.0,
558                "Charged channel at tiny ρ should have P=0, got {p} at L={l}"
559            );
560        }
561    }
562}