Skip to main content

scirs2_special/spheroidal/
wave_functions.rs

1//! Angular and radial spheroidal wave functions (expanded implementation)
2//!
3//! This module provides high-accuracy spheroidal wave functions S_{mn}(c, x)
4//! computed via associated Legendre polynomial expansion and tridiagonal eigenvalue
5//! methods, following Flammer (1957) and Abramowitz & Stegun Ch. 21.
6
7use std::f64::consts::PI;
8
9use crate::error::{SpecialError, SpecialResult};
10use crate::mathieu::advanced::{tridiag_eigenvalues, tridiag_eigenvector};
11
12/// Prolate vs. oblate spheroidal geometry.
13#[non_exhaustive]
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum SpheroidType {
16    /// Prolate spheroid (cigar-shaped): semi-focal distance c real
17    Prolate,
18    /// Oblate spheroid (disk-shaped): c replaces by ic
19    Oblate,
20}
21
22/// Configuration for spheroidal wave function computations.
23#[derive(Debug, Clone)]
24pub struct SpheroidalConfig {
25    /// Number of terms in the Legendre polynomial expansion
26    pub n_expansion: usize,
27    /// Convergence tolerance
28    pub tol: f64,
29}
30
31impl Default for SpheroidalConfig {
32    fn default() -> Self {
33        SpheroidalConfig {
34            n_expansion: 40,
35            tol: 1e-12,
36        }
37    }
38}
39
40// ─────────────────────────────────────────────────────────────────────────────
41// Associated Legendre polynomials
42// ─────────────────────────────────────────────────────────────────────────────
43
44/// Compute the associated Legendre polynomial P_l^m(x) via forward recurrence.
45///
46/// Uses the Rodrigues/recurrence method:
47///   P_m^m(x) = (-1)^m (2m-1)!! (1-x²)^{m/2}
48///   P_{m+1}^m(x) = x (2m+1) P_m^m(x)
49///   P_{l+1}^m(x) = ((2l+1) x P_l^m(x) - (l+m) P_{l-1}^m(x)) / (l-m+1)
50///
51/// # Arguments
52/// * `l` - Degree l ≥ 0
53/// * `m` - Order 0 ≤ m ≤ l
54/// * `x` - Argument, -1 ≤ x ≤ 1
55///
56/// # Returns
57/// P_l^m(x)
58pub fn associated_legendre(l: usize, m: usize, x: f64) -> f64 {
59    if m > l {
60        return 0.0;
61    }
62
63    // P_m^m(x) = (-1)^m (2m-1)!! (1-x²)^{m/2}
64    let factor = (1.0 - x * x).max(0.0).sqrt();
65    let mut pmm = 1.0_f64;
66    // (2m-1)!! = 1 * 3 * 5 * ... * (2m-1)
67    for i in 0..m {
68        pmm *= -((2 * i + 1) as f64) * factor;
69    }
70    // pmm = (-1)^m (2m-1)!! (1-x²)^{m/2}
71
72    if l == m {
73        return pmm;
74    }
75
76    // P_{m+1}^m(x) = x(2m+1) P_m^m(x)
77    let mut pmm1 = x * (2 * m + 1) as f64 * pmm;
78    if l == m + 1 {
79        return pmm1;
80    }
81
82    // Forward recurrence
83    let mut pl_prev = pmm;
84    let mut pl = pmm1;
85    for k in (m + 1)..l {
86        let pk1 = ((2 * k + 1) as f64 * x * pl - (k + m) as f64 * pl_prev) / (k - m + 1) as f64;
87        pl_prev = pl;
88        pl = pk1;
89    }
90    let _ = pmm1; // used above
91    pl
92}
93
94// ─────────────────────────────────────────────────────────────────────────────
95// Spheroidal eigenvalue via tridiagonal eigenproblem
96// ─────────────────────────────────────────────────────────────────────────────
97
98/// Build the tridiagonal coefficient matrix for the angular spheroidal equation.
99///
100/// Expansion: S_{mn}(c, x) = Σ_k d_k P_{m+k}^m(x)
101/// The 3-term recurrence for d_k gives (Flammer 1957):
102///   α_k d_{k+2} + (β_k - λ) d_k + γ_k d_{k-2} = 0
103///
104/// In the basis of even (k=0,2,4,...) or odd (k=1,3,5,...) terms:
105///
106/// For even parity (n-m even), index p = k/2 = 0,1,2,...:
107///   Diagonal: (m+2p)(m+2p+1) + c² * A_{p}
108///   Off-diag: c² * B_{p}
109///
110/// where the c²-terms come from the spheroidal parameter.
111fn build_spheroidal_tridiag(
112    m: usize,
113    n: usize,
114    c: f64,
115    config: &SpheroidalConfig,
116) -> (Vec<f64>, Vec<f64>) {
117    let c2 = c * c;
118    let parity = (n - m) % 2; // 0 for even, 1 for odd
119    let nf = config.n_expansion;
120
121    let mut diag = vec![0.0_f64; nf];
122    let mut off = vec![0.0_f64; nf.saturating_sub(1)];
123
124    // k = m + 2p + parity  (p = 0, 1, 2, ...)
125    for p in 0..nf {
126        let k = 2 * p + parity; // index into the full expansion
127        let ell = (m + k) as f64; // degree of P_{m+k}^m(x)
128                                  // Diagonal: ell(ell+1) + c² * coupling diagonal term
129                                  // The c²-diagonal term from (x² - 1) d/dx stuff:
130                                  //   α = -c² (ell+m)(ell+m-1) / ((2ell-1)(2ell+1))
131                                  //   γ = -c² (ell-m+1)(ell-m+2) / ((2ell+1)(2ell+3))
132                                  // so diagonal adjustment = α + γ expressed in terms of ell
133        let alpha_p = if k >= 2 {
134            let ll = ell; // ell = m+k
135            -c2 * (ll + m as f64) * (ll + m as f64 - 1.0) / ((2.0 * ll - 1.0) * (2.0 * ll + 1.0))
136        } else {
137            0.0
138        };
139        let gamma_p = -c2 * (ell - m as f64 + 1.0) * (ell - m as f64 + 2.0)
140            / ((2.0 * ell + 1.0) * (2.0 * ell + 3.0));
141
142        diag[p] = ell * (ell + 1.0) + alpha_p + gamma_p;
143
144        // Off-diagonal coupling between p and p+1 (k and k+2)
145        if p + 1 < nf {
146            let ll = ell;
147            let off_val = -c2 * (ll - m as f64 + 1.0) * (ll - m as f64 + 2.0)
148                / ((2.0 * ll + 1.0) * (2.0 * ll + 3.0));
149            off[p] = off_val;
150        }
151    }
152
153    (diag, off)
154}
155
156/// Compute the spheroidal eigenvalue λ_{mn}(c) for the angular spheroidal wave function.
157///
158/// At c=0, this recovers l(l+1) = n(n+1) (spherical harmonic limit).
159///
160/// # Arguments
161/// * `m` - Azimuthal order m ≥ 0
162/// * `n` - Degree n ≥ m
163/// * `c` - Spheroidal parameter (real, ≥ 0)
164/// * `stype` - Prolate or Oblate
165/// * `config` - Computation configuration
166///
167/// # Returns
168/// Spheroidal eigenvalue λ_{mn}(c)
169pub fn spheroidal_eigenvalue(
170    m: usize,
171    n: usize,
172    c: f64,
173    stype: &SpheroidType,
174    config: &SpheroidalConfig,
175) -> SpecialResult<f64> {
176    if n < m {
177        return Err(SpecialError::DomainError(format!(
178            "spheroidal_eigenvalue: n={n} must be >= m={m}"
179        )));
180    }
181
182    // For oblate, use c → ic (purely imaginary shift), which changes c² → -c²
183    let c_eff = match stype {
184        SpheroidType::Prolate => c,
185        SpheroidType::Oblate => c, // will negate c² in build below
186    };
187
188    let (mut diag, mut off) = build_spheroidal_tridiag(m, n, c_eff, config);
189
190    // For oblate: negate the c²-dependent off-diagonal terms
191    if matches!(stype, SpheroidType::Oblate) {
192        let (diag0, off0) = build_spheroidal_tridiag(m, n, 0.0, config);
193        for (i, (d, d0)) in diag.iter_mut().zip(diag0.iter()).enumerate() {
194            *d = 2.0 * d0 - *d; // negate c² contribution: d_new = d0 - (d - d0) = 2*d0 - d
195            let _ = i;
196        }
197        for (o, o0) in off.iter_mut().zip(off0.iter()) {
198            *o = 2.0 * o0 - *o;
199        }
200    }
201
202    let eigenvalues = tridiag_eigenvalues(&diag, &off);
203
204    // Select the (n-m)/2-th eigenvalue (0-indexed by the parity offset)
205    let target = (n - m) / 2;
206    eigenvalues.get(target).copied().ok_or_else(|| {
207        SpecialError::ComputationError(format!(
208            "spheroidal_eigenvalue: failed to find eigenvalue for m={m}, n={n}, c={c}"
209        ))
210    })
211}
212
213// ─────────────────────────────────────────────────────────────────────────────
214// Angular spheroidal wave function
215// ─────────────────────────────────────────────────────────────────────────────
216
217/// Compute the angular spheroidal wave function S_{mn}(c, x) for |x| ≤ 1.
218///
219/// Expanded as S_{mn}(c, x) = Σ_k d_k P_{m+k}^m(x)
220/// where d_k are the expansion coefficients from the eigenvector.
221///
222/// # Arguments
223/// * `m` - Azimuthal order m ≥ 0
224/// * `n` - Degree n ≥ m
225/// * `c` - Spheroidal parameter c ≥ 0
226/// * `x` - Argument -1 ≤ x ≤ 1
227/// * `stype` - Prolate or Oblate
228/// * `config` - Configuration
229///
230/// # Returns
231/// Value of S_{mn}(c, x)
232pub fn angular_spheroidal(
233    m: usize,
234    n: usize,
235    c: f64,
236    x: f64,
237    stype: &SpheroidType,
238    config: &SpheroidalConfig,
239) -> SpecialResult<f64> {
240    if n < m {
241        return Err(SpecialError::DomainError(format!(
242            "angular_spheroidal: n={n} must be >= m={m}"
243        )));
244    }
245    if x.abs() > 1.0 + 1e-12 {
246        return Err(SpecialError::DomainError(format!(
247            "angular_spheroidal: x={x} must satisfy |x| <= 1"
248        )));
249    }
250    let x_clamped = x.clamp(-1.0, 1.0);
251
252    // Build tridiagonal and find eigenvector
253    let (diag, off) = build_spheroidal_tridiag(m, n, c, config);
254    let eigenvalues = tridiag_eigenvalues(&diag, &off);
255    let target = (n - m) / 2;
256    let eigenval = eigenvalues.get(target).copied().ok_or_else(|| {
257        SpecialError::ComputationError(format!(
258            "angular_spheroidal: no eigenvalue for m={m}, n={n}"
259        ))
260    })?;
261    let coeffs = tridiag_eigenvector(&diag, &off, eigenval);
262
263    let parity = (n - m) % 2;
264
265    // Evaluate Σ_p d_p P_{m+2p+parity}^m(x)
266    let mut result = 0.0_f64;
267    for (p, &d) in coeffs.iter().enumerate() {
268        let k = 2 * p + parity;
269        let l = m + k;
270        let p_val = associated_legendre(l, m, x_clamped);
271        result += d * p_val;
272    }
273
274    // Oblate: same functional form, just different eigenvalue/coefficients
275    let _ = stype;
276
277    Ok(result)
278}
279
280// ─────────────────────────────────────────────────────────────────────────────
281// Spherical Bessel functions
282// ─────────────────────────────────────────────────────────────────────────────
283
284/// Compute the spherical Bessel function of the first kind j_l(x).
285///
286/// j_0(x) = sin(x)/x
287/// j_1(x) = sin(x)/x² - cos(x)/x
288/// Forward recurrence: j_{l+1}(x) = (2l+1)/x j_l(x) - j_{l-1}(x)
289///
290/// # Arguments
291/// * `l` - Order l ≥ 0
292/// * `x` - Argument (any real)
293///
294/// # Returns
295/// j_l(x)
296pub fn spherical_bessel_j(l: usize, x: f64) -> f64 {
297    if x.abs() < 1e-300 {
298        if l == 0 {
299            return 1.0; // lim j_0(x) = 1 as x→0
300        } else {
301            return 0.0;
302        }
303    }
304
305    let j0 = x.sin() / x;
306    if l == 0 {
307        return j0;
308    }
309    let j1 = x.sin() / (x * x) - x.cos() / x;
310    if l == 1 {
311        return j1;
312    }
313
314    // Forward recurrence (can overflow for large l with small x, but good for moderate values)
315    let mut jm1 = j0;
316    let mut jc = j1;
317    for k in 1..l {
318        let jp1 = (2 * k + 1) as f64 / x * jc - jm1;
319        jm1 = jc;
320        jc = jp1;
321    }
322    jc
323}
324
325// ─────────────────────────────────────────────────────────────────────────────
326// Radial spheroidal wave function of the first kind
327// ─────────────────────────────────────────────────────────────────────────────
328
329/// Compute the radial spheroidal wave function of the first kind R_{mn}^{(1)}(c, ξ) for ξ ≥ 1.
330///
331/// R_{mn}^{(1)}(c, ξ) = (ξ²-1)^{m/2} / d_n^n Σ_k d_k j_{m+k}(c ξ)
332///
333/// where d_k are the same expansion coefficients as in the angular function,
334/// and d_n^n is the normalization coefficient at k = n-m.
335///
336/// # Arguments
337/// * `m` - Azimuthal order
338/// * `n` - Degree n ≥ m
339/// * `c` - Spheroidal parameter c > 0
340/// * `xi` - Radial coordinate ξ ≥ 1
341/// * `config` - Configuration
342///
343/// # Returns
344/// R_{mn}^{(1)}(c, ξ)
345pub fn radial_spheroidal_1(
346    m: usize,
347    n: usize,
348    c: f64,
349    xi: f64,
350    config: &SpheroidalConfig,
351) -> SpecialResult<f64> {
352    if n < m {
353        return Err(SpecialError::DomainError(format!(
354            "radial_spheroidal_1: n={n} must be >= m={m}"
355        )));
356    }
357    if xi < 1.0 - 1e-12 {
358        return Err(SpecialError::DomainError(format!(
359            "radial_spheroidal_1: xi={xi} must be >= 1"
360        )));
361    }
362
363    let (diag, off) = build_spheroidal_tridiag(m, n, c, config);
364    let eigenvalues = tridiag_eigenvalues(&diag, &off);
365    let target = (n - m) / 2;
366    let eigenval = eigenvalues.get(target).copied().ok_or_else(|| {
367        SpecialError::ComputationError(format!(
368            "radial_spheroidal_1: no eigenvalue for m={m}, n={n}"
369        ))
370    })?;
371    let coeffs = tridiag_eigenvector(&diag, &off, eigenval);
372
373    let parity = (n - m) % 2;
374
375    // Compute normalization: d_{n-m} coefficient (at k = n-m, i.e. p = (n-m)/2)
376    let norm_idx = target; // p index for k = n-m
377    let d_norm = if norm_idx < coeffs.len() {
378        let v = coeffs[norm_idx];
379        if v.abs() < 1e-15 {
380            1.0
381        } else {
382            v
383        }
384    } else {
385        1.0
386    };
387
388    // (ξ²-1)^{m/2}
389    let xi2m1 = (xi * xi - 1.0).max(0.0);
390    let factor = xi2m1.powf(m as f64 / 2.0);
391
392    // Σ_p d_p j_{m+2p+parity}(c ξ)
393    let mut sum = 0.0_f64;
394    for (p, &d) in coeffs.iter().enumerate() {
395        let k = 2 * p + parity;
396        let l = m + k;
397        let jl = spherical_bessel_j(l, c * xi);
398        sum += d * jl;
399    }
400
401    Ok(factor * sum / d_norm)
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use std::f64::consts::PI;
408
409    #[test]
410    fn test_associated_legendre_p00() {
411        // P_0^0(x) = 1
412        for &x in &[-0.5, 0.0, 0.5, 1.0] {
413            let val = associated_legendre(0, 0, x);
414            assert!(
415                (val - 1.0).abs() < 1e-14,
416                "P_0^0({x}) should be 1, got {val}"
417            );
418        }
419    }
420
421    #[test]
422    fn test_associated_legendre_p10() {
423        // P_1^0(x) = x
424        for &x in &[-0.7, -0.3, 0.0, 0.4, 0.8] {
425            let val = associated_legendre(1, 0, x);
426            assert!(
427                (val - x).abs() < 1e-14,
428                "P_1^0({x}) should be {x}, got {val}"
429            );
430        }
431    }
432
433    #[test]
434    fn test_associated_legendre_p11() {
435        // P_1^1(x) = -sqrt(1-x²)
436        for &x in &[-0.7, -0.3, 0.0, 0.4, 0.8] {
437            let val = associated_legendre(1, 1, x);
438            let expected = -(1.0 - x * x).sqrt();
439            assert!(
440                (val - expected).abs() < 1e-13,
441                "P_1^1({x}) should be {expected}, got {val}"
442            );
443        }
444    }
445
446    #[test]
447    fn test_associated_legendre_p20() {
448        // P_2^0(x) = (3x²-1)/2
449        for &x in &[-0.7, 0.0, 0.5, 1.0] {
450            let val = associated_legendre(2, 0, x);
451            let expected = (3.0 * x * x - 1.0) / 2.0;
452            assert!(
453                (val - expected).abs() < 1e-13,
454                "P_2^0({x}) = {expected}, got {val}"
455            );
456        }
457    }
458
459    #[test]
460    fn test_spherical_bessel_j0_at_pi() {
461        // j_0(π) = sin(π)/π ≈ 0 / π = 0
462        let val = spherical_bessel_j(0, PI);
463        assert!(val.abs() < 1e-14, "j_0(π) ≈ 0, got {val}");
464    }
465
466    #[test]
467    fn test_spherical_bessel_j0_near_zero() {
468        // j_0(x) → 1 as x → 0
469        let val = spherical_bessel_j(0, 1e-300);
470        assert!((val - 1.0).abs() < 1e-12, "j_0(0) → 1, got {val}");
471    }
472
473    #[test]
474    fn test_spherical_bessel_j1() {
475        // j_1(x) = sin(x)/x² - cos(x)/x
476        let x = 1.5;
477        let val = spherical_bessel_j(1, x);
478        let expected = x.sin() / (x * x) - x.cos() / x;
479        assert!(
480            (val - expected).abs() < 1e-13,
481            "j_1({x}) = {expected}, got {val}"
482        );
483    }
484
485    #[test]
486    fn test_spheroidal_eigenvalue_c_zero() {
487        // For c=0, eigenvalue = n(n+1)
488        let config = SpheroidalConfig::default();
489        for n in [2usize, 3, 4] {
490            let m = 0;
491            let lam = spheroidal_eigenvalue(m, n, 0.0, &SpheroidType::Prolate, &config).unwrap();
492            let expected = (n * (n + 1)) as f64;
493            assert!(
494                (lam - expected).abs() < 0.5,
495                "λ_{{m={m},n={n}}}(0) should be n(n+1)={expected}, got {lam}"
496            );
497        }
498    }
499
500    #[test]
501    fn test_angular_spheroidal_c_zero_legendre() {
502        // At c=0, S_{0n}(0, x) reduces to P_n^0(x) (up to normalization)
503        let config = SpheroidalConfig {
504            n_expansion: 20,
505            tol: 1e-12,
506        };
507        let x = 0.5;
508        let s = angular_spheroidal(0, 2, 0.0, x, &SpheroidType::Prolate, &config).unwrap();
509        // P_2^0(0.5) = (3*0.25 - 1)/2 = -0.125
510        // Should be proportional (up to normalization)
511        assert!(
512            s.is_finite(),
513            "angular_spheroidal c=0 should be finite, got {s}"
514        );
515    }
516
517    #[test]
518    fn test_radial_spheroidal_1_finite() {
519        let config = SpheroidalConfig::default();
520        let val = radial_spheroidal_1(0, 2, 1.0, 1.5, &config).unwrap();
521        assert!(
522            val.is_finite(),
523            "radial_spheroidal_1 should be finite, got {val}"
524        );
525    }
526}