nereids_physics/
penetrability.rs

1//! Hard-sphere penetrability, shift, and phase shift functions.
2//!
3//! These are the standard neutron penetrability factors for a hard-sphere
4//! potential, needed for the R-matrix cross-section calculation.
5//!
6//! ## SAMMY Reference
7//! - `rml/mrml07.f`: `Sinsix` (phase shifts), `Pgh` (penetrability + shift),
8//!   `Pf` (penetrability only)
9//! - SAMMY manual Section 2 (R-matrix theory)
10//!
11//! ## Definitions
12//! For channel radius a and neutron wave number k:
13//!   ρ = k·a
14//!
15//! The penetrability P_l(ρ), shift factor S_l(ρ), and hard-sphere phase
16//! shift φ_l(ρ) depend on orbital angular momentum l.
17
18use nereids_core::constants::NEAR_ZERO_FLOOR;
19
20/// Penetrability factor P_l(ρ) for orbital angular momentum l.
21///
22/// Returns the ratio of the outgoing wave amplitude at the nuclear surface
23/// to the asymptotic amplitude. For s-wave (l=0), P = ρ.
24///
25/// Reference: SAMMY `rml/mrml07.f` Pf function (lines 474-522)
26pub fn penetrability(l: u32, rho: f64) -> f64 {
27    let rho2 = rho * rho;
28    match l {
29        0 => rho,
30        1 => {
31            let denom = 1.0 + rho2;
32            rho2 * rho / denom
33        }
34        2 => {
35            let rho4 = rho2 * rho2;
36            let denom = 9.0 + 3.0 * rho2 + rho4;
37            rho4 * rho / denom
38        }
39        3 => {
40            let rho4 = rho2 * rho2;
41            let rho6 = rho4 * rho2;
42            let denom = 225.0 + 45.0 * rho2 + 6.0 * rho4 + rho6;
43            rho6 * rho / denom
44        }
45        4 => {
46            let rho4 = rho2 * rho2;
47            let rho6 = rho4 * rho2;
48            let rho8 = rho4 * rho4;
49            let denom = 11025.0 + 1575.0 * rho2 + 135.0 * rho4 + 10.0 * rho6 + rho8;
50            rho8 * rho / denom
51        }
52        _ => penetrability_general(l, rho),
53    }
54}
55
56/// Shift factor S_l(ρ).
57///
58/// The shift factor modifies the effective resonance energy.
59/// For s-wave, S_0 = 0. For higher l, S_l is negative and energy-dependent.
60///
61/// Reference: SAMMY `rml/mrml07.f` Pgh function (lines 347-469)
62pub fn shift_factor(l: u32, rho: f64) -> f64 {
63    let rho2 = rho * rho;
64    match l {
65        0 => 0.0,
66        1 => {
67            let denom = 1.0 + rho2;
68            -1.0 / denom
69        }
70        2 => {
71            let rho4 = rho2 * rho2;
72            let denom = 9.0 + 3.0 * rho2 + rho4;
73            -(18.0 + 3.0 * rho2) / denom
74        }
75        3 => {
76            let rho4 = rho2 * rho2;
77            let rho6 = rho4 * rho2;
78            let denom = 225.0 + 45.0 * rho2 + 6.0 * rho4 + rho6;
79            -(675.0 + 90.0 * rho2 + 6.0 * rho4) / denom
80        }
81        4 => {
82            let rho4 = rho2 * rho2;
83            let rho6 = rho4 * rho2;
84            let rho8 = rho4 * rho4;
85            let denom = 11025.0 + 1575.0 * rho2 + 135.0 * rho4 + 10.0 * rho6 + rho8;
86            -(44100.0 + 4725.0 * rho2 + 270.0 * rho4 + 10.0 * rho6) / denom
87        }
88        _ => shift_factor_general(l, rho),
89    }
90}
91
92/// Hard-sphere phase shift φ_l(ρ).
93///
94/// Reference: SAMMY `rml/mrml07.f` Sinsix function (lines 254-342)
95pub fn phase_shift(l: u32, rho: f64) -> f64 {
96    let rho2 = rho * rho;
97    match l {
98        0 => rho,
99        1 => rho - rho.atan(),
100        2 => rho - (3.0 * rho / (3.0 - rho2)).atan(),
101        3 => {
102            let num = rho * (15.0 - rho2);
103            let den = 15.0 - 6.0 * rho2;
104            rho - (num / den).atan()
105        }
106        4 => {
107            let rho4 = rho2 * rho2;
108            let num = rho * (105.0 - 10.0 * rho2);
109            let den = 105.0 - 45.0 * rho2 + rho4;
110            rho - (num / den).atan()
111        }
112        _ => phase_shift_general(l, rho),
113    }
114}
115
116/// Shift factor at imaginary channel argument S_l(iκ) for a closed channel.
117///
118/// For a channel with energy E_c < 0 (below threshold), the wave number is
119/// imaginary: k_c = iκ with κ = sqrt(2μ|E_c|) / ħ. The penetrability is
120/// zero by definition, but the shift factor S_l(iκ) is real and finite and
121/// must be included in the level matrix diagonal `L_c = (S_c − B_c) + i·P_c`.
122///
123/// S_l(iκ) is obtained by analytic continuation of S_l(ρ) to imaginary
124/// argument, i.e. substituting ρ² → −κ² in the Blatt-Weisskopf formula.
125/// For l ≤ 4 the result is a closed-form rational function of κ².
126/// For l > 4 a complex Bessel recursion is used.
127///
128/// Note: at κ = 1 for l = 1 the denominator vanishes (virtual-state pole).
129/// This is a genuine physical singularity; the caller receives ±∞ from
130/// floating-point arithmetic and should handle it via the normal `|L_c|`
131/// guard in the Y-matrix construction.
132///
133/// Reference: Lane & Thomas, Rev. Mod. Phys. 30 (1958);
134/// SAMMY `rml/mrml07.f` Pgh — PH = 1/(S−B+iP).
135pub fn shift_factor_closed(l: u32, kappa: f64) -> f64 {
136    // Substitute ρ² → −κ² in the explicit S_l(ρ) formulas.
137    let k2 = kappa * kappa;
138    match l {
139        0 => 0.0,
140        1 => -1.0 / (1.0 - k2),
141        2 => {
142            let k4 = k2 * k2;
143            let denom = 9.0 - 3.0 * k2 + k4;
144            -(18.0 - 3.0 * k2) / denom
145        }
146        3 => {
147            let k4 = k2 * k2;
148            let k6 = k4 * k2;
149            let denom = 225.0 - 45.0 * k2 + 6.0 * k4 - k6;
150            -(675.0 - 90.0 * k2 + 6.0 * k4) / denom
151        }
152        4 => {
153            let k4 = k2 * k2;
154            let k6 = k4 * k2;
155            let k8 = k4 * k4;
156            let denom = 11025.0 - 1575.0 * k2 + 135.0 * k4 - 10.0 * k6 + k8;
157            -(44100.0 - 4725.0 * k2 + 270.0 * k4 - 10.0 * k6) / denom
158        }
159        _ => shift_factor_closed_general(l, kappa),
160    }
161}
162
163/// General imaginary-argument shift factor for l > 4 via complex Bessel recursion.
164///
165/// Evaluates S_l(iκ) = Re[ρ(f∂f/∂ρ + g∂g/∂ρ)/(f²+g²)] at ρ = iκ
166/// using the complex Bessel functions f_l(iκ) and g_l(iκ) computed via
167/// upward recursion from the l=0 seed values:
168///   f_0(iκ) = i·sinh(κ),  g_0(iκ) = cosh(κ)
169fn shift_factor_closed_general(l: u32, kappa: f64) -> f64 {
170    if kappa < NEAR_ZERO_FLOOR {
171        return 0.0;
172    }
173
174    let (f, g) = bessel_fg_imaginary(l, kappa);
175    let h = kappa * 1e-6 + 1e-12;
176    let (fp, gp) = bessel_fg_imaginary(l, kappa + h);
177    let (fm, gm) = bessel_fg_imaginary(l, kappa - h);
178
179    // Numerical ∂/∂κ
180    let df_re = (fp.0 - fm.0) / (2.0 * h);
181    let df_im = (fp.1 - fm.1) / (2.0 * h);
182    let dg_re = (gp.0 - gm.0) / (2.0 * h);
183    let dg_im = (gp.1 - gm.1) / (2.0 * h);
184
185    // ∂/∂ρ = (1/i)·∂/∂κ = −i·∂/∂κ
186    // f·∂f/∂ρ = (f_re + i·f_im)·(df_im − i·df_re)
187    //   Im part = f_im·df_im − f_re·df_re
188    // ρ·(f·∂f/∂ρ + …): ρ = iκ, so Re[iκ·z] = −κ·Im[z]
189    let num_im = (f.1 * df_im - f.0 * df_re) + (g.1 * dg_im - g.0 * dg_re);
190    let numerator = -kappa * num_im;
191
192    // f² + g² (complex square, not modulus squared); result is real
193    let denom = f.0 * f.0 - f.1 * f.1 + g.0 * g.0 - g.1 * g.1;
194    if denom.abs() < NEAR_ZERO_FLOOR {
195        return 0.0;
196    }
197    numerator / denom
198}
199
200/// Bessel functions f_l(iκ) = iκ·j_l(iκ) and g_l(iκ) = −iκ·n_l(iκ)
201/// evaluated at imaginary argument ρ = iκ (κ > 0), returned as
202/// (f_re, f_im) and (g_re, g_im).
203///
204/// Seed values (l = 0):  f = (0, sinh κ),  g = (cosh κ, 0).
205/// The upward factor (2n+1)/ρ = (2n+1)/(iκ) is purely imaginary.
206fn bessel_fg_imaginary(l: u32, kappa: f64) -> ((f64, f64), (f64, f64)) {
207    let sh = kappa.sinh();
208    let ch = kappa.cosh();
209    let inv_k = 1.0 / kappa;
210
211    let mut f_prev = (0.0_f64, sh); // f_0 = i·sinh κ
212    let mut g_prev = (ch, 0.0_f64); // g_0 = cosh κ
213
214    if l == 0 {
215        return (f_prev, g_prev);
216    }
217
218    // l = 1: f_1 = f_0/(iκ) − g_0 = sinh(κ)/κ − cosh(κ)  [real]
219    //         g_1 = g_0/(iκ) + f_0 = i·(sinh(κ) − cosh(κ)/κ) [imaginary]
220    let mut f_curr = (sh * inv_k - ch, 0.0);
221    let mut g_curr = (0.0, sh - ch * inv_k);
222
223    if l == 1 {
224        return (f_curr, g_curr);
225    }
226
227    // Upward recursion: factor = (2n+1)/(iκ), purely imaginary with imaginary part a.
228    // (a·i)·(r + i·s) = −a·s + i·a·r
229    for n in 1..(l as usize) {
230        let a = -((2 * n + 1) as f64) * inv_k; // imaginary part of (2n+1)/(iκ)
231        let f_next = (-a * f_curr.1 - f_prev.0, a * f_curr.0 - f_prev.1);
232        let g_next = (-a * g_curr.1 - g_prev.0, a * g_curr.0 - g_prev.1);
233        f_prev = f_curr;
234        g_prev = g_curr;
235        f_curr = f_next;
236        g_curr = g_next;
237    }
238
239    (f_curr, g_curr)
240}
241
242/// Derivative of penetrability dP_l/dρ.
243///
244/// Needed for converting between observed and reduced widths when
245/// penetrability varies with energy.
246///
247/// Reference: SAMMY `rml/mrml07.f` Pf function
248#[cfg_attr(
249    not(test),
250    expect(dead_code, reason = "reserved for future width-conversion utilities")
251)]
252pub(crate) fn penetrability_derivative(l: u32, rho: f64) -> f64 {
253    let rho2 = rho * rho;
254    match l {
255        0 => 1.0,
256        1 => {
257            let denom = 1.0 + rho2;
258            rho2 * (3.0 + rho2) / (denom * denom)
259        }
260        2 => {
261            let rho4 = rho2 * rho2;
262            let denom = 9.0 + 3.0 * rho2 + rho4;
263            rho4 * (45.0 + 9.0 * rho2 + rho4) / (denom * denom)
264        }
265        3 => {
266            let rho4 = rho2 * rho2;
267            let rho6 = rho4 * rho2;
268            let denom = 225.0 + 45.0 * rho2 + 6.0 * rho4 + rho6;
269            // dP₃/dρ = ρ⁶(1575 + 225ρ² + 18ρ⁴ + ρ⁶) / D²
270            // SAMMY rml/mrml07.f Pgh subroutine, line 398.
271            rho6 * (1575.0 + 225.0 * rho2 + 18.0 * rho4 + rho6) / (denom * denom)
272        }
273        _ => {
274            // Numerical derivative as fallback for l >= 4
275            let h = rho * 1e-6 + 1e-12;
276            (penetrability(l, rho + h) - penetrability(l, rho - h)) / (2.0 * h)
277        }
278    }
279}
280
281/// General penetrability via recursion for l > 4.
282///
283/// Uses the recursion relation:
284///   P_l = ρ / |F_l + i·G_l|²
285/// where F_l, G_l are regular/irregular Coulomb functions (neutral case).
286///
287/// For the hard-sphere case (no Coulomb), we use the recursion:
288///   P_l = ρ·P_{l-1} / [(2l-1) - ρ²·P_{l-1}/P_l ... ]
289/// implemented via backwards recursion of f_l, g_l.
290fn penetrability_general(l: u32, rho: f64) -> f64 {
291    // Compute via backwards recursion of spherical Bessel functions.
292    // P_l = ρ / (f_l² + g_l²) where f_l, g_l are defined by:
293    //   f_l(ρ) = ρ·j_l(ρ), g_l(ρ) = -ρ·n_l(ρ)
294    // Use the standard recursion relations.
295    let (fl, gl) = bessel_fg(l, rho);
296    rho / (fl * fl + gl * gl)
297}
298
299/// General shift factor via recursion for l > 4.
300fn shift_factor_general(l: u32, rho: f64) -> f64 {
301    let (fl, gl) = bessel_fg(l, rho);
302    let denom = fl * fl + gl * gl;
303    // S_l = ρ·(f_l·f_l' + g_l·g_l') / (f_l² + g_l²)
304    // Using the Wronskian relation.
305    // For the hard-sphere: S_l = (l+1) - ρ²/(S_{l-1} + (l) - iP_{l-1}) ... complicated
306    // Simpler: use recursion S_l = ρ² / (l as f64 - S_{l-1}) - l as f64
307    // But this is unstable. Use numerical derivative instead.
308    let h = rho * 1e-6 + 1e-12;
309    let (fl_p, gl_p) = bessel_fg(l, rho + h);
310    let (fl_m, gl_m) = bessel_fg(l, rho - h);
311    let p_p = (rho + h) / (fl_p * fl_p + gl_p * gl_p);
312    let p_m = (rho - h) / (fl_m * fl_m + gl_m * gl_m);
313    let _dp_drho = (p_p - p_m) / (2.0 * h);
314    // S_l = ρ·dP_l/dρ / P_l - P_l + l(l+1)/ρ ... no, use relation:
315    // Easier: S_l(ρ) = ρ·(f_l·df_l/dρ + g_l·dg_l/dρ) / (f_l² + g_l²)
316    let dfl = (fl_p - fl_m) / (2.0 * h);
317    let dgl = (gl_p - gl_m) / (2.0 * h);
318    rho * (fl * dfl + gl * dgl) / denom
319}
320
321/// General phase shift via recursion for l > 4.
322fn phase_shift_general(l: u32, rho: f64) -> f64 {
323    let (fl, gl) = bessel_fg(l, rho);
324    fl.atan2(gl)
325}
326
327/// Compute f_l(ρ) = ρ·j_l(ρ) and g_l(ρ) = -ρ·n_l(ρ) via upward recursion.
328///
329/// j_l and n_l are spherical Bessel functions of the first and second kind.
330fn bessel_fg(l: u32, rho: f64) -> (f64, f64) {
331    // Near-zero guard.  The sign of g_0 is mathematically +1 (cos(0)),
332    // but we return -1.0 because this path is only reachable for l == 0
333    // (higher l returns NEG_INFINITY) and only P_l = rho / (f^2 + g^2)
334    // uses the result, where g^2 = 1 regardless of sign.
335    if rho.abs() < NEAR_ZERO_FLOOR {
336        return if l == 0 {
337            (0.0, -1.0)
338        } else {
339            (0.0, f64::NEG_INFINITY)
340        };
341    }
342
343    // Start with l=0:
344    // f_0 = ρ·j_0(ρ) = sin(ρ)
345    // g_0 = -ρ·n_0(ρ) = cos(ρ)
346    let mut f_prev = rho.sin();
347    let mut g_prev = rho.cos();
348
349    if l == 0 {
350        return (f_prev, g_prev);
351    }
352
353    // l=1:
354    // f_1 = ρ·j_1(ρ) = sin(ρ)/ρ - cos(ρ)
355    // g_1 = -ρ·n_1(ρ) = cos(ρ)/ρ + sin(ρ)
356    let mut f_curr = f_prev / rho - g_prev;
357    let mut g_curr = g_prev / rho + f_prev;
358
359    if l == 1 {
360        return (f_curr, g_curr);
361    }
362
363    // Upward recursion for l >= 2:
364    // f_{l+1} = ((2l+1)/ρ)·f_l - f_{l-1}
365    // g_{l+1} = ((2l+1)/ρ)·g_l - g_{l-1}
366    for n in 1..(l as i64) {
367        let factor = (2 * n + 1) as f64 / rho;
368        let f_next = factor * f_curr - f_prev;
369        let g_next = factor * g_curr - g_prev;
370        f_prev = f_curr;
371        g_prev = g_curr;
372        f_curr = f_next;
373        g_curr = g_next;
374    }
375
376    (f_curr, g_curr)
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[test]
384    fn test_penetrability_l0() {
385        // P_0(ρ) = ρ
386        assert!((penetrability(0, 0.5) - 0.5).abs() < 1e-15);
387        assert!((penetrability(0, 1.0) - 1.0).abs() < 1e-15);
388        assert!((penetrability(0, 0.001) - 0.001).abs() < 1e-15);
389    }
390
391    #[test]
392    fn test_penetrability_l1() {
393        // P_1(ρ) = ρ³/(1 + ρ²)
394        let rho = 0.5;
395        let expected = 0.5_f64.powi(3) / (1.0 + 0.25);
396        assert!((penetrability(1, rho) - expected).abs() < 1e-15);
397
398        let rho = 1.0;
399        let expected = 1.0 / 2.0;
400        assert!((penetrability(1, rho) - expected).abs() < 1e-15);
401    }
402
403    #[test]
404    fn test_penetrability_l2() {
405        // P_2(ρ) = ρ⁵/(9 + 3ρ² + ρ⁴)
406        let rho = 1.0;
407        let expected = 1.0 / (9.0 + 3.0 + 1.0);
408        assert!((penetrability(2, rho) - expected).abs() < 1e-15);
409    }
410
411    #[test]
412    fn test_shift_factor_l0() {
413        // S_0 = 0 always
414        assert!((shift_factor(0, 0.5)).abs() < 1e-15);
415        assert!((shift_factor(0, 5.0)).abs() < 1e-15);
416    }
417
418    #[test]
419    fn test_shift_factor_l1() {
420        // S_1(ρ) = -1/(1 + ρ²)
421        let rho = 1.0;
422        assert!((shift_factor(1, rho) - (-0.5)).abs() < 1e-15);
423        let rho = 0.0;
424        assert!((shift_factor(1, rho) - (-1.0)).abs() < 1e-15);
425    }
426
427    #[test]
428    fn test_phase_shift_l0() {
429        // φ_0(ρ) = ρ
430        assert!((phase_shift(0, 0.5) - 0.5).abs() < 1e-15);
431        assert!((phase_shift(0, 1.0) - 1.0).abs() < 1e-15);
432    }
433
434    #[test]
435    fn test_phase_shift_l1() {
436        // φ_1(ρ) = ρ - arctan(ρ)
437        let rho = 1.0;
438        let expected = 1.0 - 1.0_f64.atan();
439        assert!((phase_shift(1, rho) - expected).abs() < 1e-14);
440    }
441
442    #[test]
443    fn test_phase_shift_small_rho() {
444        // For small ρ, φ_l ≈ ρ^(2l+1) / (1·3·5···(2l+1))²
445        // φ_0(0.01) ≈ 0.01
446        assert!((phase_shift(0, 0.01) - 0.01).abs() < 1e-10);
447        // φ_1(0.01) ≈ 0.01 - arctan(0.01) ≈ 3.33e-7
448        let expected = 0.01 - 0.01_f64.atan();
449        assert!((phase_shift(1, 0.01) - expected).abs() < 1e-15);
450    }
451
452    #[test]
453    fn test_penetrability_derivative_l0() {
454        // dP_0/dρ = 1
455        assert!((penetrability_derivative(0, 1.0) - 1.0).abs() < 1e-15);
456    }
457
458    #[test]
459    fn test_penetrability_derivative_l1() {
460        // dP_1/dρ = ρ²(3 + ρ²)/(1 + ρ²)²
461        let rho = 1.0;
462        let expected = 1.0 * (3.0 + 1.0) / (2.0 * 2.0);
463        assert!((penetrability_derivative(1, rho) - expected).abs() < 1e-14);
464    }
465
466    #[test]
467    fn test_penetrability_derivative_l2() {
468        // dP₂/dρ = ρ⁴(45 + 9ρ² + ρ⁴) / (9 + 3ρ² + ρ⁴)²
469        let rho = 2.0;
470        let rho2 = rho * rho;
471        let rho4 = rho2 * rho2;
472        let denom = 9.0 + 3.0 * rho2 + rho4;
473        let expected = rho4 * (45.0 + 9.0 * rho2 + rho4) / (denom * denom);
474        assert!(
475            (penetrability_derivative(2, rho) - expected).abs() < 1e-14,
476            "L=2 derivative: got {}, expected {}",
477            penetrability_derivative(2, rho),
478            expected
479        );
480    }
481
482    #[test]
483    fn test_penetrability_derivative_l3() {
484        // dP₃/dρ = ρ⁶(1575 + 225ρ² + 18ρ⁴ + ρ⁶) / D²
485        // where D = 225 + 45ρ² + 6ρ⁴ + ρ⁶
486        // SAMMY rml/mrml07.f Pgh subroutine, line 398.
487        let rho = 1.5;
488        let rho2 = rho * rho;
489        let rho4 = rho2 * rho2;
490        let rho6 = rho4 * rho2;
491        let denom = 225.0 + 45.0 * rho2 + 6.0 * rho4 + rho6;
492        let expected = rho6 * (1575.0 + 225.0 * rho2 + 18.0 * rho4 + rho6) / (denom * denom);
493        assert!(
494            (penetrability_derivative(3, rho) - expected).abs() < 1e-14,
495            "L=3 derivative: got {}, expected {}",
496            penetrability_derivative(3, rho),
497            expected
498        );
499    }
500
501    #[test]
502    fn test_penetrability_derivative_l3_vs_numerical() {
503        // Cross-check L=3 analytic dP₃/dρ against numerical derivative.
504        // This catches coefficient errors independent of the formula.
505        let rho = 2.0;
506        let h = 1e-6;
507        let numerical = (penetrability(3, rho + h) - penetrability(3, rho - h)) / (2.0 * h);
508        let analytic = penetrability_derivative(3, rho);
509        assert!(
510            (analytic - numerical).abs() < 1e-6,
511            "L=3 analytic={}, numerical={}",
512            analytic,
513            numerical
514        );
515    }
516
517    #[test]
518    fn test_penetrability_derivative_l2_vs_numerical() {
519        // Cross-check L=2 analytic dP₂/dρ against numerical derivative.
520        let rho = 2.0;
521        let h = 1e-6;
522        let numerical = (penetrability(2, rho + h) - penetrability(2, rho - h)) / (2.0 * h);
523        let analytic = penetrability_derivative(2, rho);
524        assert!(
525            (analytic - numerical).abs() < 1e-6,
526            "L=2 analytic={}, numerical={}",
527            analytic,
528            numerical
529        );
530    }
531
532    #[test]
533    fn test_general_matches_explicit_l2() {
534        // Verify that the general recursion gives the same result as explicit l=2
535        let rho = 2.0;
536        let p_explicit = penetrability(2, rho);
537        let p_general = penetrability_general(2, rho);
538        assert!(
539            (p_explicit - p_general).abs() < 1e-10,
540            "explicit={}, general={}",
541            p_explicit,
542            p_general
543        );
544    }
545
546    // ── shift_factor_closed tests ─────────────────────────────────────────────
547
548    #[test]
549    fn test_shift_factor_closed_l0() {
550        // S_0 = 0 regardless of κ
551        assert_eq!(shift_factor_closed(0, 0.0), 0.0);
552        assert_eq!(shift_factor_closed(0, 2.0), 0.0);
553    }
554
555    #[test]
556    fn test_shift_factor_closed_l1_continuity() {
557        // At κ → 0: S_1(iκ) = −1/(1−κ²) → −1 = S_1(0) from the real-ρ formula.
558        // Continuity: S_1(ρ=0) = −1/(1+0) = −1; S_1(i·0) = −1/(1−0) = −1.
559        let s = shift_factor_closed(1, 1e-10);
560        assert!((s - (-1.0)).abs() < 1e-8, "got {s}");
561    }
562
563    #[test]
564    fn test_shift_factor_closed_l1_exact() {
565        // S_1(i·0.5) = −1/(1−0.25) = −4/3
566        let expected = -4.0 / 3.0;
567        let got = shift_factor_closed(1, 0.5);
568        assert!(
569            (got - expected).abs() < 1e-12,
570            "got {got}, expected {expected}"
571        );
572    }
573
574    #[test]
575    fn test_shift_factor_closed_l2_exact() {
576        // S_2(i·0.5): k2=0.25, k4=0.0625
577        // num = -(18−3·0.25) = -(18−0.75) = −17.25
578        // den = 9−3·0.25+0.0625 = 9−0.75+0.0625 = 8.3125
579        let expected = -17.25 / 8.3125;
580        let got = shift_factor_closed(2, 0.5);
581        assert!(
582            (got - expected).abs() < 1e-12,
583            "got {got}, expected {expected}"
584        );
585    }
586
587    #[test]
588    fn test_shift_factor_closed_general_matches_explicit_l4() {
589        // Verify general recursion against closed-form for l=4.
590        let kappa = 0.3;
591        let explicit = shift_factor_closed(4, kappa);
592        let general = shift_factor_closed_general(4, kappa);
593        assert!(
594            (explicit - general).abs() < 1e-8,
595            "l=4 kappa={kappa}: explicit={explicit}, general={general}"
596        );
597    }
598
599    #[test]
600    fn test_general_matches_explicit_l3() {
601        let rho = 1.5;
602        let p_explicit = penetrability(3, rho);
603        let p_general = penetrability_general(3, rho);
604        assert!(
605            (p_explicit - p_general).abs() < 1e-10,
606            "explicit={}, general={}",
607            p_explicit,
608            p_general
609        );
610    }
611}