Skip to main content

thermite_special/
zernike.rs

1//! Zernike ordering conventions: normalization flags and the three single-index schemes.
2//!
3//! The polynomials themselves are [`SpecialMath::zernike`](crate::SpecialMath::zernike)
4//! and [`zernike_r`](crate::SpecialMath::zernike_r). What lives here is everything
5//! *around* them, which in practice is where the errors are.
6//!
7//! A Zernike mode is named by two integers: the radial degree `n >= 0` and the
8//! azimuthal frequency `m`, with `|m| <= n` and `n - |m|` even. Every application
9//! flattens that pair into a single running index, and there are three incompatible
10//! ways to do it, all in current use:
11//!
12//! | Scheme | First index | Ordering within a degree | Found in |
13//! |---|---|---|---|
14//! | ANSI Z80.28 / OSA | 0 | `m` ascending from `-n` to `+n` | ophthalmology, most Python tooling |
15//! | Noll | 1 | `\|m\|` ascending, sign alternating by `n mod 4` | astronomy, Zemax "Standard" coefficients |
16//! | Fringe (Air Force / Arizona) | 1 | by spatial frequency `n + \|m\|`, cosine before sine | interferometry, Zemax "Fringe" coefficients |
17//!
18//! Handing a Noll-indexed coefficient vector to ANSI-indexed code produces a
19//! plausible-looking wavefront that is wrong from the second term on, and nothing in
20//! the numbers announces it. Converting explicitly at the boundary is the fix, which is
21//! why these are here rather than left to the caller.
22//!
23//! Normalization is the second, independent axis. [`ZERNIKE_UNIT_PEAK`] leaves the
24//! radial polynomial alone, so every mode has `R_n^m(1) = 1` and coefficients read as
25//! peak wavefront amplitude. [`ZERNIKE_ORTHONORMAL`] applies
26//! `$N_n^m = \sqrt{2(n+1)/(1 + \delta_{m,0})}$`, making the modes orthonormal on the
27//! unit disc under the `$1/\pi$`-weighted inner product, so a coefficient is an RMS
28//! contribution and the total wavefront RMS is the root-sum-square of them. Both the
29//! ANSI and Noll standards specify the orthonormal form, while unit-peak is what most
30//! hand-rolled shader and interferometer code produces. There is no safe default, so
31//! the choice is a required const generic rather than a flag with an opinion.
32//!
33//! All conversions here are `const fn` over plain integers. They are configuration,
34//! evaluated once per mode and not per sample, and never belong in a vector loop.
35
36/// Leave the radial polynomial unnormalized: `$R_n^m(1) = 1$` for every mode.
37///
38/// Coefficients then read as peak wavefront amplitude in whatever unit `rho` is
39/// measured against. See the [module docs](self) for the trade against
40/// [`ZERNIKE_ORTHONORMAL`].
41pub const ZERNIKE_UNIT_PEAK: u8 = 0;
42
43/// Scale each mode by `$\sqrt{2(n+1)/(1 + \delta_{m,0})}$`, the ANSI Z80.28 and Noll
44/// normalization.
45///
46/// The modes are then orthonormal on the unit disc, so a coefficient is that mode's RMS
47/// contribution and the wavefront RMS is the root-sum-square of the coefficients.
48pub const ZERNIKE_ORTHONORMAL: u8 = 1;
49
50/// Whether `(n, m)` names a real Zernike mode: `$|m| \le n$` with `$n - |m|$` even.
51///
52/// Every evaluator here returns zero for a pair that fails this, rather than an
53/// arbitrary value from a recurrence run outside its range.
54#[inline]
55pub const fn is_valid(n: u32, m: i32) -> bool {
56    let am = m.unsigned_abs();
57    am <= n && (n - am).is_multiple_of(2)
58}
59
60/// The number of Zernike modes with radial degree at most `n`, i.e. `(n+1)(n+2)/2`.
61///
62/// This is the length of a full ANSI-indexed coefficient vector truncated at degree `n`,
63/// and one past the largest valid ANSI index.
64#[inline]
65pub const fn count_up_to_degree(n: u32) -> u32 {
66    (n + 1) * (n + 2) / 2
67}
68
69// --- ANSI Z80.28 / OSA, zero-based ---
70
71/// The ANSI Z80.28 / OSA single index of mode `(n, m)`: `$j = (n(n+2) + m)/2$`, from 0.
72#[inline]
73pub const fn ansi_index(n: u32, m: i32) -> u32 {
74    ((n * (n + 2)) as i32 + m) as u32 / 2
75}
76
77/// The mode `(n, m)` carrying ANSI index `j`. Inverse of [`ansi_index`].
78#[inline]
79pub const fn ansi_to_nm(j: u32) -> (u32, i32) {
80    // Degree n occupies j in [n(n+1)/2, n(n+3)/2], so n is the largest degree whose
81    // block starts at or before j. Counted rather than solved: the closed form needs a
82    // square root, and an integer loop of at most O(sqrt(j)) steps runs at compile time.
83    let mut n = 0;
84    while (n + 1) * (n + 2) / 2 <= j {
85        n += 1;
86    }
87
88    (n, 2 * j as i32 - (n * (n + 2)) as i32)
89}
90
91// --- Noll, one-based ---
92
93/// The Noll single index of mode `(n, m)`, from 1.
94///
95/// `$j = n(n+1)/2 + |m| + c$`, where the parity correction `c` alternates which of the
96/// `$\pm m$` pair comes first with `n mod 4`. That alternation is the whole reason Noll
97/// indexing cannot be computed from `|m|` alone, and the usual place conversions go
98/// wrong.
99#[inline]
100pub const fn noll_index(n: u32, m: i32) -> u32 {
101    let am = m.unsigned_abs();
102
103    // Cosine-first for n mod 4 in {0, 1}, sine-first for {2, 3}.
104    let cosine_first = n % 4 <= 1;
105    let is_cosine = m >= 0;
106
107    let c = if is_cosine == cosine_first && m != 0 { 0 } else { 1 };
108
109    n * (n + 1) / 2 + am + c
110}
111
112/// The mode `(n, m)` carrying Noll index `j`. Inverse of [`noll_index`].
113///
114/// # Panics
115/// If `j` is zero. Noll indices start at 1, and there is no mode 0 to return.
116#[inline]
117pub const fn noll_to_nm(j: u32) -> (u32, i32) {
118    assert!(j > 0, "Noll indices are one-based; there is no mode 0");
119
120    // Degree n owns the n+1 indices starting at n(n+1)/2 + 1.
121    let mut n = 0;
122    while (n + 1) * (n + 2) / 2 < j {
123        n += 1;
124    }
125
126    // Position within the degree block. Within it |m| ascends by 2 from n's parity,
127    // each nonzero |m| appearing twice (once per sign).
128    let r = j - n * (n + 1) / 2 - 1;
129
130    let am = if n.is_multiple_of(2) {
131        2 * r.div_ceil(2)
132    } else {
133        2 * (r / 2) + 1
134    };
135
136    // The sign is decided by the same parity rule as `noll_index`, so ask it rather
137    // than restate it: whichever sign round-trips is the answer.
138    let m = am as i32;
139
140    if noll_index(n, m) == j { (n, m) } else { (n, -m) }
141}
142
143/// The ANSI index of the mode carrying Noll index `j`.
144///
145/// The gather a Noll-indexed caller needs against an ANSI-laid-out basis buffer, which
146/// is what [`zernike_basis`](crate::SpecialMath::zernike_basis) fills. Exists as one
147/// function because the composition `ansi_index(noll_to_nm(j))` is short enough to write
148/// by hand at every call site and exactly the kind of thing that gets written backwards.
149///
150/// # Panics
151/// If `j` is zero. Noll indices start at 1.
152#[inline]
153pub const fn noll_to_ansi(j: u32) -> u32 {
154    let (n, m) = noll_to_nm(j);
155    ansi_index(n, m)
156}
157
158// --- Fringe / Air Force / University of Arizona, one-based ---
159
160/// The Fringe (Air Force / Arizona) single index of mode `(n, m)`, from 1.
161///
162/// `$j = (1 + (n + |m|)/2)^2 - 2|m| + [m < 0]$`. Ordering is by spatial frequency
163/// `$n + |m|$` rather than by radial degree, which is why Fringe truncations (the
164/// classic 37-term set) keep low-frequency high-degree terms that an ANSI truncation at
165/// the same count would drop.
166#[inline]
167pub const fn fringe_index(n: u32, m: i32) -> u32 {
168    let am = m.unsigned_abs();
169    let s = 1 + (n + am) / 2;
170
171    s * s - 2 * am + if m < 0 { 1 } else { 0 }
172}
173
174/// The mode `(n, m)` carrying Fringe index `j`. Inverse of [`fringe_index`].
175///
176/// # Panics
177/// If `j` is zero. Fringe indices start at 1.
178#[inline]
179pub const fn fringe_to_nm(j: u32) -> (u32, i32) {
180    assert!(j > 0, "Fringe indices are one-based; there is no mode 0");
181
182    // Frequency group q = (n + |m|)/2 owns exactly j in [q^2 + 1, (q+1)^2], so the group
183    // falls straight out of an integer square root and the rest is arithmetic.
184    let mut q = 0;
185    while (q + 1) * (q + 1) < j {
186        q += 1;
187    }
188
189    // Within the group, j = (q+1)^2 - 2|m| + [m < 0], so the offset from the group's top
190    // carries both |m| and the sign in its low bit.
191    let t = (q + 1) * (q + 1) - j;
192
193    let am = t.div_ceil(2);
194    let m = if t.is_multiple_of(2) { am as i32 } else { -(am as i32) };
195
196    (2 * q - am, m)
197}
198
199/// The ANSI index of the mode carrying Fringe index `j`.
200///
201/// The Fringe counterpart of [`noll_to_ansi`], and the more valuable of the two: Fringe
202/// orders by spatial frequency rather than radial degree, so the mapping reorders modes
203/// rather than merely renumbering them, and no amount of staring at a coefficient vector
204/// reveals a missing conversion.
205///
206/// Note that a Fringe index can name a mode of higher radial degree than an ANSI
207/// truncation of the same length contains (Fringe 9 is `(4, 0)`, ANSI index 12), so
208/// check the result against the basis length rather than assuming it fits.
209///
210/// # Panics
211/// If `j` is zero. Fringe indices start at 1.
212#[inline]
213pub const fn fringe_to_ansi(j: u32) -> u32 {
214    let (n, m) = fringe_to_nm(j);
215    ansi_index(n, m)
216}
Last built: 2026-09-08 21:35:55 UTC