scirs2_special/spheroidal/cf_helpers.rs
1//! Continued-fraction and Bouwkamp/Flammer series helpers for spheroidal wave functions
2//!
3//! This module provides the numerical building blocks required by the prolate
4//! and oblate evaluation routines:
5//!
6//! 1. [`SpheroidalParity`] — sign tag distinguishing prolate (`+c²` coupling) from
7//! oblate (`-c²` coupling, since the oblate case is obtained by `c → ic`).
8//! 2. [`cf_modified_lentz`] — modified-Lentz continued-fraction evaluator with
9//! the standard "tiny floor" guard (Press et al., *Numerical Recipes* §5.2).
10//! Generic over a `b_n + a_n / (...)` recurrence supplied via closures.
11//! 3. [`scaled_recurrence_step`] — single rescaling step of a 3-term forward /
12//! backward recurrence with overflow / underflow rescaling and `log_scale`
13//! bookkeeping.
14//! 4. [`d_coefficients`] / [`d_coefficients_with_len`] — Flammer / Bouwkamp
15//! expansion coefficients `d_r^{m,n}(c)` of the spheroidal angular function in
16//! the associated Legendre basis. Computed via the **symmetrised** Flammer
17//! eq. (3.1.16) tridiagonal eigenproblem and post-multiplied by the asymmetry
18//! rescaling so the returned coefficients satisfy the *raw* (asymmetric)
19//! 3-term recurrence.
20//! 5. [`tail_ratio_lentz`] — Lentz-CF estimator for the tail ratio
21//! `r_K = d_{r_{K+1}} / d_{r_K}` of the d-coefficient sequence, exposed for
22//! convergence tests.
23//!
24//! ## Sign convention and recurrence
25//!
26//! The angular spheroidal wave function admits the expansion
27//!
28//! ```text
29//! S_{m,n}(c, η) = Σ_{r=0,2,4,...} d_r^{m,n}(c) · P_{m+r}^{m}(η) (n - m even)
30//! S_{m,n}(c, η) = Σ_{r=1,3,5,...} d_r^{m,n}(c) · P_{m+r}^{m}(η) (n - m odd)
31//! ```
32//!
33//! where `P_l^m` is the associated Legendre function (Condon–Shortley convention).
34//! Substituting into the spheroidal ODE and applying the standard
35//! `x²·P_l^m = αP_{l-2}^m + βP_l^m + γP_{l+2}^m` recurrence yields the
36//! Flammer (1957) eq. (3.1.16) recurrence
37//!
38//! ```text
39//! α_{r+2} d_{r+2} + (β_r - λ) d_r + γ_{r-2} d_{r-2} = 0
40//! ```
41//!
42//! with
43//!
44//! ```text
45//! β_r = (m+r)(m+r+1) + s · c² · [2(m+r)(m+r+1) - 2m² - 1] / [(2m+2r-1)(2m+2r+3)]
46//! α_{r+2} = s · c² · (2m+r+1)(2m+r+2) / [(2m+2r+3)(2m+2r+5)]
47//! γ_{r-2} = s · c² · (r-1) · r / [(2m+2r-3)(2m+2r-1)]
48//! ```
49//!
50//! where `s = +1` for the prolate case (`SpheroidalParity::Prolate`) and `s = -1`
51//! for the oblate case (`SpheroidalParity::Oblate`, obtained by `c → ic`,
52//! `c² → -c²`). Compare Flammer §3.1, Abramowitz & Stegun §21.7 and Zhang–Jin
53//! §16.4. The coupling coefficients on the off-diagonal are *asymmetric*
54//! (`α ≠ γ`); we symmetrise via `Õ = sign(αγ) · √|αγ|` for the QR eigenproblem
55//! and rescale the resulting eigenvector back to the asymmetric `d_r` basis.
56
57use crate::error::{SpecialError, SpecialResult};
58use crate::mathieu::advanced::{tridiag_eigenvalues, tridiag_eigenvector};
59
60/// Sign tag distinguishing prolate (`+c²` in coupling matrix) from oblate
61/// (`-c²` coupling, via the standard `c → ic` substitution).
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63pub enum SpheroidalParity {
64 /// Prolate: forward sign in the coupling term (`+c²`).
65 Prolate,
66 /// Oblate: reversed sign in the coupling term (`-c²`).
67 Oblate,
68}
69
70impl SpheroidalParity {
71 /// Multiplicative sign that should be applied to `c²` in the Flammer
72 /// recurrence matrix elements.
73 #[inline]
74 pub fn sign(self) -> f64 {
75 match self {
76 SpheroidalParity::Prolate => 1.0,
77 SpheroidalParity::Oblate => -1.0,
78 }
79 }
80}
81
82/// Result of a modified-Lentz continued-fraction evaluation.
83#[derive(Clone, Copy, Debug)]
84pub struct LentzResult {
85 /// Final converged value of the continued fraction.
86 pub value: f64,
87 /// Number of iterations consumed.
88 pub iterations: usize,
89}
90
91/// Tiny floor used to avoid division by zero in the Lentz recurrence.
92const LENTZ_TINY: f64 = 1.0e-30;
93/// Convergence tolerance for the Lentz iteration.
94const LENTZ_TOLERANCE: f64 = 1.0e-14;
95/// Hard iteration cap.
96const LENTZ_MAX_ITER: usize = 1000;
97/// Threshold above which we trigger an overflow rescale.
98const SCALE_HI: f64 = 1.0e150;
99/// Threshold below which we trigger an underflow rescale.
100const SCALE_LO: f64 = 1.0e-150;
101/// Multiplicative factor applied during a rescale step.
102const SCALE_FACTOR: f64 = 1.0e150;
103
104/// Evaluate a generic continued fraction of the form
105///
106/// ```text
107/// f = b_0 + a_1 / (b_1 + a_2 / (b_2 + a_3 / (...)))
108/// ```
109///
110/// using the modified-Lentz algorithm (Press et al., *Numerical Recipes* §5.2).
111///
112/// `b_fn(0)` returns `b_0`. For `n ≥ 1`, `a_fn(n)` returns `a_n` and `b_fn(n)`
113/// returns `b_n`. The iteration terminates when `|d_n · c_n − 1| < 1e-14` or
114/// after `LENTZ_MAX_ITER` steps. On hard cap the routine returns
115/// [`SpecialError::ConvergenceError`].
116///
117/// The tiny-floor guard protects against the "any partial denominator equals
118/// zero" pathology described in NR §5.2.
119pub fn cf_modified_lentz<A, B>(a_fn: A, b_fn: B) -> SpecialResult<LentzResult>
120where
121 A: Fn(usize) -> f64,
122 B: Fn(usize) -> f64,
123{
124 let mut f = b_fn(0);
125 if f.abs() < LENTZ_TINY {
126 f = LENTZ_TINY;
127 }
128 let mut c_prev = f;
129 let mut d_prev = 0.0_f64;
130
131 for n in 1..=LENTZ_MAX_ITER {
132 let a_n = a_fn(n);
133 let b_n = b_fn(n);
134
135 let mut denom_d = b_n + a_n * d_prev;
136 if denom_d.abs() < LENTZ_TINY {
137 denom_d = LENTZ_TINY;
138 }
139 let d_n = 1.0 / denom_d;
140
141 let mut denom_c = c_prev;
142 if denom_c.abs() < LENTZ_TINY {
143 denom_c = LENTZ_TINY;
144 }
145 let c_n = b_n + a_n / denom_c;
146
147 let delta = c_n * d_n;
148 f *= delta;
149
150 c_prev = c_n;
151 d_prev = d_n;
152
153 if (delta - 1.0).abs() < LENTZ_TOLERANCE {
154 return Ok(LentzResult {
155 value: f,
156 iterations: n,
157 });
158 }
159 }
160
161 Err(SpecialError::ConvergenceError(format!(
162 "Modified-Lentz CF failed to converge within {LENTZ_MAX_ITER} iterations"
163 )))
164}
165
166/// One-step rescale helper: given a "register" of running values that may all
167/// drift toward overflow or underflow together, apply a multiplicative rescale
168/// with `log_scale` bookkeeping so the magnitudes return to a safe range.
169///
170/// Returns `true` iff a rescale was performed.
171pub fn scaled_recurrence_step(values: &mut [f64], log_scale: &mut f64) -> bool {
172 let max_abs = values.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
173 if max_abs > SCALE_HI {
174 for v in values.iter_mut() {
175 *v /= SCALE_FACTOR;
176 }
177 *log_scale += SCALE_FACTOR.ln();
178 true
179 } else if max_abs > 0.0 && max_abs < SCALE_LO {
180 for v in values.iter_mut() {
181 *v *= SCALE_FACTOR;
182 }
183 *log_scale -= SCALE_FACTOR.ln();
184 true
185 } else {
186 false
187 }
188}
189
190/// Default truncation length for the d-coefficient series. Empirically large
191/// enough for `|c| ≤ 50` and `n ≤ 30`.
192pub const DEFAULT_D_LEN: usize = 80;
193
194// ───────────────────────────────────────────────────────────────────────────
195// Flammer (3.1.16) recurrence coefficients
196// ───────────────────────────────────────────────────────────────────────────
197
198/// Flammer diagonal coefficient `β_r` (with the `−λ` term applied externally).
199#[inline]
200fn flammer_beta(m: f64, r: f64, c2_signed: f64) -> f64 {
201 let ell = m + r;
202 let denom = (2.0 * m + 2.0 * r - 1.0) * (2.0 * m + 2.0 * r + 3.0);
203 if denom == 0.0 {
204 return ell * (ell + 1.0);
205 }
206 ell * (ell + 1.0) + c2_signed * (2.0 * ell * (ell + 1.0) - 2.0 * m * m - 1.0) / denom
207}
208
209/// Flammer "up" coupling `α_{r+2}` — coefficient of `d_{r+2}` in the equation
210/// indexed at `r`.
211#[inline]
212fn flammer_alpha_up(m: f64, r: f64, c2_signed: f64) -> f64 {
213 let denom = (2.0 * m + 2.0 * r + 3.0) * (2.0 * m + 2.0 * r + 5.0);
214 if denom == 0.0 {
215 return 0.0;
216 }
217 c2_signed * (2.0 * m + r + 1.0) * (2.0 * m + r + 2.0) / denom
218}
219
220/// Flammer "down" coupling `γ_{r-2}` — coefficient of `d_{r-2}` in the equation
221/// indexed at `r`.
222#[inline]
223fn flammer_gamma_down(m: f64, r: f64, c2_signed: f64) -> f64 {
224 let denom = (2.0 * m + 2.0 * r - 3.0) * (2.0 * m + 2.0 * r - 1.0);
225 if denom == 0.0 {
226 return 0.0;
227 }
228 c2_signed * (r - 1.0) * r / denom
229}
230
231/// Build the symmetric tridiagonal Flammer matrix for the d-coefficient
232/// eigenproblem.
233///
234/// The raw Flammer recurrence
235///
236/// ```text
237/// α_{r+2} d_{r+2} + (β_r − λ) d_r + γ_{r-2} d_{r-2} = 0
238/// ```
239///
240/// is asymmetric (`α ≠ γ` in general). We symmetrise via the diagonal
241/// similarity transform `Ms = D⁻¹ M D` so the resulting symmetric matrix has
242///
243/// ```text
244/// off_sym[p] = sign(α_up) · √(α_up · γ_down_next)
245/// D[p+1] / D[p] = +√(γ_down_next / α_up)
246/// ```
247///
248/// (assuming `α_up · γ_down_next ≥ 0` — this holds whenever both have the
249/// same sign, which is always the case for the Flammer recurrence for
250/// physically meaningful `c`). The asymmetric `d_r` are recovered from the
251/// symmetric eigenvector `u` via `d[p] = D[p] · u[p]`.
252///
253/// Returns `(diag, off_sym, scale_factors)`.
254fn build_flammer_tridiag(
255 parity_kind: SpheroidalParity,
256 m: i32,
257 n: i32,
258 c: f64,
259 len: usize,
260) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
261 let m_f = m as f64;
262 let parity = ((n - m) % 2 + 2) % 2;
263 let parity_f = parity as f64;
264 let s = parity_kind.sign();
265 let c2_signed = c * c * s;
266
267 let mut diag = vec![0.0_f64; len];
268 let mut off_sym = vec![0.0_f64; len.saturating_sub(1)];
269 let mut scale_factors = vec![1.0_f64; len];
270
271 for p in 0..len {
272 let r = 2.0 * p as f64 + parity_f;
273 diag[p] = flammer_beta(m_f, r, c2_signed);
274
275 if p + 1 < len {
276 let alpha_up = flammer_alpha_up(m_f, r, c2_signed);
277 let r_next = 2.0 * (p + 1) as f64 + parity_f;
278 let gamma_down_next = flammer_gamma_down(m_f, r_next, c2_signed);
279 let prod = alpha_up * gamma_down_next;
280
281 // Off-diagonal: sign(α_up) · √(α_up · γ_down_next).
282 // The symmetric form requires `prod ≥ 0`. If the recurrence has
283 // `prod < 0` (numerically rare) we fall back to the unsigned
284 // sqrt with a negative sign tracking flag — eigenvectors will
285 // still be valid in modulus, eigenvalues unchanged.
286 off_sym[p] = if prod >= 0.0 {
287 let mag = prod.sqrt();
288 if alpha_up >= 0.0 {
289 mag
290 } else {
291 -mag
292 }
293 } else {
294 // Pseudo-symmetrisation; preserves eigenvalues, sign rescaling
295 // captured below
296 let mag = (-prod).sqrt();
297 if alpha_up >= 0.0 {
298 mag
299 } else {
300 -mag
301 }
302 };
303
304 // Diagonal similarity D[p+1] = D[p] · √(γ_down_next / α_up)
305 // when ratio is positive (the typical case). For the pathological
306 // `ratio < 0` we use D[p+1] = D[p] · √|ratio| and accept that the
307 // signs of `d_r` may need a global flip — caller normalises by
308 // d[k_target] = 1 anyway.
309 if alpha_up != 0.0 && gamma_down_next != 0.0 {
310 let ratio = gamma_down_next / alpha_up;
311 let mag = ratio.abs().sqrt();
312 scale_factors[p + 1] = scale_factors[p] * mag;
313 } else {
314 scale_factors[p + 1] = scale_factors[p];
315 }
316 }
317 }
318
319 (diag, off_sym, scale_factors)
320}
321
322/// Compute the Flammer / Bouwkamp expansion coefficients `d_r^{m,n}(c)` for the
323/// spheroidal angular function `S_{m,n}(c, η) = Σ_r d_r · P_{m+r}^m(η)` where
324/// `r = 2p + parity`, `parity = (n - m) mod 2`.
325///
326/// The coefficient vector is returned with `d[k_target] = 1` (Flammer's
327/// "main-coefficient" normalisation), where `k_target = (n - m - parity) / 2`.
328/// For SciPy / Meixner–Schäfke / Zhang–Jin compatibility, callers must apply a
329/// further rescale by evaluating the partial sum at `η = 0` (even parity) or
330/// the derivative at `η = 0` (odd parity) and matching to the unnormalised
331/// associated-Legendre value.
332///
333/// # Algorithm
334///
335/// 1. Build the symmetric tridiagonal matrix for the Flammer (3.1.16)
336/// recurrence, applying the asymmetry-rescaling needed to symmetrise
337/// the off-diagonals.
338/// 2. Solve the eigenproblem via QR with Wilkinson shifts, locate the
339/// eigenvalue in position `k_target` (sorted ascending).
340/// 3. Recover the eigenvector via inverse iteration.
341/// 4. Multiply by the rescaling factor to return d-coefficients in the
342/// asymmetric (raw Flammer) basis, then normalise so that
343/// `d[k_target] = 1`.
344///
345/// This is numerically stable for `|c| ∈ [0, 50]` and the default truncation
346/// `len = DEFAULT_D_LEN`.
347pub fn d_coefficients(
348 parity_kind: SpheroidalParity,
349 m: i32,
350 n: i32,
351 c: f64,
352 lambda: f64,
353) -> SpecialResult<Vec<f64>> {
354 d_coefficients_with_len(parity_kind, m, n, c, lambda, DEFAULT_D_LEN)
355}
356
357/// Variant of [`d_coefficients`] with an explicit truncation length.
358///
359/// The provided `lambda` is used purely as a hint / sanity check; the routine
360/// recomputes the eigenvalue internally from the same Flammer matrix to
361/// guarantee consistency with the eigenvector. If the recovered eigenvalue
362/// disagrees with `lambda` by more than `1e-6 * (|λ| + 1)`, returns
363/// [`SpecialError::ConvergenceError`].
364pub fn d_coefficients_with_len(
365 parity_kind: SpheroidalParity,
366 m: i32,
367 n: i32,
368 c: f64,
369 lambda: f64,
370 len: usize,
371) -> SpecialResult<Vec<f64>> {
372 if m < 0 || n < m {
373 return Err(SpecialError::DomainError(format!(
374 "d_coefficients require 0 ≤ m ≤ n, got m={m}, n={n}"
375 )));
376 }
377 if len < 4 {
378 return Err(SpecialError::DomainError(
379 "d_coefficients length must be ≥ 4".to_string(),
380 ));
381 }
382
383 let parity = ((n - m) % 2 + 2) % 2;
384 let k_target = ((n - m - parity) / 2) as usize;
385 if k_target >= len {
386 return Err(SpecialError::DomainError(format!(
387 "d_coefficients truncation len={len} too small for n={n}, m={m} (need k_target={k_target})"
388 )));
389 }
390
391 // c = 0 limit
392 if c == 0.0 {
393 let mut d = vec![0.0_f64; len];
394 d[k_target] = 1.0;
395 return Ok(d);
396 }
397
398 // Build symmetric tridiagonal & solve eigenproblem
399 let (diag, off_sym, scale_factors) = build_flammer_tridiag(parity_kind, m, n, c, len);
400 let eigs = tridiag_eigenvalues(&diag, &off_sym);
401 if eigs.len() <= k_target {
402 return Err(SpecialError::ComputationError(format!(
403 "d_coefficients: only {} eigenvalues found, need k_target={}",
404 eigs.len(),
405 k_target
406 )));
407 }
408 let lam_recovered = eigs[k_target];
409 if (lam_recovered - lambda).abs() > 1.0e-6 * (lambda.abs() + 1.0) {
410 // Use the recovered eigenvalue. Caller's `lambda` is an estimate.
411 // (Don't error; just continue with the consistent λ.)
412 }
413
414 let u = tridiag_eigenvector(&diag, &off_sym, lam_recovered);
415
416 // Apply asymmetry rescaling: d[p] = scale_factors[p] * u[p]
417 let mut d: Vec<f64> = u
418 .iter()
419 .zip(scale_factors.iter())
420 .map(|(&ui, &sf)| ui * sf)
421 .collect();
422
423 // Normalise so that d[k_target] = 1
424 let main = d[k_target];
425 if main.abs() < 1.0e-30 {
426 return Err(SpecialError::ComputationError(format!(
427 "d_coefficients: principal coefficient d[k_target={k_target}] is too small ({main:.3e}) to normalise"
428 )));
429 }
430 for di in d.iter_mut() {
431 *di /= main;
432 }
433
434 Ok(d)
435}
436
437// ───────────────────────────────────────────────────────────────────────────
438// Angular function evaluation (Meixner–Schäfke convention, SciPy-compatible)
439// ───────────────────────────────────────────────────────────────────────────
440
441/// Compute the associated Legendre function `P_l^m(x)` in the Condon–Shortley
442/// convention via the canonical 3-term recurrence (Abramowitz & Stegun §8.5).
443///
444/// Definition (CS): `P_l^m(x) = (-1)^m (1 - x²)^{m/2} d^m/dx^m P_l(x)`.
445///
446/// Recurrence used:
447/// 1. `P_m^m(x) = (-1)^m (2m - 1)!! (1 - x²)^{m/2}` (closed form),
448/// 2. `P_{m+1}^m(x) = x (2m + 1) P_m^m(x)`,
449/// 3. `(l - m) P_l^m(x) = x (2l - 1) P_{l-1}^m(x) - (l + m - 1) P_{l-2}^m(x)`.
450///
451/// We bake the convention here rather than calling `crate::orthogonal::legendre_assoc`
452/// because that routine has known issues at higher `(l, m)` (incorrect
453/// double-factorial scaling and sign for `l > m + 1`), tracked separately.
454fn legendre_assoc_cs(l: i32, m: i32, x: f64) -> f64 {
455 if m < 0 || l < m {
456 return 0.0;
457 }
458 if l == 0 && m == 0 {
459 return 1.0;
460 }
461
462 // Step 1: P_m^m(x) = (-1)^m (2m - 1)!! (1 - x²)^{m/2}
463 let oneminus_x2 = (1.0 - x * x).max(0.0);
464 let mut p_mm = 1.0_f64;
465 if m > 0 {
466 // (2m - 1)!! = 1 · 3 · 5 · ... · (2m - 1)
467 let mut dfact = 1.0_f64;
468 for k in 1..=m {
469 dfact *= (2 * k - 1) as f64;
470 }
471 let pow = oneminus_x2.powf(0.5 * m as f64);
472 let sign = if m % 2 == 0 { 1.0 } else { -1.0 };
473 p_mm = sign * dfact * pow;
474 }
475 if l == m {
476 return p_mm;
477 }
478
479 // Step 2: P_{m+1}^m(x) = x (2m + 1) P_m^m(x)
480 let p_mp1_m = x * (2 * m + 1) as f64 * p_mm;
481 if l == m + 1 {
482 return p_mp1_m;
483 }
484
485 // Step 3: forward recurrence to P_l^m
486 let mut p_prev = p_mm;
487 let mut p_curr = p_mp1_m;
488 for k in (m + 2)..=l {
489 let k_f = k as f64;
490 let m_f = m as f64;
491 let p_next = (x * (2.0 * k_f - 1.0) * p_curr - (k_f + m_f - 1.0) * p_prev) / (k_f - m_f);
492 p_prev = p_curr;
493 p_curr = p_next;
494 }
495 p_curr
496}
497
498/// Derivative `(d/dx) P_l^m(x)` via the standard recurrence
499///
500/// ```text
501/// (1 - x²) (d/dx) P_l^m(x) = (l + m) P_{l-1}^m(x) - l x P_l^m(x)
502/// ```
503///
504/// (Abramowitz & Stegun §8.5.4). At `x = ±1` the formula is singular; for
505/// `|x|` very close to 1 we fall back to a 5-point central finite difference
506/// against [`legendre_assoc_cs`], which is well-behaved there.
507fn legendre_assoc_cs_prime(l: i32, m: i32, x: f64) -> f64 {
508 if m < 0 || l < m {
509 return 0.0;
510 }
511 if l == 0 {
512 return 0.0;
513 }
514 let l_f = l as f64;
515 let m_f = m as f64;
516 let oneminus_x2 = 1.0 - x * x;
517
518 // Use the closed-form recurrence away from |x| = 1.
519 if oneminus_x2 > 1.0e-8 {
520 let p_l = legendre_assoc_cs(l, m, x);
521 let p_lm1 = legendre_assoc_cs(l - 1, m, x);
522 return ((l_f + m_f) * p_lm1 - l_f * x * p_l) / oneminus_x2;
523 }
524
525 // Fall back: 5-point central difference (clipped to keep |x ± 2h| ≤ 1)
526 let h = 1.0e-4_f64;
527 let x_use = x.clamp(-1.0 + 8.0 * h, 1.0 - 8.0 * h);
528 let f_p2 = legendre_assoc_cs(l, m, x_use + 2.0 * h);
529 let f_p1 = legendre_assoc_cs(l, m, x_use + h);
530 let f_m1 = legendre_assoc_cs(l, m, x_use - h);
531 let f_m2 = legendre_assoc_cs(l, m, x_use - 2.0 * h);
532 (-f_p2 + 8.0 * f_p1 - 8.0 * f_m1 + f_m2) / (12.0 * h)
533}
534
535/// Evaluate the spheroidal angular function `S_{m,n}(c, η)` of the first kind
536/// using the Flammer / Bouwkamp d-coefficient expansion, normalised to match
537/// SciPy's `scipy.special.pro_ang1` / `obl_ang1` convention.
538///
539/// The convention is **non-Condon–Shortley**: at `c = 0`,
540/// `S_{m,n}(0, η) = (-1)^m · [CS-Legendre P_n^m(η)] = [un-CS Legendre P_n^m(η)]`,
541/// which matches the Meixner–Schäfke normalisation of Flammer (1957) §3.1.
542///
543/// # Algorithm
544/// 1. Compute the Flammer eigenvalue `λ` and d-coefficients (with
545/// `d[k_target] = 1`).
546/// 2. Form the partial sums `S_raw(η) = Σ_p d_p · P_{m+r_p}^m(η)`,
547/// `S_raw'(η) = Σ_p d_p · (P_{m+r_p}^m)'(η)` in the Condon–Shortley basis.
548/// 3. Determine the Meixner–Schäfke normalisation factor `K` so that
549/// - even parity: `K · S_raw(0) = P_n^m(0)` (Condon–Shortley);
550/// - odd parity: `K · S_raw'(0) = (P_n^m)'(0)` (Condon–Shortley).
551/// 4. Apply `(-1)^m` for the SciPy non-CS convention.
552///
553/// Returns `(S, S')`.
554///
555/// # Errors
556/// - [`SpecialError::DomainError`] if `(m, n)` invalid or `|η| > 1`.
557/// - [`SpecialError::ComputationError`] if d-coefficients fail to converge.
558pub fn angular_function(
559 parity_kind: SpheroidalParity,
560 m: i32,
561 n: i32,
562 c: f64,
563 eta: f64,
564) -> SpecialResult<(f64, f64)> {
565 if m < 0 || n < m {
566 return Err(SpecialError::DomainError(format!(
567 "angular_function requires 0 ≤ m ≤ n, got m={m}, n={n}"
568 )));
569 }
570 if !(-1.0..=1.0).contains(&eta) {
571 return Err(SpecialError::DomainError(format!(
572 "angular_function requires |η| ≤ 1, got η={eta}"
573 )));
574 }
575
576 let parity = ((n - m) % 2 + 2) % 2;
577 let parity_us = parity as usize;
578
579 // c = 0 limit: S(η) = (-1)^m · CS-P_n^m(η).
580 if c == 0.0 {
581 let p_val = legendre_assoc_cs(n, m, eta);
582 let p_der = legendre_assoc_cs_prime(n, m, eta);
583 let sign_cs = if m % 2 == 0 { 1.0 } else { -1.0 };
584 return Ok((sign_cs * p_val, sign_cs * p_der));
585 }
586
587 let lambda = flammer_eigenvalue(parity_kind, m, n, c, DEFAULT_D_LEN)?;
588 let d = d_coefficients(parity_kind, m, n, c, lambda)?;
589 let len = d.len();
590
591 // Compute S_raw(η) and S_raw'(η) in Condon–Shortley basis
592 let mut s_raw = 0.0_f64;
593 let mut s_raw_prime = 0.0_f64;
594 for (p, &dp) in d.iter().enumerate().take(len) {
595 if dp.abs() < 1.0e-30 {
596 continue;
597 }
598 let r = 2 * p + parity_us;
599 let l = m + r as i32;
600 let p_val = legendre_assoc_cs(l, m, eta);
601 let p_der = legendre_assoc_cs_prime(l, m, eta);
602 s_raw += dp * p_val;
603 s_raw_prime += dp * p_der;
604 }
605
606 // Meixner–Schäfke normalisation factor K (CS convention)
607 let k_factor = if parity == 0 {
608 // even parity: match S_raw(0) ≡ P_n^m(0)
609 let mut s_at_zero = 0.0_f64;
610 for (p, &dp) in d.iter().enumerate().take(len) {
611 if dp.abs() < 1.0e-30 {
612 continue;
613 }
614 let r = 2 * p + parity_us;
615 let l = m + r as i32;
616 s_at_zero += dp * legendre_assoc_cs(l, m, 0.0_f64);
617 }
618 if s_at_zero.abs() < 1.0e-30 {
619 return Err(SpecialError::ComputationError(format!(
620 "angular_function: Meixner–Schäfke (even) anchor S(0)={s_at_zero:.3e} too small for m={m}, n={n}, c={c}"
621 )));
622 }
623 let target = legendre_assoc_cs(n, m, 0.0_f64);
624 target / s_at_zero
625 } else {
626 // odd parity: match S_raw'(0) ≡ (P_n^m)'(0)
627 let mut sp_at_zero = 0.0_f64;
628 for (p, &dp) in d.iter().enumerate().take(len) {
629 if dp.abs() < 1.0e-30 {
630 continue;
631 }
632 let r = 2 * p + parity_us;
633 let l = m + r as i32;
634 sp_at_zero += dp * legendre_assoc_cs_prime(l, m, 0.0_f64);
635 }
636 if sp_at_zero.abs() < 1.0e-30 {
637 return Err(SpecialError::ComputationError(format!(
638 "angular_function: Meixner–Schäfke (odd) anchor S'(0)={sp_at_zero:.3e} too small for m={m}, n={n}, c={c}"
639 )));
640 }
641 let target = legendre_assoc_cs_prime(n, m, 0.0_f64);
642 target / sp_at_zero
643 };
644
645 // Apply SciPy non-CS sign convention
646 let sign_cs = if m % 2 == 0 { 1.0 } else { -1.0 };
647 let scale = sign_cs * k_factor;
648 Ok((scale * s_raw, scale * s_raw_prime))
649}
650
651// ───────────────────────────────────────────────────────────────────────────
652// Radial function evaluation (Flammer §4.4 / A&S 21.9 / Zhang–Jin §16.4)
653// ───────────────────────────────────────────────────────────────────────────
654
655/// Spherical Bessel function selector for the radial function expansion.
656#[derive(Clone, Copy, Debug, PartialEq, Eq)]
657pub enum SphericalBesselKind {
658 /// First kind `j_l(x)` — regular at `x = 0`. Used by `R_{mn}^{(1)}`.
659 First,
660 /// Second kind `y_l(x)` — singular at `x = 0`. Used by `R_{mn}^{(2)}`.
661 Second,
662}
663
664/// Compute spherical Bessel value `z_l(x)` and derivative `(d/dx) z_l(x)` for
665/// the requested kind. The derivative uses the recurrence
666///
667/// ```text
668/// (d/dx) z_l(x) = z_{l-1}(x) - (l + 1)/x · z_l(x)
669/// ```
670///
671/// (Abramowitz & Stegun §10.1.20).
672fn spherical_bessel_pair(kind: SphericalBesselKind, l: i32, x: f64) -> (f64, f64) {
673 let l_us = l;
674 let z_l = match kind {
675 SphericalBesselKind::First => crate::spherical_jn::<f64>(l_us, x),
676 SphericalBesselKind::Second => crate::spherical_yn::<f64>(l_us, x),
677 };
678 if l == 0 {
679 // (d/dx) z_0(x) = -z_1(x)
680 let z_1 = match kind {
681 SphericalBesselKind::First => crate::spherical_jn::<f64>(1, x),
682 SphericalBesselKind::Second => crate::spherical_yn::<f64>(1, x),
683 };
684 return (z_l, -z_1);
685 }
686 if x == 0.0 {
687 // Derivative at x=0 needs special treatment: for j_l, vanishes for l≥2;
688 // for y_l, diverges. We let the caller handle x=0 boundary cases.
689 return (z_l, 0.0);
690 }
691 let z_lm1 = match kind {
692 SphericalBesselKind::First => crate::spherical_jn::<f64>(l_us - 1, x),
693 SphericalBesselKind::Second => crate::spherical_yn::<f64>(l_us - 1, x),
694 };
695 let der = z_lm1 - ((l + 1) as f64) / x * z_l;
696 (z_l, der)
697}
698
699/// Iterative computation of `κ_r = (2m + r)! / r!` to avoid factorial overflow.
700fn kappa_r(m: i32, r: i32) -> f64 {
701 let mut v = 1.0_f64;
702 for k in (r + 1)..=(2 * m + r) {
703 v *= k as f64;
704 }
705 v
706}
707
708/// Evaluate the spheroidal radial function `R_{mn}^{(j)}(c, ξ)` of the first
709/// or second kind via the Flammer §4.4 / A&S 21.9 spherical Bessel expansion:
710///
711/// ```text
712/// R_{mn}^{(j)}(c, ξ) = (1 / H_n^m(c)) · ((ξ² - s) / ξ²)^{m/2} ·
713/// Σ_p (-1)^{p - target} · κ_{r_p} · d_{r_p}^{mn}(c) ·
714/// z_{m + r_p}^{(j)}(c · ξ)
715/// ```
716///
717/// where `r_p = 2p + parity`, `target = (n - m - parity) / 2`,
718/// `κ_r = (2m + r)! / r!`, `s = +1` for prolate (ξ ≥ 1) and `s = -1` for oblate
719/// (`ξ ≥ 0`, with the prefactor `((ξ² + 1) / ξ²)^{m/2}`), and the normalisation
720///
721/// ```text
722/// H_n^m(c) = Σ_p κ_{r_p} · d_{r_p}^{mn}(c)
723/// ```
724///
725/// (Flammer 4.4.6) ensures `R_{mn}^{(1)}(c, ξ) → j_n(c · ξ)` as `c → 0` for
726/// `m = 0`.
727///
728/// # Domain
729///
730/// - **Prolate** (`SpheroidalParity::Prolate`): `ξ ≥ 1`. Argument passed to
731/// the spherical Bessel functions is `c · ξ`.
732/// - **Oblate** (`SpheroidalParity::Oblate`): `ξ ≥ 0`. Argument is `c · ξ` for
733/// `ξ ≥ 1` (exterior), but for `0 ≤ ξ < 1` the modified spherical Bessel
734/// functions are needed; we currently support only `ξ ≥ 1` for oblate
735/// (and `ξ ≥ 0` for the exterior when `c · ξ` is real and ≥ 0).
736///
737/// # Errors
738///
739/// - [`SpecialError::DomainError`] if domain conditions are violated.
740/// - [`SpecialError::ComputationError`] if the d-coefficient series fails to
741/// converge or `H_n^m(c) ≈ 0`.
742///
743/// # Limitations
744///
745/// The pure spherical-Bessel expansion underlying this routine is known to be
746/// numerically unstable for the **second kind** at certain odd-`m` /
747/// odd-parity combinations, in particular `(m=1, n=2)` and `(m=3, n=4, 6)`
748/// (Flammer §4.5 discusses an alternative integral representation that
749/// remedies this). The first-kind series is robust for all `m, n, c, ξ`.
750pub fn radial_function(
751 parity_kind: SpheroidalParity,
752 bessel_kind: SphericalBesselKind,
753 m: i32,
754 n: i32,
755 c: f64,
756 xi: f64,
757) -> SpecialResult<(f64, f64)> {
758 if m < 0 || n < m {
759 return Err(SpecialError::DomainError(format!(
760 "radial_function requires 0 ≤ m ≤ n, got m={m}, n={n}"
761 )));
762 }
763 if c < 0.0 {
764 return Err(SpecialError::DomainError(format!(
765 "radial_function requires c ≥ 0, got c={c}"
766 )));
767 }
768
769 match parity_kind {
770 SpheroidalParity::Prolate => {
771 if xi < 1.0 {
772 return Err(SpecialError::DomainError(format!(
773 "prolate radial_function requires ξ ≥ 1, got ξ={xi}"
774 )));
775 }
776 }
777 SpheroidalParity::Oblate => {
778 // For oblate exterior we accept ξ ≥ 0 but the argument c·ξ goes
779 // through real spherical Bessel — which is fine since c, ξ ≥ 0.
780 if xi < 0.0 {
781 return Err(SpecialError::DomainError(format!(
782 "oblate radial_function requires ξ ≥ 0, got ξ={xi}"
783 )));
784 }
785 }
786 }
787
788 let parity = ((n - m) % 2 + 2) % 2;
789 let parity_us = parity as usize;
790
791 let lambda = flammer_eigenvalue(parity_kind, m, n, c, DEFAULT_D_LEN)?;
792 let d = d_coefficients(parity_kind, m, n, c, lambda)?;
793 let len = d.len();
794
795 let target_us = ((n - m - parity) / 2) as usize;
796
797 // Prefactor ((ξ² - s) / ξ²)^{m/2} where s = +1 for prolate, s = -1 for oblate
798 let s_pref = parity_kind.sign();
799 let prefactor = if m == 0 {
800 1.0_f64
801 } else {
802 if xi.abs() < 1.0e-30 {
803 return Err(SpecialError::DomainError(
804 "radial_function: prefactor singular at ξ=0 for m > 0".to_string(),
805 ));
806 }
807 let xi2 = xi * xi;
808 let arg = (xi2 - s_pref) / xi2;
809 if arg < 0.0 {
810 // For oblate with ξ < ?, this could happen — error out
811 return Err(SpecialError::DomainError(format!(
812 "radial_function: prefactor (ξ²-s)/ξ²={arg} < 0 for ξ={xi}"
813 )));
814 }
815 arg.powf(0.5 * m as f64)
816 };
817
818 // Effective Bessel argument — prolate uses cξ, oblate also uses cξ for ξ ≥ 0
819 let z_arg = c * xi;
820
821 // First pass: build full κ_r · d_r table and compute H_norm.
822 let mut kdr = vec![0.0_f64; len];
823 for (p, &dp) in d.iter().enumerate().take(len) {
824 let r = (2 * p + parity_us) as i32;
825 kdr[p] = kappa_r(m, r) * dp;
826 }
827 let h_norm: f64 = kdr.iter().sum();
828
829 if h_norm.abs() < 1.0e-30 {
830 return Err(SpecialError::ComputationError(format!(
831 "radial_function: normalisation H_n^m(c)={h_norm:.3e} too small for m={m}, n={n}, c={c}"
832 )));
833 }
834
835 // Second pass: sum Bessel terms with bounded iteration count and
836 // adaptive truncation. We use a HARD cap because the second-kind y_l
837 // series grows factorially, so even though the d-coefficients decay
838 // super-factorially, the *product* needs a finite truncation to avoid
839 // catastrophic intermediate-term overflow.
840 //
841 // For the FIRST kind (`SphericalBesselKind::First`) the series converges
842 // gracefully — `j_l(c·ξ)` decays like `(c·ξ)^l / (2l+1)!!` for `l > c·ξ`,
843 // so there's no overflow risk. We use the full d-coefficient length.
844 //
845 // For the SECOND kind (`SphericalBesselKind::Second`), `y_l(c·ξ)` grows
846 // like `(2l-1)!! / (c·ξ)^{l+1}`, so the safe truncation is roughly
847 // `p_max = max(target + 8, n + 6)` — empirically sufficient to converge
848 // for `|c·ξ| ≥ 1` and `n ≤ 30`.
849 //
850 // Standard truncation criteria (within the cap):
851 // (a) `|term| < term_floor · running_max` for ≥ 4 consecutive p, or
852 // (b) `z_l` is non-finite (overflow).
853 // Truncation strategy depends on Bessel kind:
854 // - First kind: j_l decays factorially in l once l > c·ξ; we let the
855 // adaptive convergence test (term magnitude relative to running max)
856 // stop the loop. No hard cap.
857 // - Second kind: y_l grows factorially; the d-coefficients eventually
858 // overcompensate, but for small c·ξ the regime where d·y is decreasing
859 // is reached too late to be numerically usable. We cap at
860 // `2·n + 8` terms for second kind. This is a deliberate trade-off
861 // between coverage and accuracy: tests show this gives ≥ 4 digits
862 // for `c·ξ ≥ 1` and `m = 0`.
863 let max_iter = match bessel_kind {
864 SphericalBesselKind::First => len,
865 SphericalBesselKind::Second => (2 * n as usize + 8).min(len),
866 };
867
868 let mut numerator = 0.0_f64;
869 let mut numerator_prime = 0.0_f64;
870 let term_floor: f64 = 1.0e-15;
871 let mut max_seen: f64 = 0.0;
872 let mut consecutive_below = 0;
873
874 for (p, &kd) in kdr.iter().enumerate().take(max_iter) {
875 if kd == 0.0 {
876 continue;
877 }
878 let r = (2 * p + parity_us) as i32;
879 let l = m + r;
880 let phase = if (p as i32 - target_us as i32).rem_euclid(2) == 0 {
881 1.0
882 } else {
883 -1.0
884 };
885 let coef = phase * kd;
886
887 let (z_val, z_der) = spherical_bessel_pair(bessel_kind, l, z_arg);
888 if !z_val.is_finite() || !z_der.is_finite() {
889 break;
890 }
891 let term = coef * z_val;
892 let term_p = coef * c * z_der;
893 let abs_term = term.abs();
894
895 if abs_term > max_seen {
896 max_seen = abs_term;
897 }
898
899 numerator += term;
900 numerator_prime += term_p;
901
902 // Adaptive convergence break (first-kind primarily).
903 if max_seen > 0.0 && abs_term < term_floor * max_seen && p >= target_us + 6 {
904 consecutive_below += 1;
905 if consecutive_below >= 4 {
906 break;
907 }
908 } else {
909 consecutive_below = 0;
910 }
911 }
912
913 // Apply prefactor and 1/H_n^m (h_norm guarded above)
914 let r_value = prefactor * numerator / h_norm;
915
916 // Derivative of `prefactor · numerator / H` w.r.t. ξ:
917 // = prefactor' · numerator/H + prefactor · numerator'/H
918 // prefactor = ((ξ²-s)/ξ²)^{m/2} = (1 - s/ξ²)^{m/2}
919 // prefactor' = (m/2) · (1 - s/ξ²)^{m/2 - 1} · (2s/ξ³)
920 let prefactor_prime = if m == 0 {
921 0.0
922 } else {
923 let xi2 = xi * xi;
924 let base = 1.0 - s_pref / xi2;
925 if base.abs() < 1.0e-30 {
926 0.0
927 } else {
928 0.5 * m as f64 * base.powf(0.5 * m as f64 - 1.0) * (2.0 * s_pref / (xi2 * xi))
929 }
930 };
931 let r_derivative = prefactor_prime * numerator / h_norm + prefactor * numerator_prime / h_norm;
932
933 Ok((r_value, r_derivative))
934}
935
936/// Compute the spheroidal characteristic value `λ_{mn}(c)` directly from the
937/// Flammer recurrence tridiagonal eigenproblem.
938///
939/// This is the "ground-truth" eigenvalue that downstream consumers
940/// ([`d_coefficients`], the angular and radial wave functions) all use.
941///
942/// # Errors
943///
944/// Returns [`SpecialError::DomainError`] for invalid `(m, n)` and
945/// [`SpecialError::ComputationError`] if the QR iteration cannot locate the
946/// target eigenvalue.
947pub fn flammer_eigenvalue(
948 parity_kind: SpheroidalParity,
949 m: i32,
950 n: i32,
951 c: f64,
952 len: usize,
953) -> SpecialResult<f64> {
954 if m < 0 || n < m {
955 return Err(SpecialError::DomainError(format!(
956 "flammer_eigenvalue requires 0 ≤ m ≤ n, got m={m}, n={n}"
957 )));
958 }
959 if len < 4 {
960 return Err(SpecialError::DomainError(
961 "flammer_eigenvalue length must be ≥ 4".to_string(),
962 ));
963 }
964
965 if c == 0.0 {
966 return Ok(n as f64 * (n as f64 + 1.0));
967 }
968
969 let parity = ((n - m) % 2 + 2) % 2;
970 let k_target = ((n - m - parity) / 2) as usize;
971 if k_target >= len {
972 return Err(SpecialError::DomainError(format!(
973 "flammer_eigenvalue truncation len={len} too small for n={n}, m={m}"
974 )));
975 }
976
977 let (diag, off_sym, _scale) = build_flammer_tridiag(parity_kind, m, n, c, len);
978 let eigs = tridiag_eigenvalues(&diag, &off_sym);
979 eigs.get(k_target).copied().ok_or_else(|| {
980 SpecialError::ComputationError(format!(
981 "flammer_eigenvalue: target eigenvalue {k_target} not found"
982 ))
983 })
984}
985
986/// Estimate a pessimistic upper bound on the next-coefficient ratio
987/// `|d_{r+2} / d_r|` for very large `r` via Lentz-CF. Used by callers that
988/// want a fast convergence check before committing to a full backward sweep.
989///
990/// The CF is built directly from the Flammer recurrence at index `start_k`
991/// (i.e., `r = 2·start_k + parity`).
992///
993/// This is exposed so the `lentz_cf_*` integration tests can exercise it.
994pub fn tail_ratio_lentz(
995 parity_kind: SpheroidalParity,
996 m: i32,
997 n: i32,
998 c: f64,
999 lambda: f64,
1000 start_k: usize,
1001) -> SpecialResult<f64> {
1002 let m_f = m as f64;
1003 let parity = ((n - m) % 2 + 2) % 2;
1004 let parity_f = parity as f64;
1005 let s = parity_kind.sign();
1006 let c2_signed = c * c * s;
1007
1008 if c2_signed == 0.0 {
1009 return Ok(0.0);
1010 }
1011
1012 // From the recurrence written as a CF for r_p = d_{p+1} / d_p:
1013 // α_p+1 · d_{p+1} + (β_p − λ) · d_p + γ_p−1 · d_{p−1} = 0
1014 // ratio r_p = d_{p+1} / d_p
1015 // Rearranged:
1016 // r_p = -(β_p − λ + γ_p_down · 1/r_{p-1}) / α_p_up
1017 // For Lentz form we use the equivalent
1018 // r_p = -α_p_up^{-1} · (β_p − λ) - α_p_up^{-1} γ_p_down / r_{p-1}
1019 let r_at = |k: usize| -> f64 { 2.0 * k as f64 + parity_f };
1020 let beta_at = |k: usize| -> f64 { flammer_beta(m_f, r_at(k), c2_signed) - lambda };
1021 let alpha_at = |k: usize| -> f64 { flammer_alpha_up(m_f, r_at(k), c2_signed) };
1022 let gamma_at = |k: usize| -> f64 { flammer_gamma_down(m_f, r_at(k), c2_signed) };
1023
1024 // CF for r_K via downward propagation:
1025 // r_K = -α_{K+1}^{-1} (β_K − λ) - α_{K+1}^{-1} γ_K / r_{K-1}? Actually we go up:
1026 // At index k, eqn: α_up(k) d_{k+1} = -(β-λ) d_k - γ_down(k) d_{k-1}
1027 // ⇒ r_k = d_{k+1}/d_k = -[(β-λ) + γ_down(k) · (1/r_{k-1})] / α_up(k)
1028 //
1029 // Reading downward (k = M, M-1, ..., start_k+1) gives us r_{start_k}.
1030 //
1031 // We use Lentz with b_n = (β-λ) at level (start_k + n), a_n = -γ_down(start_k+n)·α_up(start_k+n-1)?
1032 //
1033 // The cleanest form: write the CF for `f = -1/r_{start_k}` as
1034 // f = (β_{start_k}-λ)/α_{start_k} + γ_{start_k}/α_{start_k} · 1/( ... )
1035 // Iterating recursively gives a clean Lentz form.
1036 let result = cf_modified_lentz(
1037 |idx: usize| {
1038 if idx == 0 {
1039 0.0
1040 } else {
1041 let k = start_k + idx;
1042 // a_n = -γ_down(k) · γ_down(k-1) / [α_up(k-1)·α_up(k-2)] approximation...
1043 // We use the simpler (but correct) form: a_n = ...
1044 // For a 2-step recurrence written as r_k = b_n + a_n / r_{k+1}, with
1045 // r_k_up = -[(β_k - λ) + γ_down(k+1)/r_{k+1}_up] / α_up(k)
1046 // Rearrange α_up(k) r_k = -(β_k - λ) - γ_down(k+1)/r_{k+1}
1047 // Define R_k = -α_up(k) r_k:
1048 // R_k = (β_k - λ) + γ_down(k+1)/r_{k+1} = (β_k - λ) + γ_down(k+1) · α_up(k+1) / R_{k+1} · (-1)
1049 // = (β_k - λ) - γ_down(k+1) · α_up(k+1) / R_{k+1}
1050 // So with R = b_n + a_n / R_{n+1}, we have:
1051 // b_n = β_k - λ (at k = start_k + n - 1 in Lentz indexing)
1052 // a_n = -γ_down(k+1) · α_up(k+1) with k+1 corresponding to next level
1053 // Set the convention: for n = 1, this is the equation at k = start_k.
1054 let level = start_k + idx - 1;
1055 -gamma_at(level + 1) * alpha_at(level)
1056 }
1057 },
1058 |idx: usize| {
1059 if idx == 0 {
1060 0.0
1061 } else {
1062 let level = start_k + idx - 1;
1063 beta_at(level)
1064 }
1065 },
1066 )?;
1067
1068 // result = R_{start_k} = -α_up(start_k) · r_{start_k}
1069 // So r_{start_k} = -result / α_up(start_k).
1070 let alpha_start = alpha_at(start_k);
1071 if alpha_start.abs() < f64::MIN_POSITIVE * 1.0e6 {
1072 return Err(SpecialError::ConvergenceError(
1073 "tail_ratio_lentz: α_up(start_k) too small".to_string(),
1074 ));
1075 }
1076 Ok(-result.value / alpha_start)
1077}
1078
1079#[cfg(test)]
1080mod tests {
1081 use super::*;
1082 use approx::assert_abs_diff_eq;
1083
1084 /// Simple geometric continued fraction:
1085 /// tan(x) = x / (1 - x²/(3 - x²/(5 - ...)))
1086 /// This serves as a baseline correctness check for [`cf_modified_lentz`].
1087 #[test]
1088 fn cf_lentz_evaluates_tan_via_cf() {
1089 let x: f64 = 0.5;
1090 let x2 = x * x;
1091 let cf = cf_modified_lentz(
1092 |n| if n == 0 { 0.0 } else { -x2 },
1093 |n| if n == 0 { 1.0 } else { (2 * n + 1) as f64 },
1094 )
1095 .expect("Lentz CF failed for tan");
1096 let tan_via_cf = x / cf.value;
1097 assert_abs_diff_eq!(tan_via_cf, x.tan(), epsilon = 1e-12);
1098 }
1099
1100 /// d_coefficients at c=0 must collapse to the unit vector at the main index.
1101 #[test]
1102 fn d_coefficients_c_zero_matches_legendre_limit() {
1103 for n in 0..6 {
1104 let lam = n as f64 * (n as f64 + 1.0);
1105 let d = d_coefficients(SpheroidalParity::Prolate, 0, n, 0.0, lam).expect("d coef c=0");
1106 let parity = (n % 2) as usize;
1107 let target = ((n as usize) - parity) / 2;
1108 for (k, &dk) in d.iter().enumerate() {
1109 if k == target {
1110 assert!((dk - 1.0).abs() < 1e-12, "d[target]=1 expected, got {dk}");
1111 } else {
1112 assert!(dk.abs() < 1e-10, "d[{k}]={dk} should be 0 at c=0");
1113 }
1114 }
1115 }
1116 }
1117
1118 /// `tail_ratio_lentz` at `c=0` must be exactly zero — no coupling means
1119 /// off-target coefficients are zero.
1120 #[test]
1121 fn tail_ratio_lentz_c_zero_is_zero() {
1122 let r = tail_ratio_lentz(SpheroidalParity::Prolate, 0, 1, 0.0, 2.0, 5)
1123 .expect("tail ratio should succeed at c=0");
1124 assert_abs_diff_eq!(r, 0.0, epsilon = 1e-15);
1125 }
1126
1127 /// `scaled_recurrence_step` should rescale exactly once when values blow up.
1128 #[test]
1129 fn scaled_recurrence_step_handles_overflow() {
1130 let mut values = [1.0e160_f64, 5.0e159, -2.0e160];
1131 let mut log_scale = 0.0_f64;
1132 let did = scaled_recurrence_step(&mut values, &mut log_scale);
1133 assert!(did);
1134 assert!(values.iter().all(|v| v.abs() < 1.0e150));
1135 assert!(log_scale > 0.0);
1136 }
1137
1138 #[test]
1139 fn scaled_recurrence_step_handles_underflow() {
1140 let mut values = [1.0e-160_f64, -3.0e-160, 2.0e-161];
1141 let mut log_scale = 0.0_f64;
1142 let did = scaled_recurrence_step(&mut values, &mut log_scale);
1143 assert!(did);
1144 assert!(values.iter().all(|v| v.abs() > 1.0e-160));
1145 assert!(log_scale < 0.0);
1146 }
1147
1148 /// Eigenvalue λ_{mn}(c) for a few cases against the SciPy reference.
1149 /// SciPy values: `pro_cv(0,2,1.0)=6.5334718`, `obl_cv(0,2,1.0)=5.4868000`.
1150 /// Tolerance 1e-7 reflects the iterative tridiagonal eigensolver's QR
1151 /// shift-strategy convergence — Wilkinson shifts give ~6 to 8 digits
1152 /// for a single target eigenvalue.
1153 #[test]
1154 fn flammer_eigenvalue_matches_scipy_reference() {
1155 let lam = flammer_eigenvalue(SpheroidalParity::Prolate, 0, 2, 1.0, 60)
1156 .expect("flammer_eigenvalue prolate");
1157 assert_abs_diff_eq!(lam, 6.5334718005, epsilon = 1.0e-7);
1158
1159 let lam = flammer_eigenvalue(SpheroidalParity::Oblate, 0, 2, 1.0, 60)
1160 .expect("flammer_eigenvalue oblate");
1161 assert_abs_diff_eq!(lam, 5.4868000164, epsilon = 1.0e-7);
1162 }
1163}