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}