thermite_special/specialized/generic/sici.rs
1//! The trigonometric integrals `Si(x) = int_0^x sin(t)/t dt` and
2//! `Ci(x) = gamma + ln x + int_0^x (cos t - 1)/t dt`.
3//!
4//! # Two regions
5//!
6//! Below a crossover (12 in f64, 6 in f32) `Si(x)/x` and `Cin(x)/x^2` are smooth
7//! functions of `v = x^2`, fitted as Chebyshev series and summed by Clenshaw, where
8//! `Cin = gamma + ln x - Ci` is the entire part, the piece that is _not_ the
9//! logarithmic singularity. Above it, the auxiliary form
10//!
11//! ```text
12//! Si = pi/2 - f cos x - g sin x, Ci = f sin x - g cos x
13//! ```
14//!
15//! with `f = P(v)/x` and `g = Q(v)/x^2` for `v = 1/x^2`, both Horner polynomials
16//! tending to 1.
17//!
18//! # Why the crossover is so far out
19//!
20//! Because these auxiliaries are harder than the Fresnel ones, and for a structural
21//! reason worth recording. `f(x) = int_0^inf e^{-xt}/(1+t^2) dt` (verified to 12
22//! digits), so `P(v) = x f` is a _Stieltjes_ function: its asymptotic series
23//! `sum (-1)^k (2k)! v^k` diverges, and the branch cut reaches `v = 0`. Polynomial
24//! convergence at that endpoint is therefore sub-geometric. It shows.
25//! Measured degree for f64, at contribution-weighted targets:
26//!
27//! | range | deg P | amplification | deg Q |
28//! |---|---|---|---|
29//! | `x >= 6` | 33 | 3.6e8 | 31 |
30//! | `x >= 8` | 25 | 455 | 24 |
31//! | `x >= 10` | 20 | 1.19 | 19 |
32//! | `x >= 12` | 17 | 1.03 | 16 |
33//!
34//! One fit at `x >= 12` beats the multi-range ladders that were also measured
35//! (`[8,20]` plus `[20,inf)` is 28 terms and a select, against 17), and Pade of the
36//! divergent series is no better. `[10/10]` reaches only 8.8e-8 at `x >= 8`.
37//!
38//! `P` is fitted at plain relative accuracy because `|Ci| ~ f`, so `f`'s error is
39//! the result's error. `Q` is relaxed by a factor `x`, contributing at `1/x^2`
40//! against a `1/x` result. Re-fitting `Q` at plain relative accuracy adds degrees
41//! and buys nothing.
42//!
43//! # Accuracy
44//!
45//! Measured against mpmath at 45 digits over `x` from 1e-4 to 1e15 (f64) and 1e7
46//! (f32): `Si` 2.03 ulp f64 / 1.34 f32, `Ci` 1.42 / 1.99 relative to its envelope.
47//!
48//! Two contract points belong in the caller's head:
49//!
50//! - **`Ci` has zeros**, the first near `x = 0.6165`, and nothing is relatively
51//! accurate at one. The grading above is against `|gamma + ln x| + |Cin|` below
52//! the crossover and `1/x` above it, which is what the arithmetic can actually
53//! deliver.
54//! - **Large-`x` accuracy inherits `sin_cos`'s argument reduction.** For `Ci` the
55//! oscillation _is_ the value, so a phase error is a relative error. Full
56//! reduction is a `Best`-tier property in this library, and below that `Ci`'s
57//! accuracy at large `x` degrades with it. `Si` is insulated: it tends to `pi/2`
58//! and the oscillation is a correction of size `1/x`.
59//!
60//! `Si` is `pi/2` to within half an ulp above `x = 1.147e16` (f64) / `2.136e7`
61//! (f32). `Ci` has no such cutoff: it decays like `1/x` and stays representable for
62//! every finite argument.
63
64use thermite::{
65 element::FloatElement,
66 math::{
67 CoreMathWithPolicy as _, TranscendentalMathWithPolicy as _, policy::Policy,
68 specialized::SpecializedTranscendentalMath,
69 },
70 prelude::*,
71};
72
73use super::chebyshev::chebyshev_series;
74
75/// `(Si(x), Ci(x))`.
76///
77/// `cheb_si` and `cheb_cin` are Chebyshev coefficients for `Si(x)/x` and
78/// `Cin(x)/x^2` in `v = x^2` mapped onto `[-1, 1]` by `v*map - 1`. `aux_p` and
79/// `aux_q` are ascending monomial coefficients in `v = 1/x^2`.
80#[inline(always)]
81pub fn sici_with<P, E, V, const NSI: usize, const NCI: usize, const NP: usize, const NQ: usize>(
82 x: V,
83 x0: E,
84 map: E,
85 cutoff: E,
86 cheb_si: &[E; NSI],
87 cheb_cin: &[E; NCI],
88 aux_p: &[E; NP],
89 aux_q: &[E; NQ],
90) -> (V, V)
91where
92 E: FloatElement,
93 V: FloatVector<Element = E> + SpecializedTranscendentalMath<E>,
94 P: Policy,
95{
96 // `Si` is odd. `Ci(-x) = Ci(x) + i*pi`, so the real branch is the one at |x| and
97 // the imaginary part is dropped. Both match SciPy's `sici`.
98 let ax = x.abs();
99 let is_small = ax.cmp_le(V::splat(x0));
100
101 if !const { P::POLICY.avoid_branching } && is_small.all() {
102 let (si, ci) = sici_small::<P, E, V, NSI, NCI>(ax, map, cheb_si, cheb_cin);
103 return (si.copysign(x), ci);
104 }
105
106 // One division: rx = 1/x, then f = P(v)/x and g = Q(v)/x^2 with v = rx^2.
107 let rx = ax.approx_reciprocal_p::<P>();
108 let v = rx * rx;
109 let f = v.poly_n_p::<P, NP>(aux_p) * rx;
110 let g = v.poly_n_p::<P, NQ>(aux_q) * v;
111
112 let (sin_x, cos_x) = ax.sin_cos_p::<P>();
113
114 // Chained FMAs: pi/2 - f cos x - g sin x, and f sin x - g cos x.
115 let mut si = g.nmul_adde(sin_x, f.nmul_adde(cos_x, V::FRAC_PI_2));
116 let mut ci = g.nmul_adde(cos_x, f * sin_x);
117
118 if const { P::POLICY.avoid_branching } || thermite::unlikely(is_small.any()) {
119 let (ss, cs) = sici_small::<P, E, V, NSI, NCI>(ax, map, cheb_si, cheb_cin);
120 si = is_small.select(ss, si);
121 ci = is_small.select(cs, ci);
122 }
123
124 // `Si` converges: above the cutoff the oscillating correction is under half an ulp
125 // of pi/2. `Ci` does NOT: it decays like 1/x and stays representable for every
126 // finite argument, so it must keep coming out of the auxiliary branch. Clamping it
127 // to zero on the same condition returns 0 for `Ci(9.9e8)`, whose true value is
128 // -5.4e-10.
129 si = ax.cmp_gt(V::splat(cutoff)).select(V::FRAC_PI_2, si);
130
131 // At an actual infinity both limits have to be named: `rx` is 0, so `f` and `g`
132 // vanish, but `sin_cos(inf)` is a NaN that would otherwise multiply through.
133 let inf = ax.cmp_eq(V::INFINITY);
134 si = inf.select(V::FRAC_PI_2, si);
135 ci = inf.select(V::ZERO, ci);
136
137 (si.copysign(x), ci)
138}
139
140/// `(Si, Ci)` from the Chebyshev series in `v = x^2`, for `|x|` under the crossover.
141///
142/// `Ci = (gamma + ln x) - x^2 B(v)` cancels near the zero at `x ~ 0.6165`, where the
143/// two terms are equal and opposite. That is the function's own conditioning, not
144/// the form's: `Ci` is genuinely zero there and no rearrangement recovers relative
145/// accuracy. Splitting off `Cin` is what keeps everything _else_ accurate. It
146/// isolates the logarithmic singularity, so the small-argument fit never has to
147/// represent it.
148#[inline(always)]
149fn sici_small<P, E, V, const NSI: usize, const NCI: usize>(
150 ax: V,
151 map: E,
152 cheb_si: &[E; NSI],
153 cheb_cin: &[E; NCI],
154) -> (V, V)
155where
156 E: FloatElement,
157 V: FloatVector<Element = E> + SpecializedTranscendentalMath<E>,
158 P: Policy,
159{
160 let v = ax * ax;
161 let t = v.mul_sube(V::splat(map), V::ONE);
162 let a = chebyshev_series::<P, E, V, 1, NSI, true>(t, cheb_si);
163 let b = chebyshev_series::<P, E, V, 1, NCI, true>(t, cheb_cin);
164
165 // ln(0) is -inf and v*b is 0 there, so Ci(0) = -inf falls out without a guard.
166 let ci = (V::EULER_GAMMA + ax.ln_p::<P>()) - v * b;
167 (ax * a, ci)
168}