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}