Skip to main content

thermite_special/specialized/generic/bessel/
airy.rs

1//! The Airy functions `$\mathrm{Ai}$`, `$\mathrm{Bi}$` and their derivatives.
2//!
3//! Airy is Bessel at order `$\pm 1/3$` and `$\pm 2/3$`, with
4//! `$\zeta = \tfrac{2}{3}\lvert x\rvert^{3/2}$`. That is not a shortcut anyone invented here.
5//! It is how Boost.Math's Airy functions are written, and the reason the fractional-order machinery in
6//! [`bessel_nu`](super::jy_real) and [`bessel_ik_nu`](super::ik_real) had to exist
7//! first.
8//!
9//! ```math
10//! \begin{aligned}
11//! x < 0:\quad \mathrm{Ai} &= \tfrac{\sqrt{-x}}{3}\left(J_{1/3} + J_{-1/3}\right), &
12//!             \mathrm{Bi} &= \sqrt{\tfrac{-x}{3}}\left(J_{-1/3} - J_{1/3}\right) \\
13//! x > 0:\quad \mathrm{Ai} &= \tfrac{1}{\pi}\sqrt{\tfrac{x}{3}}\,K_{1/3}, &
14//!             \mathrm{Bi} &= \sqrt{\tfrac{x}{3}}\left(I_{-1/3} + I_{1/3}\right)
15//! \end{aligned}
16//! ```
17//!
18//! with the derivatives the same shapes at order `$2/3$`. Note `$\mathrm{Ai}$` on the positive
19//! axis goes through `$K$` rather than the `$I$` **difference**, and Boost's comment says why:
20//! "the accuracy is horrible as we're subtracting two very large values". `$\mathrm{Bi}$` uses
21//! the `$I$` **sum**, which has no such problem.
22//!
23//! # Order `1/3` is where the rotation stops costing anything
24//!
25//! Both branches need the negative order as well as the positive one, and a second Bessel
26//! evaluation is the expensive way to get it. One pass suffices:
27//! `$J_{-\nu} = J_\nu\cos\nu\pi - Y_\nu\sin\nu\pi$` and
28//! `$I_{-\nu} = I_\nu + \tfrac{2}{\pi}\sin(\nu\pi)K_\nu$`, and both kernels return the pair.
29//!
30//! At thirds those trigonometric factors are **exact**: `$\cos(\pi/3) = 1/2$`,
31//! `$\cos(2\pi/3) = -1/2$`, and `$\sin(\pi/3) = \sin(2\pi/3) = \sqrt3/2$`. So the rotation
32//! costs two multiplies and no transcendental at all. This is the whole of what a `Thirds`
33//! specialisation can buy (there is no cheaper _algorithm_ for a third-order Bessel function
34//! in any library), and it is bought here rather than in the order dispatch.
35//!
36//! # Accuracy is set by `zeta`, which is why the scaled form exists
37//!
38//! Measured against mpmath (`notes/special/tools/model_airy.py`), the error of the unscaled
39//! functions grows **linearly in `$\zeta$`** on both sides of the origin, and for two different
40//! reasons that arrive at the same number:
41//!
42//! | `x` | mechanism | measured |
43//! |---|---|---|
44//! | `-10` | phase: `$\mathrm{ulp}(\zeta)$` of argument error becomes phase error | 21 eps |
45//! | `-300` | same | 3.7e3 eps |
46//! | `-1e4` | same | 3.2e5 eps |
47//! | `+10` | the `$e^{-\zeta}$` factor costs `$\zeta\varepsilon/2$` relative | 3.2 eps |
48//! | `+100` | same | 684 eps |
49//!
50//! On the negative axis that is irreducible without a two-word `$\zeta$`, and Boost has the
51//! same exposure. On the **positive** axis it is not: with `SCALED` the exponential is never
52//! formed, because [`bessel_ik_real`](super::ik_real::bessel_ik_real) already returns
53//! `$e^{\zeta}K$` natively. The scaled pair holds 1-3 eps everywhere and, being free of the
54//! exponential, also has no range limit. `$\zeta$` passes 710 at `$x \approx 104$`, where the
55//! unscaled `$\mathrm{Ai}$` goes subnormal (zero by 108) and `$\mathrm{Bi}$` overflows.
56//!
57//! Boost ships no scaled Airy. SciPy does, as `airye`, and for exactly this reason.
58
59use thermite::{
60    LargeInt,
61    math::{
62        PrimalProjection, TranscendentalMathWithPolicy,
63        policy::{Policy, PrecisionPolicy},
64        specialized::SpecializedTranscendentalMath,
65    },
66    prelude::*,
67};
68
69use thermite::const_splat;
70use thermite::element::FloatElement;
71
72use crate::specialized::{BesselDetails, SpecializedSpecialMath};
73use crate::tables::bessel::airy::AiryZero;
74use crate::tables::lgamma1p::LogGamma1p;
75
76/// `$(\mathrm{Ai}(x),\; \mathrm{Ai}'(x),\; \mathrm{Bi}(x),\; \mathrm{Bi}'(x))$`.
77///
78/// SciPy's `airy` returns this tuple in this order, and so does this.
79///
80/// With `SCALED`, returns
81/// `$(e^{\zeta}\mathrm{Ai},\; e^{\zeta}\mathrm{Ai}',\; e^{-\zeta}\mathrm{Bi},\;
82/// e^{-\zeta}\mathrm{Bi}')$` for `$x > 0$` and the unscaled values for `$x \le 0$`, where they
83/// oscillate and there is nothing to scale (SciPy's `airye` convention).
84///
85/// # The four `WANT_*` flags, and why the single-function entry points are not wrappers
86///
87/// The four outputs split across **two independent Bessel evaluations**: `$\mathrm{Ai}$` and
88/// `$\mathrm{Bi}$` come from order `$1/3$`, the two derivatives from order `$2/3$`. Nothing is
89/// shared between them, so a caller who wants one value should not pay for both passes, which
90/// is the whole reason `airy_ai` is its own entry point rather than `airy(x).0`.
91///
92/// Each pass is behind a `const` test on the flags, and on the positive axis `WANT_BI` /
93/// `WANT_BIP` additionally decide `bessel_ik_real`'s `NEED_I`: `$\mathrm{Ai}$` is `$K_{1/3}$`
94/// alone, so asking only for it skips the continued fraction _and_ the asymptotic series that
95/// produce `$I$`. So `airy_ai` costs roughly a quarter of `airy`, not a half.
96///
97/// They are four separate `bool` parameters rather than one bitmask because a **derived**
98/// const cannot be a const-generic argument on stable (`generic_const_exprs`), and
99/// `NEED_I` has to be passed on. A standalone const parameter can be forwarded. `WANT & BI`
100/// cannot. Same wall the Bessel order parameter hit, recorded in the landmines.
101///
102/// Slots the flags exclude come back as zero. That is why the public entry points take one
103/// field each and the tuple ones set all four: the impl is the same function.
104///
105/// # Cost, and why the two branches are guarded
106///
107/// A packet spanning the origin runs both branches: `$J/Y$` for the negative lanes, `$I/K$`
108/// for the positive ones. Each is behind an `any()` guard, so a packet that does not straddle
109/// zero pays for one, which is the common case and worth the two branches.
110#[inline(always)]
111#[allow(clippy::too_many_arguments)]
112pub fn airy_impl<
113    P,
114    E,
115    V,
116    const NH: usize,
117    const NE: usize,
118    const NO: usize,
119    const FN: LargeInt,
120    const FD: LargeInt,
121    const SCALED: bool,
122    const WANT_AI: bool,
123    const WANT_AIP: bool,
124    const WANT_BI: bool,
125    const WANT_BIP: bool,
126>(
127    x: V,
128    t: &LogGamma1p<E, NE, NO>,
129    z: &AiryZero<E>,
130    far_threshold: E,
131) -> (V, V, V, V)
132where
133    E: FloatElement,
134    V: FloatVector<Element = E>
135        + TranscendentalMathWithPolicy
136        + SpecializedTranscendentalMath<E>
137        + SpecializedSpecialMath<E>
138        + PrimalProjection<Primal = V>
139        + BesselDetails<V>,
140    P: Policy,
141{
142    // Which Bessel pass each half of the request needs. Order 1/3 carries the values, order
143    // 2/3 the derivatives, and the two share nothing.
144    let values = const { WANT_AI || WANT_BI };
145    let derivs = const { WANT_AIP || WANT_BIP };
146
147    let ax = x.abs();
148    let root = ax.sqrt();
149
150    let third: V = const_splat!(ratio <E>: 1 / 3);
151    let two_third: V = const_splat!(ratio <E>: 2 / 3);
152
153    // zeta = (2/3) |x|^{3/2}. The single most accuracy-critical line in the file: on the
154    // negative axis this is a phase, so its rounding is the error floor.
155    let zeta = (ax * root) * two_third;
156
157    // The origin, and everything that rounds to it. `zeta` is subnormal for
158    // `3e-216 < |x| < 8e-206` and zero below, and a subnormal `zeta` carries only a few bits,
159    // which `Ai ~ zeta^{-1/3}` hands straight back (2.2e-2 relative at `x = 1e-215` with the
160    // guard at `zeta == 0`). Every Airy value rounds to its value at the origin there, so the
161    // substitution is exact. The same compare catches a denormal-flushing policy's zero, and
162    // the Bessel passes get a harmless `zeta` on those lanes so a NaN cannot hold a series
163    // open to `max_iterations` (10.5 ms against 3.3 us per packet).
164    let at_zero = zeta.cmp_lt(V::MIN_POSITIVE);
165
166    // ---- a second word of zeta, at `Best` -----------------------------------------------
167    //
168    // The error of everything downstream is `zeta`'s rounding: on the negative axis it is a
169    // phase (measured 21 eps at x = -10, 3.7e3 at -300), and on the positive axis it is the
170    // exponent of `e^{-zeta}` (684 eps at x = 100). Neither is in the Bessel kernels, which
171    // hold a few eps. It is `zeta` itself, one rounded number, being handed to a sine or an
172    // exponential of it. So carry the rounding: `zeta = hi + lo` with `lo` from three exact
173    // residuals (the square root's, the product's, the constant's), and let the two
174    // consumers that are sensitive to it (the Hankel arm's phase and the unscaled
175    // exponentials) apply it to first order. Every other consumer is insensitive: the
176    // scaled `K`/`I` see `lo / (2 zeta)`, and the small-`zeta` arms see `lo` against their
177    // own several eps.
178    //
179    // Correctly-rounded FMAs (`mul_add`), since the residuals are only exact when fused;
180    // `Best` pays the emulation where there is no hardware FMA, the lower tiers pay nothing.
181    let zeta_lo = if const { P::POLICY.precision.ge(PrecisionPolicy::Best) } {
182        // sqrt(ax) = root + r / (2 root), r = ax - root^2 exactly.
183        let r = root.nmul_add(root, ax);
184        let root_lo = r / (root + root);
185        // ax * sqrt(ax) = p + (p_err + ax * root_lo), p_err exact.
186        let p = ax * root;
187        let p_lo = ax.mul_sub(root, p) + ax * root_lo;
188        // 2/3 = c + c_lo, with 2 - 3c exact.
189        let c_lo = two_third.nmul_add(const_splat!(int <E>: 3), V::TWO) * third;
190        // zeta = c * p: the rounding of that product, plus the two carried terms.
191        let lo = two_third.mul_sub(p, zeta) + two_third * p_lo + c_lo * p;
192        at_zero.select(V::ZERO, lo)
193    } else {
194        V::ZERO
195    };
196
197    let zeta = at_zero.select(V::ONE, zeta);
198
199    let neg = x.is_negative();
200    let pos = !neg;
201
202    // The exact rotation factors at thirds. `sin(pi/3) = sin(2pi/3) = sqrt(3)/2`, and the
203    // cosines are +-1/2, so no transcendental is evaluated for the negative order anywhere.
204    let half_sqrt3 = V::SQRT_3 * V::HALF;
205
206    // `root` was formed on the way to `zeta`. `Best` pays a second square root for
207    // `sqrt(x/3)`. The lower tiers scale the one in hand, at about half an ulp.
208    let root_third = if const { P::POLICY.precision.ge(PrecisionPolicy::Best) } {
209        (ax * third).sqrt()
210    } else {
211        root * V::FRAC_1_SQRT_3
212    };
213
214    let mut ai = V::ZERO;
215    let mut aip = V::ZERO;
216    let mut bi = V::ZERO;
217    let mut bip = V::ZERO;
218
219    // ---- x < 0: the oscillating branch --------------------------------------------------
220    //
221    // Both `Ai` and `Bi` need `J` **and** `Y` here, because the negative order comes from the
222    // rotation, so unlike the positive branch there is nothing the mask can drop inside a
223    // pass, only whole passes.
224    if neg.any() {
225        if values {
226            let (j1, y1) = super::jy_real::bessel_jy_real::<P, E, V, NH, NE, NO, FN, FD>(third, zeta, zeta_lo, t);
227
228            // J_{-1/3} = J_{1/3}/2 - (sqrt3/2) Y_{1/3}. Written folded, so `J + J_{-}` and
229            // `J - J_{-}` are each one FMA rather than two roundings and a subtract.
230            ai = root * (j1.mul_sube(const_splat!(ratio <E>: 3 / 2), half_sqrt3 * y1)) * third;
231            bi = -root_third * (j1.mul_adde(V::HALF, half_sqrt3 * y1));
232        }
233
234        if derivs {
235            let (j2, y2) = super::jy_real::bessel_jy_real::<P, E, V, NH, NE, NO, FN, FD>(two_third, zeta, zeta_lo, t);
236
237            // At 2/3 the cosine flips sign, so the two combinations swap which one is the sum.
238            // Ai' = |x| (J_{2/3} - J_{-2/3})/3, Bi' = |x| (J_{2/3} + J_{-2/3})/sqrt3.
239            aip = ax * (j2.mul_adde(const_splat!(ratio <E>: 3 / 2), half_sqrt3 * y2)) * third;
240            bip = ax * (j2.mul_sube(V::HALF, half_sqrt3 * y2)) * V::FRAC_1_SQRT_3;
241        }
242    }
243
244    // ---- x > 0: the exponential branch ---------------------------------------------------
245    if pos.any() {
246        // The exponentials the unscaled form pays for, hoisted so the two orders share them.
247        // Each costs `zeta eps / 2` relative (684 eps at x = 100), which is the whole
248        // argument for the scaled twin.
249        //
250        // `e^{-(hi + lo)} = e^{-hi} (1 - lo)` to first order: the second word of `zeta`
251        // is what turns the `zeta eps / 2` of the unscaled forms into a few eps at `Best`.
252        let (em, ep) = match const { SCALED } {
253            true => (V::ONE, V::ONE),
254            false => (
255                (-zeta).exp_p::<P>() * (V::ONE - zeta_lo),
256                zeta.exp_p::<P>() * (V::ONE + zeta_lo),
257            ),
258        };
259
260        // `e^{-2 zeta}`, the factor the scaled reflection needs, and only `Bi`/`Bi'` need it.
261        // It must be its own exponential. Reconstructing it from an `expm1` in hand loses
262        // everything past zeta ~ 8, measured on `bessel_ik_half`. `sqrt(3)/pi` is
263        // `(2/pi) sin(nu pi)` at both thirds.
264        let refl = match const { WANT_BI || WANT_BIP } {
265            false => V::ZERO,
266            true => V::SQRT_3 * V::FRAC_1_PI * (-(zeta + zeta)).exp_p::<P>() * (V::ONE - (zeta_lo + zeta_lo)),
267        };
268
269        if values {
270            // `Ai` is `K_{1/3}` alone, so a caller asking only for it skips `I` entirely,
271            // which is the continued fraction and the asymptotic series both.
272            let (i1, k1) =
273                super::ik_real::bessel_ik_real::<P, E, V, V, NE, NO, true, WANT_BI>(third, zeta, t, far_threshold);
274
275            ai = pos.select(root_third * V::FRAC_1_PI * k1 * em, ai);
276            bi = pos.select(root_third * refl.mul_adde(k1, i1 + i1) * ep, bi);
277        }
278
279        if derivs {
280            let (i2, k2) =
281                super::ik_real::bessel_ik_real::<P, E, V, V, NE, NO, true, WANT_BIP>(two_third, zeta, t, far_threshold);
282
283            aip = pos.select(-(ax * V::FRAC_1_SQRT_3 * V::FRAC_1_PI) * k2 * em, aip);
284            bip = pos.select((ax * V::FRAC_1_SQRT_3) * refl.mul_adde(k2, i2 + i2) * ep, bip);
285        }
286    }
287
288    // ---- the origin, see `at_zero` above -------------------------------------------------
289    (
290        at_zero.select(V::splat(z.ai), ai),
291        at_zero.select(V::splat(z.aip), aip),
292        at_zero.select(V::splat(z.bi), bi),
293        at_zero.select(V::splat(z.bip), bip),
294    )
295}
Last built: 2026-09-08 21:35:55 UTC