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}