// Copyright (C) 2026 Kiyotsugu Arai // SPDX-License-Identifier: LGPL-3.0-or-later // spheroidal.hpp // spheroidal wave functions // // The angular functions S_{mn}(c, η) and eigenvalues λ_{mn}(c) obtained by separating // the Helmholtz equation in prolate / oblate spheroidal coordinates. This drop is the // first version in the stocking phase and provides only the native-double prolate / // oblate angular functions (first kind) and eigenvalues. The radial functions // R_{mn}(c, ξ) and the Float / Complex versions are future work. // // Functions provided (native double / float / long double): // spheroidalEigenvalueProlate(m, n, c) λ_{mn}^{P}(c) // spheroidalEigenvalueOblate(m, n, c) λ_{mn}^{O}(c) // spheroidalPS1(m, n, c, eta) S_{mn}^{P,1}(c, η) (prolate) // spheroidalOS1(m, n, c, eta) S_{mn}^{O,1}(c, η) (oblate) // // Differential equation (DLMF 30.2.1, prolate; sign of γ²=c² > 0): // d/dη [(1-η²) dS/dη] + [λ - c²η² - m²/(1-η²)] S = 0, |η| ≤ 1 // // Bouwkamp / Flammer expansion (DLMF 30.6, A&S 21.7): // S_{mn}(c, η) = Σ_{r=0,2,...}' d_r^{mn}(c) · P^m_{m+r}(η) // (' sums over r of the same parity as (n-m)) // // The coefficients d_r satisfy the 3-term recurrence // A_r d_{r+2} + (B_r - λ) d_r + C_r d_{r-2} = 0 // and λ_{mn} is the eigenvalue chosen so that this recurrence has a decaying solution as r→∞. // // Recurrence coefficients (Flammer 1957, A&S 21.7.4): // A_r = (2m+r+1)(2m+r+2) c² / [(2m+2r+3)(2m+2r+5)] // B_r = (m+r)(m+r+1) + [2(m+r)(m+r+1) - 2m² - 1] c² / [(2m+2r-1)(2m+2r+3)] // C_r = r(r-1) c² / [(2m+2r-3)(2m+2r-1)] // // Oblate reuses the same recurrence via the substitution c² → -c² (DLMF 30.2.1 note). // // Numerical method: // 1. truncate r at size N=40, symmetric tridiagonalization // (replace the off-diagonal with √(A_{r}·C_{r+2}), branching real / imaginary by the sign of c²) // 2. compute eigenvalues / eigenvectors via implicit-shift QL // 3. the (n-m)/2-th eigenvalue in ascending order is λ_{mn} // 4. Flammer-normalize the corresponding eigenvector (Σ d_r = 1) and extract d_r^{mn} // // Limitations (stocking phase initial): // - native float only (Float arbitrary-precision version is future work) // - targets 12-digit precision for |c| ≤ ~10 (large c requires increasing N) // - second and third kinds not implemented // - radial R_{mn}^{(j)}(c, ξ) not implemented // - resonance (region where n-m is large and λ becomes degenerate) not handled // // Uses: spheroidal scattering, generalization of Mathieu, 3D Helmholtz analytic solutions // // References: // - DLMF §30 // - C. Flammer, "Spheroidal Wave Functions" (Stanford 1957) // - Abramowitz & Stegun §21 // - SciPy: scipy.special.{pro_ang1, obl_ang1} #ifndef SANGI_SPECIAL_SPHEROIDAL_HPP #define SANGI_SPECIAL_SPHEROIDAL_HPP #include #include #include #include #include #include #include namespace sangi { namespace special { namespace detail_spheroidal { // compute the coefficients of the 3-term recurrence // r is parity, parity+2, parity+4, ... template struct RecCoeffs { T A_r; // d_{r+2} coefficient T B_r; // diagonal (main) T C_r; // d_{r-2} coefficient }; // c_squared = c² (prolate: > 0, oblate: < 0) template [[nodiscard]] RecCoeffs rec_coeffs(int m, int r, T c_squared) { T A = (T(2*m + r + 1) * T(2*m + r + 2) * c_squared) / (T(2*m + 2*r + 3) * T(2*m + 2*r + 5)); T B = T(m + r) * T(m + r + 1) + (T(2 * (m+r) * (m+r+1) - 2*m*m - 1) * c_squared) / (T(2*m + 2*r - 1) * T(2*m + 2*r + 3)); T C = T(r * (r - 1)) * c_squared / (T(2*m + 2*r - 3) * T(2*m + 2*r - 1)); return {A, B, C}; } // implicit-shift QL algorithm for symmetric tridiagonal matrices (Numerical Recipes "tqli" style) // d: diagonal (size N), e: sub-diagonal (size N, e[0] unused) // z: identity matrix on input, eigenvector columns on output // Return value: iteration count. Throws on failure. template inline int tqli(std::vector& d, std::vector& e, std::vector>& z) { const int n = static_cast(d.size()); if (n == 1) return 0; // handle e with a 1-shift (e[i] is the sub-diagonal between d[i] and d[i+1]) for (int i = 1; i < n; ++i) e[i - 1] = e[i]; e[n - 1] = T(0); int total_iter = 0; const int max_iter = 60; for (int l = 0; l < n; ++l) { int iter = 0; int mz; do { for (mz = l; mz < n - 1; ++mz) { T dd = std::abs(d[mz]) + std::abs(d[mz + 1]); if (std::abs(e[mz]) <= std::numeric_limits::epsilon() * dd) break; } if (mz != l) { if (iter++ == max_iter) throw std::runtime_error("spheroidal tqli: convergence failure"); T g = (d[l + 1] - d[l]) / (T(2) * e[l]); T r = std::hypot(g, T(1)); g = d[mz] - d[l] + e[l] / (g + std::copysign(r, g)); T s = T(1), c = T(1), p = T(0); for (int i = mz - 1; i >= l; --i) { T f = s * e[i]; T b = c * e[i]; e[i + 1] = (r = std::hypot(f, g)); if (r == T(0)) { d[i + 1] -= p; e[mz] = T(0); break; } s = f / r; c = g / r; g = d[i + 1] - p; T t = (d[i] - g) * s + T(2) * c * b; p = s * t; d[i + 1] = g + p; g = c * t - b; for (int k = 0; k < n; ++k) { T zi1 = z[k][i + 1]; z[k][i + 1] = s * z[k][i] + c * zi1; z[k][i] = c * z[k][i] - s * zi1; } } if (r == T(0) && mz - 1 >= l) continue; d[l] -= p; e[l] = g; e[mz] = T(0); } } while (mz != l); total_iter += iter; } return total_iter; } // extract the d_r sequence and eigenvalue λ from the recurrence // expanded over the r sequence of the same parity as (n-m), size N template struct EigPair { T lambda; std::vector d; // d_r for r = parity, parity+2, ... int parity; // 0 if (n-m) even, 1 if odd }; template [[nodiscard]] EigPair compute_eigen(int m, int n, T c_squared, int N = 40) { if (m < 0 || n < m) throw std::invalid_argument("spheroidal: requires 0 ≤ m ≤ n"); const int parity = (n - m) % 2; // index k = 0..N-1 → r = parity + 2k std::vector diag(N), off(N); for (int k = 0; k < N; ++k) { int r = parity + 2 * k; auto co = rec_coeffs(m, r, c_squared); diag[k] = co.B_r; // the sub-diagonal e[k] is between d[k] and d[k+1] if (k + 1 < N) { int r_next = parity + 2 * (k + 1); auto co_next = rec_coeffs(m, r_next, c_squared); // symmetrization: T_{k,k+1} = sqrt(A_{r(k)} · C_{r(k+1)}) T prod = co.A_r * co_next.C_r; if (prod < T(0)) { // for c² < 0 (oblate), A·C should be > 0 (both have a c² factor) // take absolute value just in case prod = std::abs(prod); } off[k] = std::sqrt(prod); } } // set the identity matrix as the initial z std::vector> z(N, std::vector(N, T(0))); for (int k = 0; k < N; ++k) z[k][k] = T(1); std::vector e = off; // since tqli treats e[1..n-1] as the sub-diagonal, pass a shifted copy std::vector e_shift(N, T(0)); for (int i = 0; i < N - 1; ++i) e_shift[i + 1] = off[i]; tqli(diag, e_shift, z); // sort eigenvalues in ascending order (eigenvectors kept in sync) std::vector idx(N); for (int k = 0; k < N; ++k) idx[k] = k; std::sort(idx.begin(), idx.end(), [&](int a, int b) { return diag[a] < diag[b]; }); const int target = (n - m) / 2; if (target >= N) throw std::out_of_range("spheroidal: N too small"); EigPair out; out.lambda = diag[idx[target]]; out.parity = parity; out.d.resize(N); // eigenvector: z[k][col] corresponds to diag[col] int col = idx[target]; for (int k = 0; k < N; ++k) out.d[k] = z[k][col]; // undo the similarity transform applied for symmetrization: // transform from the original recurrence → symmetric form d_r → d̃_r = d_r · ψ_r // ψ_0 = 1, ψ_{k+1}/ψ_k = sqrt(A_{r(k)} / C_{r(k+1)}) · sign // restore d_r to its original scale std::vector psi(N, T(1)); for (int k = 0; k + 1 < N; ++k) { int r = parity + 2 * k; int r_next = parity + 2 * (k + 1); auto co = rec_coeffs(m, r, c_squared); auto co_next = rec_coeffs(m, r_next, c_squared); T denom = co_next.C_r; if (denom == T(0)) { // when C_2 = 0 (parity=0, k=0): no scaling needed psi[k + 1] = psi[k]; } else { T ratio_sq = co.A_r / denom; if (ratio_sq < T(0)) ratio_sq = std::abs(ratio_sq); psi[k + 1] = psi[k] * std::sqrt(ratio_sq); } } for (int k = 0; k < N; ++k) { if (psi[k] != T(0)) out.d[k] /= psi[k]; } // Flammer normalization: make S_{mn}(c, 0) (n-m even) or S_{mn}'(c, 0) (n-m odd) // equal to the corresponding value of P_n^m ─ in practice normalizing by Σ d_r is fine, but // here we align by "Σ d_r · P_{m+r}^m(0) = P_n^m(0)" (even) // for (n-m) odd, Σ d_r · (P_{m+r}^m)'(0) = (P_n^m)'(0) // for simplicity: so that at c=0 only d_0 (parity=0) or d_1 (parity=1) is 1 and the rest 0 // → "d_target = 1" normalization: scale so that d=1 at the target index k=(n-m)/2 T pivot = out.d[target]; if (std::abs(pivot) < std::numeric_limits::min()) { // numerically 0 → leave as is (eigenvector being 0 at the target position is rare) return out; } for (T& x : out.d) x /= pivot; return out; } } // namespace detail_spheroidal // ================================================================ // Prolate spheroidal eigenvalue λ_{mn}(c) (c² > 0) // ================================================================ template [[nodiscard]] T spheroidalEigenvalueProlate(int m, int n, T c) { if (std::isnan(c)) return std::numeric_limits::quiet_NaN(); if (m < 0 || n < m) return std::numeric_limits::quiet_NaN(); if (c == T(0)) return T(n) * T(n + 1); // λ_{mn}(0) = n(n+1) auto eig = detail_spheroidal::compute_eigen(m, n, c * c); return eig.lambda; } // ================================================================ // Oblate spheroidal eigenvalue (c → ic, i.e. c² → -c²) // ================================================================ template [[nodiscard]] T spheroidalEigenvalueOblate(int m, int n, T c) { if (std::isnan(c)) return std::numeric_limits::quiet_NaN(); if (m < 0 || n < m) return std::numeric_limits::quiet_NaN(); if (c == T(0)) return T(n) * T(n + 1); auto eig = detail_spheroidal::compute_eigen(m, n, -c * c); return eig.lambda; } // ================================================================ // Prolate spheroidal angular function S_{mn}^{(1)}(c, η) (first kind) // ================================================================ // // S_{mn}(c, η) = Σ_{k} d_{2k+parity}^{mn}(c) · P^m_{m+2k+parity}(η) // // Normalization: internally aligned to "d=1 at the target index". This differs from both // the Meixner-Schäfke convention and the Flammer convention, but is consistent because // S = P^m_n at c=0. // template [[nodiscard]] T spheroidalPS1(int m, int n, T c, T eta) { if (std::isnan(c) || std::isnan(eta)) return std::numeric_limits::quiet_NaN(); if (m < 0 || n < m) return std::numeric_limits::quiet_NaN(); if (eta < T(-1) || eta > T(1)) return std::numeric_limits::quiet_NaN(); if (c == T(0)) return assocLegendreP(n, m, eta); auto eig = detail_spheroidal::compute_eigen(m, n, c * c); T sum = T(0); const int N = static_cast(eig.d.size()); for (int k = 0; k < N; ++k) { int r = eig.parity + 2 * k; T pmr = assocLegendreP(m + r, m, eta); sum += eig.d[k] * pmr; } return sum; } // ================================================================ // Oblate spheroidal angular function S_{mn}^{O,1}(c, η) // ================================================================ template [[nodiscard]] T spheroidalOS1(int m, int n, T c, T eta) { if (std::isnan(c) || std::isnan(eta)) return std::numeric_limits::quiet_NaN(); if (m < 0 || n < m) return std::numeric_limits::quiet_NaN(); if (eta < T(-1) || eta > T(1)) return std::numeric_limits::quiet_NaN(); if (c == T(0)) return assocLegendreP(n, m, eta); auto eig = detail_spheroidal::compute_eigen(m, n, -c * c); T sum = T(0); const int N = static_cast(eig.d.size()); for (int k = 0; k < N; ++k) { int r = eig.parity + 2 * k; T pmr = assocLegendreP(m + r, m, eta); sum += eig.d[k] * pmr; } return sum; } } // namespace special } // namespace sangi #endif // SANGI_SPECIAL_SPHEROIDAL_HPP