Skip to main content

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}
Last built: 2026-09-08 21:35:55 UTC