Skip to main content

thermite_special/specialized/generic/bessel/
spherical.rs

1//! The **spherical** Bessel functions `$j_n$`, `$y_n$`, `$i_n$`, `$k_n$`.
2//!
3//! ```math
4//! j_n(x) = \sqrt{\tfrac{\pi}{2x}}\,J_{n+1/2}(x), \qquad
5//! y_n(x) = \sqrt{\tfrac{\pi}{2x}}\,Y_{n+1/2}(x)
6//! ```
7//!
8//! and likewise for the modified pair. Both references ship these publicly: Boost as
9//! `sph_bessel` / `sph_neumann`, SciPy as `spherical_jn` /
10//! `spherical_yn` / `spherical_in` / `spherical_kn`. Boost has no modified spherical pair.
11//! SciPy has no oscillating primes as separate names. This module is the union.
12//!
13//! # The `sqrt` never gets formed, because it would only be cancelled
14//!
15//! Boost implements `sph_bessel` as literally `sqrt(pi/(2x)) * cyl_bessel_j(n + 1/2, x)`.
16//! That is two square roots that multiply to `$1/x$`: the cylindrical
17//! kernel builds its answer on `$\sqrt{2/\pi x}$` and the wrapper immediately multiplies by
18//! `$\sqrt{\pi/2x}$`.
19//!
20//! Here the recurrence is seeded in the **spherical** normalization directly:
21//!
22//! ```math
23//! j_{-1} = \frac{\cos x}{x},\quad j_0 = \frac{\sin x}{x}, \qquad
24//! y_{-1} = \frac{\sin x}{x},\quad y_0 = -\frac{\cos x}{x}
25//! ```
26//!
27//! The scaling factor between the two conventions does not depend on the order, so the
28//! recurrence is unchanged and [`walk_jy`](super::half::walk_jy) is reused verbatim
29//! (seeds in one normalization, values out in the same one). Two `sqrt`s, two divisions and a
30//! rounding disappear, and `$j_0$` becomes exactly [`sinc`](thermite::math::TranscendentalMath::sinc),
31//! which is correct **at `$x = 0$`** where the cylindrical route is `$0 \cdot \infty$`.
32//!
33//! Boost needs a small-`$z$` series below `$x = 1$` for that reason. This
34//! module needs none: the downward recurrence already covers small `$x$`, and is the arm
35//! that runs there anyway, since `$n < x$` is what selects the forward one.
36//!
37//! # Order
38//!
39//! `$n \ge 0$`, matching both references (Boost takes `unsigned`, SciPy documents `n >= 0`),
40//! and spelled `usize` so the constraint is the type rather than an assertion. That is the one
41//! place this family deliberately diverges from the cylindrical entry points, whose `i32`
42//! exists because negative orders there are meaningful. A caller who wants one here can use
43//! `$j_{-n-1}(x) = (-1)^{n+1} y_n(x)$`.
44//!
45//! # Negative `x`
46//!
47//! Unlike their cylindrical parents at half-integer order, `$j_n$`, `$y_n$` and `$i_n$` are
48//! elementary in `$\sin x$`, `$\cos x$`, `$\sinh x$`, `$\cosh x$` and powers of `$1/x$`, so
49//! they are real on the whole line and have definite parity:
50//!
51//! ```math
52//! j_n(-x) = (-1)^n j_n(x), \qquad y_n(-x) = (-1)^{n+1} y_n(x), \qquad i_n(-x) = (-1)^n i_n(x)
53//! ```
54//!
55//! which is SciPy's convention for `spherical_jn` / `spherical_yn` / `spherical_in`. The
56//! kernels evaluate on `$\lvert x\rvert$` and apply the sign at the end. They must, because the
57//! downward walk's trip count is `$a + 24 + c\,x$` and a negative `$x$` would shorten it to
58//! nothing. `$k_n$` has no parity (it is `$e^{-x}$` against `$e^{x}$`) and is NaN off the
59//! positive axis, as the cylindrical `$K$` and SciPy's `spherical_kn` are.
60
61use thermite::{
62    math::{TranscendentalMathWithPolicy, policy::Policy},
63    prelude::*,
64};
65
66use thermite::element::FloatElement;
67
68use super::half::{walk_ik, walk_jy};
69use super::ik::unscale_i_pair;
70
71/// `$(j_{n-1},\; j_n,\; y_{n-1},\; y_n)$`, on the whole real line.
72///
73/// The neighbour below comes back too, because every derivative identity in this family reaches
74/// down one order and the walk passes through it regardless:
75/// `$f_n' = f_{n-1} - \frac{n+1}{x} f_n$`.
76#[inline(always)]
77pub fn sph_jy_impl_n<P, E, V, const N: usize>(x: V) -> (V, V, V, V)
78where
79    E: FloatElement,
80    V: FloatVector<Element = E> + TranscendentalMathWithPolicy,
81    P: Policy,
82{
83    let a = V::splat(E::from_ratio(2 * N as i64 + 1, 2));
84
85    // Evaluated on `|x|` and signed at the end. See the module docs.
86    let ax = x.abs();
87
88    // Only the cosine is taken from here: the sine appears solely as `sin x / x`, which is
89    // `sinc`: identical away from the origin, and exactly 1 at it.
90    let cos_x = ax.cos_p::<P>();
91    let inv_x = V::ONE / ax;
92
93    // `sinc` rather than `sin_x * inv_x`: identical away from the origin, and exactly 1 at it,
94    // which is the value `j_0(0)` actually has.
95    let j_lo = cos_x * inv_x; // j_{-1}
96    let j_hi = ax.sinc_p::<P>(); // j_0
97    let y_lo = j_hi; // y_{-1} =  j_0
98    let y_hi = -j_lo; // y_0    = -j_{-1}
99
100    // Order zero is the seeds, taken directly: the walk would route `n = 0` through its
101    // downward arm for `x < 1/2` and rebuild `j_0` as `j_{-1} r_{1/2}`, 1 ulp off at
102    // `x = 10^{-300}` where `j_0` should be `sinc` to the bit.
103    let (j_prev, j_n, y_prev, y_n) = match const { N == 0 } {
104        true => (j_lo, j_hi, y_lo, y_hi),
105        false => walk_jy::<P, E, V>(ax, a, j_lo, j_hi, y_lo, y_hi),
106    };
107
108    // `x = 0`: `j_0` is 1 and every higher order is 0, while every `y_n` is `-inf`. The
109    // recurrence cannot produce these (`1/x` is infinite and the downward normalization goes
110    // `inf * 0`), so the origin is a select. It is one compare for a value callers do ask for.
111    //
112    // The neighbour gets the same treatment one order down, which at `n = 0` means `j_{-1}`
113    // and `y_{-1}`: `cos(0)/0` is infinite and `sin(0)/0` is one.
114    let at_zero = x.is_zero();
115
116    let j_zero = if const { N == 0 } { V::ONE } else { V::ZERO };
117    let j_prev_zero = if const { N == 0 } {
118        V::INFINITY
119    } else if const { N == 1 } {
120        V::ONE
121    } else {
122        V::ZERO
123    };
124    let y_prev_zero = if const { N == 0 } { V::ONE } else { V::NEG_INFINITY };
125
126    let j_prev = at_zero.select(j_prev_zero, j_prev);
127    let j_n = at_zero.select(j_zero, j_n);
128    let y_prev = at_zero.select(y_prev_zero, y_prev);
129    let y_n = at_zero.select(V::NEG_INFINITY, y_n);
130
131    // All four vanish at infinity, where the seeds are `NaN * 0`.
132    let inf = ax.cmp_eq(V::INFINITY);
133    let (j_prev, j_n) = (inf.select(V::ZERO, j_prev), inf.select(V::ZERO, j_n));
134    let (y_prev, y_n) = (inf.select(V::ZERO, y_prev), inf.select(V::ZERO, y_n));
135
136    // The parity fold. Orders `n` and `n - 1` have opposite parity, and `y` has the opposite
137    // of `j` at each.
138    let neg = x.is_negative();
139    match const { N % 2 == 1 } {
140        true => (j_prev, j_n.neg_c(neg), y_prev.neg_c(neg), y_n),
141        false => (j_prev.neg_c(neg), j_n, y_prev, y_n.neg_c(neg)),
142    }
143}
144
145/// `$(i_{n-1},\; i_n,\; k_{n-1},\; k_n)$`, scaled by `$(e^{-|x|}, e^{x})$` when `SCALED`.
146/// `$i_n$` is folded by parity onto the whole line. `$k_n$` is NaN for `$x < 0$`.
147///
148/// The modified spherical pair, `$i_n(x) = \sqrt{\pi/2x}\,I_{n+1/2}(x)$` and likewise for
149/// `$k$`. SciPy ships both as `spherical_in` / `spherical_kn`. Boost ships neither.
150///
151/// Seeds are `$i_{-1} = \cosh x / x$`, `$i_0 = \sinh x / x$` and
152/// `$k_{-1} = k_0 = \tfrac{\pi}{2}e^{-x}/x$`, the last two equal because `$K$` is even in
153/// order. As with the oscillating pair, the work is done in the spherical normalization so no
154/// square root is formed only to be cancelled.
155///
156/// `far_threshold` is where the unscaled `$i$` halves its exponential. See
157/// [`unscale_i_pair`].
158#[inline(always)]
159pub fn sph_ik_impl_n<P, E, V, const N: usize, const SCALED: bool>(x: V, far_threshold: E) -> (V, V, V, V)
160where
161    E: FloatElement,
162    V: FloatVector<Element = E> + TranscendentalMathWithPolicy,
163    P: Policy,
164{
165    let a = V::splat(E::from_ratio(2 * N as i64 + 1, 2));
166
167    // Evaluated on `|x|` and signed at the end. See the module docs.
168    let ax = x.abs();
169    let inv_x = V::ONE / ax;
170
171    // Scaled seeds, from one `exp_m1`. `e^{-x} sinh x = -expm1(-2x)/2` and
172    // `e^{-x} cosh x = (1 + e^{-2x})/2`, neither of which cancels at small `x`. The
173    // algebraically equal `(1 - e^{-2x})/2` would.
174    let e2m1 = (-(ax + ax)).exp_m1_p::<P>();
175    let half_inv_x = inv_x * V::HALF;
176
177    let i_lo = (V::TWO + e2m1) * half_inv_x; // e^{-x} i_{-1}
178    let i_hi = -e2m1 * half_inv_x; //          e^{-x} i_0
179    let k_seed = V::FRAC_PI_2 * inv_x; //      e^{ x} k_0 = e^{x} k_{-1}
180
181    // Order zero is the seeds: cheaper and exacter, for the reason given in `sph_jy_impl_n`.
182    let (i_prev, i_n, k_prev, k_n) = match const { N == 0 } {
183        true => (i_lo, i_hi, k_seed, k_seed),
184        // The large-`x` asymptotic arm inside the walk produces a CYLINDRICAL value and is
185        // handed the factor that brings it back to this normalization.
186        false => walk_ik::<P, E, V>(ax, a, i_lo, i_hi, k_seed, (V::FRAC_PI_2 * inv_x).sqrt()),
187    };
188
189    // The origin, as for the oscillating pair: `i_0(0) = 1`, higher orders zero, every `k_n`
190    // infinite. `k` reaches that on its own through `1/x`, but `i`'s seeds are `0/0` there.
191    let at_zero = x.is_zero();
192
193    let i_zero = if const { N == 0 } { V::ONE } else { V::ZERO };
194    let i_prev_zero = if const { N == 0 } {
195        V::INFINITY
196    } else if const { N == 1 } {
197        V::ONE
198    } else {
199        V::ZERO
200    };
201
202    let (i_prev, i_n) = (at_zero.select(i_prev_zero, i_prev), at_zero.select(i_zero, i_n));
203
204    // The parity fold for `i`, as for `j`. `k` has none and is undefined there.
205    let neg = x.is_negative();
206    let (i_prev, i_n) = match const { N % 2 == 1 } {
207        true => (i_prev, i_n.neg_c(neg)),
208        false => (i_prev.neg_c(neg), i_n),
209    };
210    let bad = x.cmp_lt(V::ZERO);
211    let (k_prev, k_n) = (bad.select(V::NAN, k_prev), bad.select(V::NAN, k_n));
212
213    match const { SCALED } {
214        true => (i_prev, i_n, k_prev, k_n),
215        false => {
216            let (i_prev, i_n) = unscale_i_pair::<P, E, V>(i_prev, i_n, ax, far_threshold);
217            let em = (-ax).exp_p::<P>();
218            (i_prev, i_n, k_prev * em, k_n * em)
219        }
220    }
221}
222
223/// `$f_n'(x)$` from the pair the walk returns: `$f_n' = \pm f_{n-1} - \frac{n+1}{x} f_n$`.
224///
225/// `MINUS` selects the `$k$` case, whose neighbour enters negated, the same asymmetry the
226/// cylindrical `$K$` has, and for the same reason: `$K$` is the decaying solution, so its
227/// derivative is negative where the others' are not.
228///
229/// # Where this comes from
230///
231/// Not a separate identity: the cylindrical one plus the derivative of the
232/// normalization. With `$f_n = \sqrt{\pi/2x}\,F_{n+1/2}$` and
233/// `$F_\nu' = F_{\nu-1} - \frac{\nu}{x}F_\nu$`, the extra `$-\frac{1}{2x}$` from
234/// differentiating `$\sqrt{\pi/2x}$` turns `$\frac{n+1/2}{x}$` into `$\frac{n+1}{x}$`. That is
235/// the whole difference. It is why the coefficient is `$n+1$` rather than the `$n$` a
236/// half-remembered version of this formula would use.
237#[inline(always)]
238pub fn sph_deriv_n<E, V, const N: usize, const MINUS: bool>(x: V, prev: V, cur: V) -> V
239where
240    E: FloatElement,
241    V: FloatVector<Element = E>,
242{
243    let coeff = V::splat(E::from_int(N as i64 + 1)) / x;
244    let p = match const { MINUS } {
245        true => -prev,
246        false => prev,
247    };
248    let d = coeff.nmul_adde(cur, p);
249
250    // The origin, where `(n+1)/x` is infinite and the identity reads `inf * 0` or
251    // `inf - inf`. `j_1'(0) = i_1'(0) = 1/3` (from `j_1 ~ x/3`) and every other finite
252    // member has a zero derivative there. The singular members' derivatives are infinite with
253    // the opposite sign to the value, since `y_n -> -inf` rises and `k_n -> +inf` falls.
254    let finite_limit = if const { N == 1 } {
255        V::splat(E::from_ratio(1, 3))
256    } else {
257        V::ZERO
258    };
259    let limit = cur.is_finite().select(finite_limit, -cur);
260    x.is_zero().select(limit, d)
261}
262
263// ---- runtime-order twins ------------------------------------------------------------------
264//
265// The same three kernels with the order as a value. Every `const { N .. }` above is a plain
266// branch or select on `n` here and the walk is handed the same `a`, so the two forms agree to
267// the bit. That equality is what `tests/bessel_sph.rs` checks. The const forms stay because
268// their origin selects and parity fold cost nothing at a literal order.
269
270/// The runtime-order twin of [`sph_jy_impl_n`].
271#[inline(always)]
272pub fn sph_jy_impl<P, E, V>(x: V, n: u32) -> (V, V, V, V)
273where
274    E: FloatElement,
275    V: FloatVector<Element = E> + TranscendentalMathWithPolicy,
276    P: Policy,
277{
278    let a = V::splat(E::from_ratio(2 * n as i64 + 1, 2));
279
280    let ax = x.abs();
281    let cos_x = ax.cos_p::<P>();
282    let inv_x = V::ONE / ax;
283
284    let j_lo = cos_x * inv_x;
285    let j_hi = ax.sinc_p::<P>();
286    let y_lo = j_hi;
287    let y_hi = -j_lo;
288
289    let (j_prev, j_n, y_prev, y_n) = match n == 0 {
290        true => (j_lo, j_hi, y_lo, y_hi),
291        false => walk_jy::<P, E, V>(ax, a, j_lo, j_hi, y_lo, y_hi),
292    };
293
294    let at_zero = x.is_zero();
295
296    let j_zero = if n == 0 { V::ONE } else { V::ZERO };
297    let j_prev_zero = match n {
298        0 => V::INFINITY,
299        1 => V::ONE,
300        _ => V::ZERO,
301    };
302    let y_prev_zero = if n == 0 { V::ONE } else { V::NEG_INFINITY };
303
304    let j_prev = at_zero.select(j_prev_zero, j_prev);
305    let j_n = at_zero.select(j_zero, j_n);
306    let y_prev = at_zero.select(y_prev_zero, y_prev);
307    let y_n = at_zero.select(V::NEG_INFINITY, y_n);
308
309    let inf = ax.cmp_eq(V::INFINITY);
310    let (j_prev, j_n) = (inf.select(V::ZERO, j_prev), inf.select(V::ZERO, j_n));
311    let (y_prev, y_n) = (inf.select(V::ZERO, y_prev), inf.select(V::ZERO, y_n));
312
313    let neg = x.is_negative();
314    match n % 2 == 1 {
315        true => (j_prev, j_n.neg_c(neg), y_prev.neg_c(neg), y_n),
316        false => (j_prev.neg_c(neg), j_n, y_prev, y_n.neg_c(neg)),
317    }
318}
319
320/// The runtime-order twin of [`sph_ik_impl_n`].
321#[inline(always)]
322pub fn sph_ik_impl<P, E, V, const SCALED: bool>(x: V, n: u32, far_threshold: E) -> (V, V, V, V)
323where
324    E: FloatElement,
325    V: FloatVector<Element = E> + TranscendentalMathWithPolicy,
326    P: Policy,
327{
328    let a = V::splat(E::from_ratio(2 * n as i64 + 1, 2));
329
330    let ax = x.abs();
331    let inv_x = V::ONE / ax;
332
333    let e2m1 = (-(ax + ax)).exp_m1_p::<P>();
334    let half_inv_x = inv_x * V::HALF;
335
336    let i_lo = (V::TWO + e2m1) * half_inv_x;
337    let i_hi = -e2m1 * half_inv_x;
338    let k_seed = V::FRAC_PI_2 * inv_x;
339
340    let (i_prev, i_n, k_prev, k_n) = match n == 0 {
341        true => (i_lo, i_hi, k_seed, k_seed),
342        false => walk_ik::<P, E, V>(ax, a, i_lo, i_hi, k_seed, (V::FRAC_PI_2 * inv_x).sqrt()),
343    };
344
345    let at_zero = x.is_zero();
346
347    let i_zero = if n == 0 { V::ONE } else { V::ZERO };
348    let i_prev_zero = match n {
349        0 => V::INFINITY,
350        1 => V::ONE,
351        _ => V::ZERO,
352    };
353
354    let (i_prev, i_n) = (at_zero.select(i_prev_zero, i_prev), at_zero.select(i_zero, i_n));
355
356    let neg = x.is_negative();
357    let (i_prev, i_n) = match n % 2 == 1 {
358        true => (i_prev, i_n.neg_c(neg)),
359        false => (i_prev.neg_c(neg), i_n),
360    };
361    let bad = x.cmp_lt(V::ZERO);
362    let (k_prev, k_n) = (bad.select(V::NAN, k_prev), bad.select(V::NAN, k_n));
363
364    match const { SCALED } {
365        true => (i_prev, i_n, k_prev, k_n),
366        false => {
367            let (i_prev, i_n) = unscale_i_pair::<P, E, V>(i_prev, i_n, ax, far_threshold);
368            let em = (-ax).exp_p::<P>();
369            (i_prev, i_n, k_prev * em, k_n * em)
370        }
371    }
372}
373
374/// The runtime-order twin of [`sph_deriv_n`].
375#[inline(always)]
376pub fn sph_deriv<E, V, const MINUS: bool>(x: V, n: u32, prev: V, cur: V) -> V
377where
378    E: FloatElement,
379    V: FloatVector<Element = E>,
380{
381    let coeff = V::splat(E::from_int(n as i64 + 1)) / x;
382    let p = match const { MINUS } {
383        true => -prev,
384        false => prev,
385    };
386    let d = coeff.nmul_adde(cur, p);
387
388    let finite_limit = if n == 1 { V::splat(E::from_ratio(1, 3)) } else { V::ZERO };
389    let limit = cur.is_finite().select(finite_limit, -cur);
390    x.is_zero().select(limit, d)
391}
Last built: 2026-09-08 21:35:55 UTC