use serde::{Deserialize, Serialize};
use std::f64::consts::PI;
#[must_use]
#[inline]
pub fn radial_polynomial(n: u32, m_abs: u32, rho: f64) -> f64 {
if (n < m_abs) || !(n - m_abs).is_multiple_of(2) {
return 0.0;
}
let half = ((n - m_abs) / 2) as usize;
let mut sum = 0.0;
for s in 0..=half {
let sign = if s.is_multiple_of(2) { 1.0 } else { -1.0 };
let num = factorial((n as usize) - s);
let d1 = factorial(s);
let d2 = factorial(((n + m_abs) / 2) as usize - s);
let d3 = factorial(half - s);
sum +=
sign * (num as f64) / ((d1 * d2 * d3) as f64) * rho.powi((n as i32) - 2 * (s as i32));
}
sum
}
#[inline]
fn factorial(n: usize) -> u64 {
match n {
0 | 1 => 1,
2 => 2,
3 => 6,
4 => 24,
5 => 120,
6 => 720,
7 => 5040,
8 => 40320,
9 => 362880,
10 => 3628800,
11 => 39916800,
12 => 479001600,
13 => 6227020800,
14 => 87178291200,
15 => 1307674368000,
16 => 20922789888000,
17 => 355687428096000,
18 => 6402373705728000,
19 => 121645100408832000,
20 => 2432902008176640000,
_ => (2..=n as u64).product(),
}
}
#[must_use]
#[inline]
pub fn zernike(n: u32, m: i32, rho: f64, theta: f64) -> f64 {
if rho > 1.0 {
return 0.0;
}
let m_abs = m.unsigned_abs();
let r = radial_polynomial(n, m_abs, rho);
let norm = if m == 0 {
((n + 1) as f64).sqrt()
} else {
(2.0 * (n + 1) as f64).sqrt()
};
let angular = if m > 0 {
(m_abs as f64 * theta).cos()
} else if m < 0 {
(m_abs as f64 * theta).sin()
} else {
1.0
};
norm * r * angular
}
#[must_use]
pub fn noll_to_nm(j: u32) -> (u32, i32) {
if j == 0 {
return (0, 0);
}
let mut n = 0u32;
while (n + 1) * (n + 2) / 2 < j {
n += 1;
}
let k = j - n * (n + 1) / 2 - 1;
let m_abs = if n.is_multiple_of(2) {
if k == 0 { 0 } else { k.div_ceil(2) * 2 }
} else {
(k / 2) * 2 + 1
};
let m = if m_abs == 0 {
0i32
} else if j.is_multiple_of(2) {
m_abs as i32
} else {
-(m_abs as i32)
};
(n, m)
}
#[must_use]
pub fn nm_to_noll(n: u32, m: i32) -> u32 {
let m_abs = m.unsigned_abs();
let base = n * (n + 1) / 2 + 1;
let slot_start = if n.is_multiple_of(2) {
if m_abs == 0 { 0 } else { 2 * (m_abs / 2) - 1 }
} else {
2 * ((m_abs - 1) / 2)
};
if m_abs == 0 {
return base;
}
let j_first = base + slot_start;
if m > 0 {
if j_first.is_multiple_of(2) {
j_first
} else {
j_first + 1
}
} else {
if j_first % 2 == 1 {
j_first
} else {
j_first + 1
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ZernikeWavefront {
pub coefficients: Vec<f64>,
}
impl ZernikeWavefront {
#[must_use]
pub fn new(coefficients: Vec<f64>) -> Self {
Self { coefficients }
}
#[must_use]
pub fn flat() -> Self {
Self {
coefficients: Vec::new(),
}
}
#[must_use]
#[inline]
pub fn evaluate(&self, rho: f64, theta: f64) -> f64 {
if rho > 1.0 {
return 0.0;
}
let mut w = 0.0;
for (idx, &coeff) in self.coefficients.iter().enumerate() {
if coeff.abs() < 1e-15 {
continue; }
let j = (idx + 1) as u32;
let (n, m) = noll_to_nm(j);
w += coeff * zernike(n, m, rho, theta);
}
w
}
#[must_use]
pub fn to_grid(&self, grid_size: usize) -> Vec<f64> {
let mut grid = vec![0.0; grid_size * grid_size];
let center = (grid_size as f64 - 1.0) / 2.0;
for iy in 0..grid_size {
for ix in 0..grid_size {
let x = (ix as f64 - center) / center;
let y = (iy as f64 - center) / center;
let rho = (x * x + y * y).sqrt();
if rho <= 1.0 {
let theta = y.atan2(x);
grid[iy * grid_size + ix] = self.evaluate(rho, theta);
}
}
}
grid
}
#[must_use]
pub fn rms_error(&self) -> f64 {
self.coefficients
.iter()
.skip(3)
.map(|c| c * c)
.sum::<f64>()
.sqrt()
}
#[must_use]
pub fn peak_to_valley(&self, samples: usize) -> f64 {
let mut min = f64::INFINITY;
let mut max = f64::NEG_INFINITY;
let center = (samples as f64 - 1.0) / 2.0;
for iy in 0..samples {
for ix in 0..samples {
let x = (ix as f64 - center) / center;
let y = (iy as f64 - center) / center;
let rho = (x * x + y * y).sqrt();
if rho <= 1.0 {
let theta = y.atan2(x);
let w = self.evaluate(rho, theta);
if w < min {
min = w;
}
if w > max {
max = w;
}
}
}
}
if min.is_finite() && max.is_finite() {
max - min
} else {
0.0
}
}
#[must_use]
#[inline]
pub fn strehl_ratio(&self, wavelength: f64) -> f64 {
let sigma = self.rms_error() / wavelength;
let x = 2.0 * PI * sigma;
(-x * x).exp()
}
}
#[must_use]
pub fn defocus(amount: f64) -> ZernikeWavefront {
let mut coeffs = vec![0.0; 4];
coeffs[3] = amount; ZernikeWavefront::new(coeffs)
}
#[must_use]
pub fn spherical(amount: f64) -> ZernikeWavefront {
let mut coeffs = vec![0.0; 11];
coeffs[10] = amount; ZernikeWavefront::new(coeffs)
}
#[must_use]
pub fn coma_x(amount: f64) -> ZernikeWavefront {
let mut coeffs = vec![0.0; 8];
coeffs[7] = amount; ZernikeWavefront::new(coeffs)
}
#[must_use]
pub fn coma_y(amount: f64) -> ZernikeWavefront {
let mut coeffs = vec![0.0; 7];
coeffs[6] = amount; ZernikeWavefront::new(coeffs)
}
#[must_use]
pub fn astigmatism_0(amount: f64) -> ZernikeWavefront {
let mut coeffs = vec![0.0; 6];
coeffs[5] = amount; ZernikeWavefront::new(coeffs)
}
#[must_use]
pub fn astigmatism_45(amount: f64) -> ZernikeWavefront {
let mut coeffs = vec![0.0; 5];
coeffs[4] = amount; ZernikeWavefront::new(coeffs)
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::FRAC_PI_4;
const EPS: f64 = 1e-6;
#[test]
fn test_radial_piston() {
assert!((radial_polynomial(0, 0, 0.0) - 1.0).abs() < EPS);
assert!((radial_polynomial(0, 0, 0.5) - 1.0).abs() < EPS);
assert!((radial_polynomial(0, 0, 1.0) - 1.0).abs() < EPS);
}
#[test]
fn test_radial_tilt() {
assert!((radial_polynomial(1, 1, 0.0)).abs() < EPS);
assert!((radial_polynomial(1, 1, 0.5) - 0.5).abs() < EPS);
assert!((radial_polynomial(1, 1, 1.0) - 1.0).abs() < EPS);
}
#[test]
fn test_radial_defocus() {
assert!((radial_polynomial(2, 0, 0.0) + 1.0).abs() < EPS);
assert!((radial_polynomial(2, 0, 1.0) - 1.0).abs() < EPS);
let mid = 1.0 / 2.0f64.sqrt(); assert!((radial_polynomial(2, 0, mid)).abs() < 0.01);
}
#[test]
fn test_radial_spherical() {
assert!((radial_polynomial(4, 0, 0.0) - 1.0).abs() < EPS);
assert!((radial_polynomial(4, 0, 1.0) - 1.0).abs() < EPS);
}
#[test]
fn test_radial_invalid_nm() {
assert!((radial_polynomial(1, 2, 0.5)).abs() < EPS);
assert!((radial_polynomial(3, 2, 0.5)).abs() < EPS);
}
#[test]
fn test_zernike_piston() {
let z = zernike(0, 0, 0.5, 0.0);
assert!((z - 1.0).abs() < EPS);
}
#[test]
fn test_zernike_tilt_x() {
let z = zernike(1, 1, 1.0, 0.0);
assert!((z - 2.0).abs() < EPS); }
#[test]
fn test_zernike_tilt_y() {
let z = zernike(1, -1, 1.0, PI / 2.0);
assert!((z - 2.0).abs() < EPS); }
#[test]
fn test_zernike_defocus() {
let z_center = zernike(2, 0, 0.0, 0.0);
assert!((z_center + 3.0f64.sqrt()).abs() < EPS); let z_edge = zernike(2, 0, 1.0, 0.0);
assert!((z_edge - 3.0f64.sqrt()).abs() < EPS); }
#[test]
fn test_zernike_outside_pupil() {
assert!((zernike(2, 0, 1.5, 0.0)).abs() < EPS);
}
#[test]
fn test_zernike_orthogonality_low_order() {
let n = 100;
let mut sum_11 = 0.0;
let mut sum_14 = 0.0;
let mut sum_44 = 0.0;
let da = 4.0 / (n * n) as f64;
for iy in 0..n {
for ix in 0..n {
let x = (ix as f64 + 0.5) / (n as f64 / 2.0) - 1.0;
let y = (iy as f64 + 0.5) / (n as f64 / 2.0) - 1.0;
let rho = (x * x + y * y).sqrt();
if rho <= 1.0 {
let theta = y.atan2(x);
let z1 = zernike(0, 0, rho, theta);
let z4 = zernike(2, 0, rho, theta);
sum_11 += z1 * z1 * da;
sum_14 += z1 * z4 * da;
sum_44 += z4 * z4 * da;
}
}
}
assert!(
(sum_11 - PI).abs() < 0.1,
"Z1·Z1 should integrate to π, got {sum_11}"
);
assert!(
sum_14.abs() < 0.1,
"Z1·Z4 should integrate to 0, got {sum_14}"
);
assert!(
(sum_44 - PI).abs() < 0.1,
"Z4·Z4 should integrate to π, got {sum_44}"
);
}
#[test]
fn test_noll_first_terms() {
assert_eq!(noll_to_nm(1), (0, 0)); assert_eq!(noll_to_nm(2), (1, 1)); assert_eq!(noll_to_nm(3), (1, -1)); assert_eq!(noll_to_nm(4), (2, 0)); assert_eq!(noll_to_nm(5), (2, -2)); assert_eq!(noll_to_nm(6), (2, 2)); assert_eq!(noll_to_nm(7), (3, -1)); assert_eq!(noll_to_nm(8), (3, 1)); assert_eq!(noll_to_nm(11), (4, 0)); }
#[test]
fn test_noll_roundtrip() {
for j in 1..=11 {
let (n, m) = noll_to_nm(j);
let back = nm_to_noll(n, m);
assert_eq!(back, j, "Noll roundtrip failed: j={j} → ({n},{m}) → {back}");
}
}
#[test]
fn test_wavefront_flat() {
let w = ZernikeWavefront::flat();
assert!((w.evaluate(0.5, 0.0)).abs() < EPS);
assert!((w.rms_error()).abs() < EPS);
}
#[test]
fn test_wavefront_defocus() {
let w = defocus(0.5);
let center = w.evaluate(0.0, 0.0);
let edge = w.evaluate(1.0, 0.0);
assert!(center < 0.0, "Defocus should be negative at center");
assert!(edge > 0.0, "Defocus should be positive at edge");
}
#[test]
fn test_wavefront_rms() {
let w = defocus(0.1);
let rms = w.rms_error();
assert!(
(rms - 0.1).abs() < EPS,
"RMS of defocus 0.1 should be 0.1, got {rms}"
);
}
#[test]
fn test_wavefront_rms_excludes_piston_tilt() {
let mut coeffs = vec![1.0, 0.5, 0.5, 0.0]; let w = ZernikeWavefront::new(coeffs.clone());
assert!(
w.rms_error().abs() < EPS,
"Piston+tilt should have zero RMS"
);
coeffs.push(0.0); coeffs[3] = 0.2; let w2 = ZernikeWavefront::new(coeffs);
assert!((w2.rms_error() - 0.2).abs() < EPS);
}
#[test]
fn test_wavefront_strehl_diffraction_limited() {
let w = ZernikeWavefront::flat();
let s = w.strehl_ratio(550e-9);
assert!((s - 1.0).abs() < EPS, "Flat wavefront should have Strehl=1");
}
#[test]
fn test_wavefront_strehl_marechal() {
let wl = 550e-9;
let rms = wl / 14.0;
let mut coeffs = vec![0.0; 11];
coeffs[10] = rms; let w = ZernikeWavefront::new(coeffs);
let s = w.strehl_ratio(wl);
assert!(
(s - 0.8).abs() < 0.05,
"Marechal criterion: Strehl ≈ 0.8, got {s}"
);
}
#[test]
fn test_wavefront_to_grid() {
let w = defocus(0.1);
let grid = w.to_grid(32);
assert_eq!(grid.len(), 32 * 32);
let center = grid[16 * 32 + 16];
let corner = grid[0]; assert!(center != 0.0, "Center should have nonzero wavefront");
assert!(corner.abs() < EPS, "Corner should be zero (outside pupil)");
}
#[test]
fn test_wavefront_pv() {
let w = defocus(0.5);
let pv = w.peak_to_valley(64);
assert!(pv > 0.0, "P-V should be positive");
let expected = 2.0 * 0.5 * 3.0f64.sqrt();
assert!(
(pv - expected).abs() < 0.1,
"Defocus PV ≈ {expected}, got {pv}"
);
}
#[test]
fn test_named_aberrations() {
let aberrations = [
defocus(1.0),
spherical(1.0),
coma_x(1.0),
coma_y(1.0),
astigmatism_0(1.0),
astigmatism_45(1.0),
];
for w in &aberrations {
let nonzero: usize = w.coefficients.iter().filter(|c| c.abs() > EPS).count();
assert_eq!(
nonzero, 1,
"Named aberration should have exactly 1 nonzero coefficient"
);
}
}
#[test]
fn test_coma_symmetry() {
let w = coma_x(0.5);
let v1 = w.evaluate(0.7, FRAC_PI_4);
let v2 = w.evaluate(0.7, -FRAC_PI_4);
assert!((v1 - v2).abs() < EPS, "Coma X should be x-axis symmetric");
}
}