Skip to main content

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