Skip to main content

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}