Skip to main content

thermite_special/specialized/generic/
zernike.rs

1//! The batch Zernike kernel: every mode through degree `L` at one point, in Cartesian
2//! coordinates.
3//!
4//! # The Cartesian substitution
5//!
6//! The polar definition `$Z_n^m = R_n^{|m|}(\rho)\cos(m\theta)$` suggests a `sin_cos` per
7//! mode and a `powi` per mode. Both disappear under one substitution.
8//!
9//! Write `$s = x^2 + y^2 = \rho^2$`. The radial polynomial factors as
10//!
11//! ```math
12//! R_n^{|m|}(\rho) = \rho^{|m|}\, Q_{k,|m|}(s), \qquad
13//! Q_{k,m}(s) = P_k^{(0,m)}(2s - 1), \qquad k = \tfrac{n - |m|}{2}
14//! ```
15//!
16//! so the `$\rho^{|m|}$` is the *only* place an odd power of `$\rho$` appears, and
17//! `$Q$` is an honest polynomial in `s`. Meanwhile
18//!
19//! ```math
20//! (x + iy)^m = \rho^m\left(\cos m\theta + i \sin m\theta\right)
21//! ```
22//!
23//! so `$\rho^{|m|}\cos(m\theta)$` and `$\rho^{|m|}\sin(m\theta)$` are exactly the real and
24//! imaginary parts of `$(x+iy)^{|m|}$`, which come off a two-line complex ladder. The
25//! `$\rho^{|m|}$` the radial part needed and the `$\rho^{|m|}$` the angular part produced
26//! are the same factor, so they never have to be formed separately:
27//!
28//! ```math
29//! Z_n^m = Q_{k,|m|}(s) \times \begin{cases}\operatorname{Re}(x+iy)^{m} & m \ge 0\\
30//!                                          \operatorname{Im}(x+iy)^{|m|} & m < 0\end{cases}
31//! ```
32//!
33//! The whole basis is therefore pure polynomial arithmetic in `(x, y)`: no `atan2`, no
34//! `sqrt`, no trigonometry, no division, `$O(L^2)$` FMAs total, and no singularity at the
35//! pupil centre (which the polar form has, in `$\partial_\theta Z / \rho$`).
36//!
37//! Taking `(x, y)` rather than `$(\rho, \theta)$` is thus not a convenience: a polar entry
38//! point would make the caller pay an `atan2` per sample to build an angle this kernel
39//! immediately destroys. Pupil samples arrive as Cartesian coordinates anyway.
40//!
41//! # The recurrence
42//!
43//! `$Q_{k,m}$` is the Jacobi three-term recurrence rewritten in `s` rather than
44//! `$t = 2s-1$`, which folds the change of variable into the coefficients instead of
45//! spending an operation on it per mode:
46//!
47//! ```math
48//! Q_{0,m} = 1,\qquad Q_{1,m}(s) = (m+2)s - (m+1)
49//! ```
50//! ```math
51//! Q_{k,m} = (A_{k,m}\,s + B_{k,m})\,Q_{k-1,m} - C_{k,m}\,Q_{k-2,m}
52//! ```
53//!
54//! with, writing `$c = 2k(k+m)(2k+m-2)$`,
55//!
56//! ```math
57//! A = \frac{2(2k+m-1)(2k+m)(2k+m-2)}{c},\quad
58//! B = \frac{-(2k+m-1)(m^2 + (2k+m)(2k+m-2))}{c},\quad
59//! C = \frac{2(k-1)(k+m-1)(2k+m)}{c}
60//! ```
61//!
62//! Every coefficient is a ratio of small integers - the largest through `L = 16` is 3360,
63//! comfortably exact in f32 - and at stamped literal `(k, m)` they fold to `.rodata`
64//! constants. Three operations per mode: one FMA for `As + B`, one multiply, one FMA.
65//!
66//! Running the recurrence in `k` at fixed `m` is what makes this `$O(L^2)$` rather than
67//! the `$O(L^3)$` of calling the single-mode entry point per mode, which restarts the
68//! recurrence from `k = 0` every time.
69//!
70//! # Layout
71//!
72//! `out[j]` for the ANSI Z80.28 / OSA index `j = (n(n+2) + m)/2`, so `N` must be
73//! `(L+1)(L+2)/2`. ANSI is the layout rather than Noll or Fringe because it is the
74//! scheme whose index is a closed form *and* whose degree truncation is contiguous;
75//! [`noll_to_ansi`](crate::zernike::noll_to_ansi) and
76//! [`fringe_to_ansi`](crate::zernike::fringe_to_ansi) gather from it.
77//!
78//! Nothing normalizes `(x, y)` onto the unit disc, exactly as the spherical-harmonic
79//! kernels do not renormalize their direction. Outside it the polynomials are still
80//! evaluated correctly and simply are not orthogonal.
81
82use thermite::{
83    math::{CoreMath, policy::Policy},
84    prelude::*,
85    register::FloatElement,
86};
87
88use crate::zernike::{ZERNIKE_ORTHONORMAL, ZERNIKE_UNIT_PEAK};
89
90/// Highest degree the stamped ladder below covers. Beyond it the kernel takes the
91/// rolled path, which is correct at any degree but computes its coefficients at runtime
92/// and does not unroll. Extending the ladder is mechanical: append literals to the `m`
93/// list and, every two degrees, to the `k` list.
94pub const MAX_DEGREE: usize = 16;
95
96// Index invariant, so the kernel can use unchecked accesses. `N == (L+1)(L+2)/2` is
97// const-asserted, and every emitted mode satisfies |m| <= n <= L, whose ANSI index
98//   (n(n+2) + m)/2  <=  (L(L+2) + L)/2  =  L(L+3)/2  =  N - 1
99// so every store is in bounds by construction. The checked forms are not used because
100// their panic paths defeat the unroller, the same finding the SH kernel records.
101
102/// The `(A, B, C)` coefficients of the `Q` recurrence at step `k >= 2`, order `m`.
103///
104/// ```text
105/// Q_k = (A s + B) Q_{k-1} - C Q_{k-2}
106/// ```
107///
108/// Written once and shared by all three consumers (the stamped ladder folds this to
109/// literals, while the rolled path and the single-mode radial call it with runtime
110/// `(k, m)`), so the recurrence exists in exactly one place. The divisions are of small integers in
111/// the element type, never of the vector, so they stay off the recurrence's critical
112/// path even where they are not folded away.
113#[inline(always)]
114fn q_coeffs<E: FloatElement>(k: thermite::LargeInt, m: thermite::LargeInt) -> (E, E, E) {
115    let c = 2 * k * (k + m) * (2 * k + m - 2);
116
117    let an = (2 * k + m - 1) * (2 * k + m) * (2 * k + m - 2);
118    let bn = (2 * k + m - 1) * m * m;
119    let cn = 2 * (k - 1) * (k + m - 1) * (2 * k + m);
120
121    (
122        E::from_ratio(2 * an, c),
123        E::from_ratio(-(bn + an), c),
124        E::from_ratio(cn, c),
125    )
126}
127
128/// The reduced radial polynomial `$Q_{k,m}(s) = P_k^{(0,m)}(2s - 1)$` at runtime `(k, m)`.
129///
130/// The single-mode counterpart of one column of the batch ladder. Used by
131/// [`zernike_r`](crate::SpecialMath::zernike_r) in place of a general `jacobi` call: the
132/// general form carries runtime `alpha`/`beta` and divides *the vector* once per step,
133/// putting a full divide latency in the dependency chain, where this divides small
134/// integers in the element type instead.
135#[inline(always)]
136pub fn reduced_radial_impl<E, V>(s: V, k: u32, m: u32) -> V
137where
138    E: FloatElement,
139    V: FloatVector<Element = E>,
140{
141    if k == 0 {
142        return V::ONE;
143    }
144
145    let mf = m as thermite::LargeInt;
146
147    // Q_1(s) = (m+2)s - (m+1)
148    let mut q_prev = V::ONE;
149    let mut q_cur = s.mul_sube(V::splat(E::from_int(mf + 2)), V::splat(E::from_int(mf + 1)));
150
151    let mut kk = 2;
152    while kk <= k {
153        let (a, b, c) = q_coeffs::<E>(kk as thermite::LargeInt, mf);
154
155        let q_next = s
156            .mul_adde(V::splat(a), V::splat(b))
157            .mul_sube(q_cur, V::splat(c) * q_prev);
158
159        q_prev = q_cur;
160        q_cur = q_next;
161
162        kk += 1;
163    }
164
165    q_cur
166}
167
168/// Unchecked fixed-array write under the module's index invariant.
169#[inline(always)]
170fn put<T, const N: usize>(a: &mut [T; N], i: usize, v: T) {
171    debug_assert!(i < N);
172    // SAFETY: see the index invariant above.
173    unsafe {
174        *a.get_unchecked_mut(i) = v;
175    }
176}
177
178/// The ANSI Z80.28 / OSA slot for `(n, m)`, as a `usize` for indexing.
179#[inline(always)]
180const fn slot(n: usize, m: i32) -> usize {
181    ((n * (n + 2)) as i32 + m) as usize / 2
182}
183
184/// `$N_n^m$` for the requested normalization, as an element constant.
185///
186/// Folds to a literal at stamped `(n, m)`: `NORM` is a monomorphized constant and the
187/// `sqrt` is of an exactly-representable integer.
188#[inline(always)]
189fn norm<E: FloatElement, const NORM: u8>(n: usize, m: usize) -> E {
190    if const { NORM == ZERNIKE_UNIT_PEAK } {
191        return <E as thermite::register::Element>::ONE;
192    }
193
194    // sqrt(2(n+1) / (1 + delta_{m,0}))
195    let radicand = if m == 0 { n + 1 } else { 2 * (n + 1) };
196
197    FloatElement::sqrt(E::from_int(radicand as thermite::LargeInt))
198}
199
200/// Writes the one or two modes of degree `n` and azimuthal order `+-m`.
201///
202/// `q` is the reduced radial polynomial `$Q_{k,m}(s)$`; `u` and `v` are the real and
203/// imaginary parts of `$(x+iy)^m$`, which already carry the `$\rho^m$` the radial part
204/// omitted. `m = 0` has a single mode, and `v` is zero there anyway.
205#[inline(always)]
206fn emit<E, V, const N: usize, const NORM: u8>(out: &mut [V; N], n: usize, m: usize, q: V, u: V, v: V)
207where
208    E: FloatElement,
209    V: FloatVector<Element = E>,
210{
211    let qn = q * V::splat(norm::<E, NORM>(n, m));
212
213    if m == 0 {
214        put(out, slot(n, 0), qn);
215    } else {
216        put(out, slot(n, m as i32), qn * u);
217        put(out, slot(n, -(m as i32)), qn * v);
218    }
219}
220
221/// [`emit`] plus the Cartesian gradient of the same one or two modes.
222///
223/// `dq` is `$\partial Q_{k,m}/\partial s$`, and `up`/`vp` are the real and imaginary parts
224/// of `$(x+iy)^{m-1}$` - the previous rung of the same ladder `u`/`v` came from.
225///
226/// Both factors of the mode depend on the point, so both differentiate. The radial half
227/// goes through `s`, giving `$\partial s/\partial x = 2x$`; the azimuthal half is a
228/// complex power, so `$\partial_x (x+iy)^m = m(x+iy)^{m-1}$` and
229/// `$\partial_y (x+iy)^m = im(x+iy)^{m-1}$`, which is why the `y` derivative crosses the
230/// real and imaginary parts over and flips one sign.
231///
232/// `tx`/`ty` are `2x` and `2y`, hoisted by the caller since every mode uses them.
233#[inline(always)]
234#[allow(clippy::too_many_arguments)]
235fn emit_d<E, V, const N: usize, const NORM: u8>(
236    out: &mut [V; N],
237    ddx: &mut [V; N],
238    ddy: &mut [V; N],
239    n: usize,
240    m: usize,
241    (tx, ty): (V, V),
242    (q, dq): (V, V),
243    (u, v): (V, V),
244    (up, vp): (V, V),
245) where
246    E: FloatElement,
247    V: FloatVector<Element = E>,
248{
249    let scale = V::splat(norm::<E, NORM>(n, m));
250
251    let qn = q * scale;
252    let dqn = dq * scale;
253
254    if m == 0 {
255        // u = 1 and du/dx = 0 * u_{-1} = 0, so only the radial half survives.
256        put(out, slot(n, 0), qn);
257        put(ddx, slot(n, 0), tx * dqn);
258        put(ddy, slot(n, 0), ty * dqn);
259
260        return;
261    }
262
263    let mq = qn * V::splat(E::from_int(m as thermite::LargeInt));
264
265    let (rx, ry) = (tx * dqn, ty * dqn);
266
267    let jp = slot(n, m as i32);
268    let jm = slot(n, -(m as i32));
269
270    put(out, jp, qn * u);
271    put(ddx, jp, rx.mul_adde(u, mq * up));
272    put(ddy, jp, ry.mul_sube(u, mq * vp));
273
274    put(out, jm, qn * v);
275    put(ddx, jm, rx.mul_adde(v, mq * vp));
276    put(ddy, jm, ry.mul_adde(v, mq * up));
277}
278
279// --- The literal ladder ---
280//
281// Stamped rather than looped, for the reason the SH kernel documents at length: LLVM
282// declines to unroll a triangular nest over a const-generic bound, leaving runtime index
283// arithmetic and register-indexed coefficient loads. Behind `if <lit> <= L` guards with
284// `L` a monomorphized constant, dead modes fold away and live ones become fixed offsets
285// and `.rodata` broadcasts.
286//
287// Hygiene note, as in `sh`: recurrence state (`q_prev`/`q_cur`, `u`/`v`) is threaded
288// between rules as `ident` arguments so it keeps its definition context.
289
290macro_rules! zernike_columns {
291    ($L:ident, $E:ident, $V:ident, $NORM:ident, $s:ident, $x:ident, $y:ident, $out:ident) => {
292        // (u, v) = Re/Im of (x + iy)^m, starting at m = 0.
293        let mut u = $V::ONE;
294        let mut v = $V::ZERO;
295
296        zernike_columns!(@m $L, $E, $V, $NORM, $s, $x, $y, u, v, $out;
297            0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16);
298    };
299
300    (@m $L:ident, $E:ident, $V:ident, $NORM:ident, $s:ident, $x:ident, $y:ident,
301     $u:ident, $v:ident, $out:ident; $($mv:literal)*) => { $(
302        if $mv <= $L {
303            // k = 0: Q = 1, so the mode is the azimuthal factor alone. This is the
304            // n = |m| diagonal, R_m^m = rho^m.
305            emit::<$E, $V, _, $NORM>($out, $mv, $mv, $V::ONE, $u, $v);
306
307            if $mv + 2 <= $L {
308                // k = 1: Q_{1,m}(s) = (m+2)s - (m+1)
309                let mut q_prev = $V::ONE;
310                let mut q_cur = $s.mul_sube(
311                    $V::splat($E::from_int($mv + 2)),
312                    $V::splat($E::from_int($mv + 1)),
313                );
314
315                emit::<$E, $V, _, $NORM>($out, $mv + 2, $mv, q_cur, $u, $v);
316
317                zernike_columns!(@k $L, $E, $V, $NORM, $s, $u, $v, $out, $mv, q_prev, q_cur;
318                    2 3 4 5 6 7 8);
319            }
320
321            if $mv < $L {
322                // (u, v) *= (x + iy)
323                let u_next = $x.difference_of_products($u, $y, $v);
324                let v_next = $x.sum_of_products($v, $y, $u);
325                $u = u_next;
326                $v = v_next;
327            }
328        }
329    )* };
330
331    (@k $L:ident, $E:ident, $V:ident, $NORM:ident, $s:ident, $u:ident, $v:ident, $out:ident,
332     $mv:literal, $qp:ident, $qc:ident; $($kv:literal)*) => { $(
333        if $mv + 2 * $kv <= $L {
334            // Literal (k, m), so `q_coeffs` folds to three `.rodata` constants.
335            let (ae, be, ce) = q_coeffs::<$E>($kv, $mv);
336
337            let a = $V::splat(ae);
338            let b = $V::splat(be);
339            let c = $V::splat(ce);
340
341            let q_next = $s.mul_adde(a, b).mul_sube($qc, c * $qp);
342
343
344            emit::<$E, $V, _, $NORM>($out, $mv + 2 * $kv, $mv, q_next, $u, $v);
345
346            $qp = $qc;
347            $qc = q_next;
348        }
349    )* };
350}
351
352/// The gradient ladder: the same columns carrying `(Q, dQ/ds)` and a one-rung window on
353/// the complex power ladder.
354///
355/// Kept separate from [`zernike_columns`] rather than parameterized by a sink, on the
356/// same judgement the SH kernel records: the duplication is short and the parameterized
357/// form costs far more in readability. Unlike `sh_d_impl` this needs no scratch array and
358/// no second pass: differentiating the `Q` recurrence gives another recurrence of the
359/// same shape, so value and slope advance together in one sweep.
360macro_rules! zernike_grad_columns {
361    ($L:ident, $E:ident, $V:ident, $NORM:ident, $s:ident, $t:ident, $x:ident, $y:ident,
362     $out:ident, $ddx:ident, $ddy:ident) => {
363        // (u, v) at column m, (up, vp) at column m - 1. The m = 0 column never reads the
364        // window, since its azimuthal factor is the constant 1.
365        let mut u = $V::ONE;
366        let mut v = $V::ZERO;
367        let mut up = $V::ZERO;
368        let mut vp = $V::ZERO;
369
370        zernike_grad_columns!(@m $L, $E, $V, $NORM, $s, $t, $x, $y, u, v, up, vp, $out, $ddx, $ddy;
371            0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16);
372    };
373
374    (@m $L:ident, $E:ident, $V:ident, $NORM:ident, $s:ident, $t:ident, $x:ident, $y:ident,
375     $u:ident, $v:ident, $up:ident, $vp:ident, $out:ident, $ddx:ident, $ddy:ident;
376     $($mv:literal)*) => { $(
377        if $mv <= $L {
378            // k = 0: Q = 1, dQ/ds = 0.
379            emit_d::<$E, $V, _, $NORM>(
380                $out, $ddx, $ddy, $mv, $mv, $t, ($V::ONE, $V::ZERO), ($u, $v), ($up, $vp),
381            );
382
383            if $mv + 2 <= $L {
384                // k = 1: Q = (m+2)s - (m+1), dQ/ds = m+2.
385                let mut q_prev = $V::ONE;
386                let mut d_prev = $V::ZERO;
387
388                let slope = $V::splat($E::from_int($mv + 2));
389
390                let mut q_cur = $s.mul_sube(slope, $V::splat($E::from_int($mv + 1)));
391                let mut d_cur = slope;
392
393                emit_d::<$E, $V, _, $NORM>(
394                    $out, $ddx, $ddy, $mv + 2, $mv, $t, (q_cur, d_cur), ($u, $v), ($up, $vp),
395                );
396
397                zernike_grad_columns!(@k $L, $E, $V, $NORM, $s, $t, $u, $v, $up, $vp,
398                    $out, $ddx, $ddy, $mv, q_prev, q_cur, d_prev, d_cur; 2 3 4 5 6 7 8);
399            }
400
401            if $mv < $L {
402                // (up, vp) = (u, v); (u, v) *= (x + iy)
403                let u_next = $x.difference_of_products($u, $y, $v);
404                let v_next = $x.sum_of_products($v, $y, $u);
405
406                $up = $u;
407                $vp = $v;
408                $u = u_next;
409                $v = v_next;
410            }
411        }
412    )* };
413
414    (@k $L:ident, $E:ident, $V:ident, $NORM:ident, $s:ident, $t:ident,
415     $u:ident, $v:ident, $up:ident, $vp:ident, $out:ident, $ddx:ident, $ddy:ident,
416     $mv:literal, $qp:ident, $qc:ident, $dp:ident, $dc:ident; $($kv:literal)*) => { $(
417        if $mv + 2 * $kv <= $L {
418            let (ae, be, ce) = q_coeffs::<$E>($kv, $mv);
419
420            let a = $V::splat(ae);
421            let b = $V::splat(be);
422            let c = $V::splat(ce);
423
424            let lin = $s.mul_adde(a, b);
425
426            // Q_k   = (As + B) Q_{k-1} - C Q_{k-2}
427            // Q'_k  = A Q_{k-1} + (As + B) Q'_{k-1} - C Q'_{k-2}
428            let q_next = lin.mul_sube($qc, c * $qp);
429            let d_next = a.mul_adde($qc, lin.mul_sube($dc, c * $dp));
430
431            emit_d::<$E, $V, _, $NORM>(
432                $out, $ddx, $ddy, $mv + 2 * $kv, $mv, $t, (q_next, d_next), ($u, $v), ($up, $vp),
433            );
434
435            $qp = $qc;
436            $qc = q_next;
437            $dp = $dc;
438            $dc = d_next;
439        }
440    )* };
441}
442
443/// Every Zernike mode through degree `L` at the Cartesian point `(x, y)`.
444///
445/// `out[(n(n+2) + m)/2]` receives `$Z_n^m$` in the normalization named by `NORM`, and `N`
446/// must equal `(L+1)(L+2)/2` (compile-time checked). See the module docs for the
447/// algorithm and the domain note.
448///
449/// The policy parameter is unused: evaluation is pure polynomial arithmetic with one
450/// fixed FMA-preferring lowering. It is accepted so the signature matches its siblings.
451// The `unused_comparisons`/`unused_assignments` allows are ladder artifacts, exactly as
452// in `sh`: the `0 <= L` guard of the first stamped column, and the dead state hand-off
453// of the last stamped row.
454#[allow(
455    clippy::extra_unused_type_parameters,
456    clippy::int_plus_one,
457    unused_comparisons,
458    unused_assignments
459)]
460#[inline(always)]
461pub fn zernike_basis_impl<P, E, V, const L: usize, const NORM: u8, const N: usize>(x: V, y: V, out: &mut [V; N])
462where
463    P: Policy,
464    E: FloatElement,
465    V: FloatVector<Element = E> + CoreMath,
466{
467    const {
468        assert!(N == (L + 1) * (L + 2) / 2, "zernike_basis: N must equal (L+1)(L+2)/2");
469        assert!(
470            NORM == ZERNIKE_UNIT_PEAK || NORM == ZERNIKE_ORTHONORMAL,
471            "zernike_basis: NORM must be ZERNIKE_UNIT_PEAK or ZERNIKE_ORTHONORMAL"
472        );
473    }
474
475    let s = x.mul_adde(x, y * y);
476
477    // Above the stamped ladder there is no unrolled code to run, so this delegates to
478    // the rolled path rather than silently leaving the high degrees unwritten. As in
479    // `sh_impl`, the guard cannot live in the caller: rustc monomorphizes both arms of
480    // an `if const` over a generic const parameter.
481    if const { L > MAX_DEGREE } {
482        rolled::<E, V, L, NORM, N>(s, x, y, out);
483        return;
484    }
485
486    zernike_columns!(L, E, V, NORM, s, x, y, out);
487}
488
489/// The same recurrence with runtime coefficients and rolled loops, for `L > MAX_DEGREE`.
490///
491/// Correct at any degree and considerably slower: the coefficients are divisions rather
492/// than folded constants, and the index arithmetic survives to runtime.
493#[inline(always)]
494fn rolled<E, V, const L: usize, const NORM: u8, const N: usize>(s: V, x: V, y: V, out: &mut [V; N])
495where
496    E: FloatElement,
497    V: FloatVector<Element = E> + CoreMath,
498{
499    let mut u = V::ONE;
500    let mut v = V::ZERO;
501
502    let mut m = 0;
503    while m <= L {
504        emit::<E, V, N, NORM>(out, m, m, V::ONE, u, v);
505
506        if m + 2 <= L {
507            let mf = m as thermite::LargeInt;
508
509            let mut q_prev = V::ONE;
510            let mut q_cur = s.mul_sube(V::splat(E::from_int(mf + 2)), V::splat(E::from_int(mf + 1)));
511
512            emit::<E, V, N, NORM>(out, m + 2, m, q_cur, u, v);
513
514            let mut k = 2;
515            while m + 2 * k <= L {
516                let (a, b, c) = q_coeffs::<E>(k as thermite::LargeInt, mf);
517
518                let q_next = s
519                    .mul_adde(V::splat(a), V::splat(b))
520                    .mul_sube(q_cur, V::splat(c) * q_prev);
521
522                emit::<E, V, N, NORM>(out, m + 2 * k, m, q_next, u, v);
523
524                q_prev = q_cur;
525                q_cur = q_next;
526
527                k += 1;
528            }
529        }
530
531        if m < L {
532            let u_next = x.difference_of_products(u, y, v);
533            let v_next = x.sum_of_products(v, y, u);
534            u = u_next;
535            v = v_next;
536        }
537
538        m += 1;
539    }
540}
541
542/// [`zernike_basis_impl`] plus the Cartesian gradient of every mode.
543///
544/// `out` receives the values exactly as [`zernike_basis_impl`] produces them, and
545/// `ddx`/`ddy` receive `$\partial Z_n^m/\partial\{x,y\}$` at the same point.
546///
547/// This is what a Shack-Hartmann reconstruction integrates against: the sensor measures
548/// wavefront *slopes*, so the fit matrix is built from the gradient basis rather than the
549/// value basis. Prefer it over seeding a `Dual<V, 2>` and calling the value form, which
550/// carries two derivative components through every operation of the whole ladder, where
551/// this shares the `Q` recurrence between the value and both gradients and differentiates
552/// only the two factors that actually depend on the point.
553///
554/// The gradient is finite everywhere including the pupil centre, which is the practical
555/// payoff of the Cartesian formulation: the polar `$\partial_\theta Z/\rho$` is singular
556/// there.
557#[allow(
558    clippy::extra_unused_type_parameters,
559    clippy::int_plus_one,
560    unused_comparisons,
561    unused_assignments
562)]
563#[inline(always)]
564pub fn zernike_basis_d_impl<P, E, V, const L: usize, const NORM: u8, const N: usize>(
565    x: V,
566    y: V,
567    out: &mut [V; N],
568    ddx: &mut [V; N],
569    ddy: &mut [V; N],
570) where
571    P: Policy,
572    E: FloatElement,
573    V: FloatVector<Element = E> + CoreMath,
574{
575    const {
576        assert!(N == (L + 1) * (L + 2) / 2, "zernike_basis_d: N must equal (L+1)(L+2)/2");
577        assert!(
578            NORM == ZERNIKE_UNIT_PEAK || NORM == ZERNIKE_ORTHONORMAL,
579            "zernike_basis_d: NORM must be ZERNIKE_UNIT_PEAK or ZERNIKE_ORTHONORMAL"
580        );
581    }
582
583    let s = x.mul_adde(x, y * y);
584
585    // ds/dx and ds/dy, hoisted: every mode's radial half is scaled by these.
586    let t = (x + x, y + y);
587
588    if const { L > MAX_DEGREE } {
589        rolled_d::<E, V, L, NORM, N>(s, t, x, y, out, ddx, ddy);
590        return;
591    }
592
593    zernike_grad_columns!(L, E, V, NORM, s, t, x, y, out, ddx, ddy);
594}
595
596/// The gradient kernel with runtime coefficients and rolled loops, for `L > MAX_DEGREE`.
597#[allow(clippy::too_many_arguments)]
598#[inline(always)]
599fn rolled_d<E, V, const L: usize, const NORM: u8, const N: usize>(
600    s: V,
601    t: (V, V),
602    x: V,
603    y: V,
604    out: &mut [V; N],
605    ddx: &mut [V; N],
606    ddy: &mut [V; N],
607) where
608    E: FloatElement,
609    V: FloatVector<Element = E> + CoreMath,
610{
611    let mut u = V::ONE;
612    let mut v = V::ZERO;
613    let mut up = V::ZERO;
614    let mut vp = V::ZERO;
615
616    let mut m = 0;
617    while m <= L {
618        emit_d::<E, V, N, NORM>(out, ddx, ddy, m, m, t, (V::ONE, V::ZERO), (u, v), (up, vp));
619
620        if m + 2 <= L {
621            let mf = m as thermite::LargeInt;
622
623            let mut q_prev = V::ONE;
624            let mut d_prev = V::ZERO;
625
626            let slope = V::splat(E::from_int(mf + 2));
627
628            let mut q_cur = s.mul_sube(slope, V::splat(E::from_int(mf + 1)));
629            let mut d_cur = slope;
630
631            emit_d::<E, V, N, NORM>(out, ddx, ddy, m + 2, m, t, (q_cur, d_cur), (u, v), (up, vp));
632
633            let mut k = 2;
634            while m + 2 * k <= L {
635                let (ae, be, ce) = q_coeffs::<E>(k as thermite::LargeInt, mf);
636
637                let a = V::splat(ae);
638                let c = V::splat(ce);
639
640                let lin = s.mul_adde(a, V::splat(be));
641
642                let q_next = lin.mul_sube(q_cur, c * q_prev);
643                let d_next = a.mul_adde(q_cur, lin.mul_sube(d_cur, c * d_prev));
644
645                emit_d::<E, V, N, NORM>(out, ddx, ddy, m + 2 * k, m, t, (q_next, d_next), (u, v), (up, vp));
646
647                q_prev = q_cur;
648                q_cur = q_next;
649                d_prev = d_cur;
650                d_cur = d_next;
651
652                k += 1;
653            }
654        }
655
656        if m < L {
657            let u_next = x.difference_of_products(u, y, v);
658            let v_next = x.sum_of_products(v, y, u);
659
660            up = u;
661            vp = v;
662            u = u_next;
663            v = v_next;
664        }
665
666        m += 1;
667    }
668}
Last built: 2026-09-08 21:35:55 UTC