Skip to main content

thermite_special/specialized/generic/
jacobi_elliptic.rs

1//! The Jacobi elliptic functions `sn`, `cn` and `dn`.
2//!
3//! # What they are
4//!
5//! All three are built from one quantity, the **amplitude** `$\varphi = \mathrm{am}(u, k)$`,
6//! defined as the angle whose incomplete elliptic integral of the first kind is `u`
7//! (`$F(\varphi, k) = u$`, see [`ellint_impl`](super::elliptic::ellint_impl)). Then
8//!
9//! ```math
10//! \mathrm{sn}(u, k) = \sin\varphi, \qquad
11//! \mathrm{cn}(u, k) = \cos\varphi, \qquad
12//! \mathrm{dn}(u, k) = \sqrt{1 - k^2\sin^2\varphi}
13//! ```
14//!
15//! Hence the names: sine amplitude, cosine amplitude, delta amplitude. At `k = 0` the
16//! amplitude is `u` itself and they degenerate to `sin u`, `cos u` and `1`. At `k = 1` they
17//! become `tanh u`, `sech u` and `sech u`.
18//!
19//! They are returned together because they are a closed system, not merely because it is
20//! cheaper: differentiating any one of them produces a product of the other two
21//! (`$\mathrm{sn}' = \mathrm{cn}\,\mathrm{dn}$`,
22//! `$\mathrm{cn}' = -\mathrm{sn}\,\mathrm{dn}$`,
23//! `$\mathrm{dn}' = -k^2\mathrm{sn}\,\mathrm{cn}$`), exactly the way `sin` and `cos` close
24//! under differentiation. The other nine Jacobi functions in Glaisher's notation (`ns`,
25//! `nc`, `nd`, `sc`, `sd`, `cs`, `cd`, `ds`, `dc`) are reciprocals and ratios of these
26//! three, so a caller holding the triple holds all twelve.
27//!
28//! # Algorithm
29//!
30//! The descending Landen transformation, in the arithmetic-only form due to Bulirsch
31//! (1965) rather than the textbook one. Both walk the same AGM ladder down from `k` to
32//! modulus zero and then climb back up, but they differ in what the climb costs:
33//!
34//! - The textbook descent (A&S 16.4, and Boost's `jacobi_elliptic`) carries an _angle_
35//!   back up, `$\varphi_{n-1} = \tfrac12(\varphi_n + \arcsin(\tfrac{c_n}{a_n}\sin\varphi_n))$`.
36//!   That is one `sin` and one `asin` per level, on a ladder several levels deep, the
37//!   worst possible shape for a vector unit, where every lane pays for both.
38//! - Bulirsch carries the _tangent_ of the angle instead. The half-angle step becomes
39//!   rational, so the entire climb is multiplies and divides, and the whole function needs
40//!   exactly **one `sin_cos`**, at the bottom of the ladder where the modulus is zero and
41//!   the amplitude is just the argument.
42//!
43//! Measured against mpmath at 40 digits over `k` in `[0, 1)` and `|u| <= 8`, worst absolute
44//! error 8.3 eps for `sn`, 4.1 for `cn`, 3.8 for `dn`. Absolute is the honest metric here:
45//! all three are bounded by 1 and all three have zeros, so relative error at a zero is
46//! governed by how well the zero's location is known, exactly as for `sin`.
47//!
48//! Accuracy degrades with `|u|` the way `sin`'s does and for the same reason: the one
49//! trig call takes `u` scaled by the AGM limit, so a large `|u|` is a large argument to
50//! reduce. The error above was measured to `|u| = 8`, and grows slowly beyond that.
51//!
52//! # The ladder is bounded, and short
53//!
54//! `$k' = \sqrt{1 - k^2}$` is what the AGM starts from, and for any `k` strictly below 1 in
55//! binary64 the cancellation-free `one_minus_sq` bottoms out at `$2^{-52}$`, so `k'` never
56//! falls below about `1.5e-8` and the ladder is never deeper than 8 rungs (measured, 4 to 6
57//! is typical). [`NMAX`] carries two rungs of margin on top of that.
58//!
59//! Lanes converge at different depths, so the loop runs until _every_ lane has converged
60//! and the climb then runs the full depth for all of them. That is safe: past convergence
61//! `$a_n = b_n$`, so the extra rungs are identity transformations, and running them for
62//! every lane unconditionally was measured to give bit-identical results to stopping each
63//! lane at its own depth.
64
65use thermite::{
66    element::FloatElement,
67    math::{
68        CoreMathWithPolicy as _, TranscendentalMathWithPolicy as _, policy::Policy,
69        specialized::SpecializedTranscendentalMath,
70    },
71    prelude::*,
72};
73
74/// Maximum depth of the AGM ladder.
75///
76/// Eight rungs is the measured worst case over the whole domain, reached only as `k`
77/// approaches 1. Two more are carried as margin. The forward pass stops as soon as every
78/// lane has converged, so this is a bound and not a trip count.
79pub const NMAX: usize = 10;
80
81/// `(sn, cn, dn)` at argument `u` and modulus `k`.
82///
83/// Only `$k^2$` enters, so the sign of `k` is irrelevant and `|k| > 1` is out of domain:
84/// `$1 - k^2$` goes negative, its square root is NaN, and the NaN propagates on its own
85/// without a guard. `k = 1` is the one modulus the ladder cannot walk (it starts at
86/// `$k' = 0$` and never converges), and is taken by the hyperbolic limit instead.
87#[inline(always)]
88pub fn jacobi_elliptic<P, E, V>(u: V, k: V) -> (V, V, V)
89where
90    E: FloatElement,
91    V: FloatVector<Element = E> + SpecializedTranscendentalMath<E>,
92    P: Policy,
93{
94    let half = V::HALF;
95
96    // The AGM ladder on (a, b) = (1, k'), recording both sequences: the climb needs every
97    // rung, and `b` cannot be recovered from `a` alone (b_i = 2 a_{i+1} - a_i loses all of
98    // b_0's digits when k' is small, which is exactly the case that needs the depth).
99    let mut asq = [V::ZERO; NMAX];
100    let mut bsq = [V::ZERO; NMAX];
101
102    let mut a = V::ONE;
103    let mut b = k.one_minus_sq(); // k'^2, cancellation-free as |k| -> 1
104    let mut c = V::ONE;
105
106    // Same threshold and same pre-update gap test as `agm_complete_ke`: the limit sits near
107    // the midpoint of the pair, so the gap going _into_ a rung is what bounds the error
108    // coming out of it. See the load-bearing note on that function.
109    let thresh = V::SQRT_EPSILON;
110    let mut depth = NMAX - 1;
111    for i in 0..NMAX {
112        V::_loop_hint();
113
114        asq[i] = a;
115        b = b.sqrt();
116        bsq[i] = b;
117        c = (a + b) * half;
118
119        if (a - b).abs().cmp_le(a * thresh).all() {
120            depth = i;
121            break;
122        }
123
124        b *= a;
125        a = c;
126    }
127
128    // Bottom of the ladder: modulus zero, where the amplitude is the argument itself. This
129    // is the only transcendental in the function.
130    let (sin_u, cos_u) = (u * c).sin_cos_p::<P>();
131
132    // The climb carries t = cot(amplitude) rather than the amplitude, which is what keeps it
133    // rational. A zero sine is a pole of the cotangent. Those lanes are recovered at the end,
134    // so all this has to do is keep the division finite.
135    let at_zero = sin_u.is_zero();
136    let sin_safe = at_zero.select(V::ONE, sin_u);
137
138    let mut t = cos_u.approx_div_p::<P>(sin_safe);
139    let mut w = c * t;
140    let mut dn = V::ONE;
141
142    let mut i = depth;
143    loop {
144        V::_loop_hint();
145
146        let ai = asq[i];
147        t *= w;
148        w *= dn;
149        dn = (bsq[i] + t).approx_div_p::<P>(ai + t);
150        t = w.approx_div_p::<P>(ai);
151
152        if i == 0 {
153            break;
154        }
155        i -= 1;
156    }
157
158    // sin and cos recovered from the cotangent: |sn| = 1/sqrt(w^2 + 1), and the sign is the
159    // one the bottom-of-ladder sine already carried.
160    let mag = w.mul_adde(w, V::ONE).inverse_sqrt_p::<P>();
161    let mut sn = mag.copysign(sin_u);
162    let mut cn = w * sn;
163
164    // Where the amplitude's sine vanished, the triple is (0, +-1, 1) exactly: the cotangent
165    // route cannot produce it, and cos_u is already the correct +-1.
166    sn = at_zero.select(V::ZERO, sn);
167    cn = at_zero.select(cos_u, cn);
168    dn = at_zero.select(V::ONE, dn);
169
170    if const { P::POLICY.check_overflow } {
171        // k = 1 makes k' = 0: the ladder starts at its own fixed point and never converges,
172        // so the limit is substituted whole. sn -> tanh, cn and dn -> sech, and the three
173        // stop being periodic.
174        let unit = k.abs().cmp_eq(V::ONE);
175        if const { P::POLICY.avoid_branching } || thermite::unlikely(unit.any()) {
176            let sech = V::ONE.approx_div_p::<P>(u.cosh_p::<P>());
177            sn = unit.select(u.tanh_p::<P>(), sn);
178            cn = unit.select(sech, cn);
179            dn = unit.select(sech, dn);
180        }
181    }
182
183    (sn, cn, dn)
184}
Last built: 2026-09-08 21:35:55 UTC