use std::f64::consts::PI;
pub struct ZernikePolynomial;
impl ZernikePolynomial {
pub fn radial(n: i32, m: i32, rho: f64) -> f64 {
let abs_m = m.unsigned_abs() as i32;
assert!(
(n - abs_m) % 2 == 0 && abs_m <= n,
"Invalid Zernike indices n={n}, m={m}"
);
let s_max = (n - abs_m) / 2;
(0..=s_max)
.map(|s| {
let sign = if s % 2 == 0 { 1.0 } else { -1.0 };
let num = factorial(n - s) as f64;
let den = (factorial(s)
* factorial((n + abs_m) / 2 - s)
* factorial((n - abs_m) / 2 - s)) as f64;
sign * num / den * rho.powi(n - 2 * s)
})
.sum()
}
pub fn evaluate(n: i32, m: i32, rho: f64, phi: f64) -> f64 {
let r = Self::radial(n, m, rho);
if m > 0 {
r * (m as f64 * phi).cos()
} else if m < 0 {
r * ((-m) as f64 * phi).sin()
} else {
r
}
}
pub fn rms_error(coefficients: &[f64]) -> f64 {
coefficients.iter().map(|c| c * c).sum::<f64>().sqrt()
}
}
fn factorial(n: i32) -> u64 {
(1..=n as u64).product()
}
pub enum ZernikeTerm {
Piston,
Tip,
Tilt,
Defocus,
AstigmatismX,
AstigmatismY,
ComaX,
ComaY,
SphericalAberration,
}
impl ZernikeTerm {
pub fn indices(&self) -> (i32, i32) {
match self {
Self::Piston => (0, 0),
Self::Tip => (1, 1),
Self::Tilt => (1, -1),
Self::Defocus => (2, 0),
Self::AstigmatismX => (2, 2),
Self::AstigmatismY => (2, -2),
Self::ComaX => (3, 1),
Self::ComaY => (3, -1),
Self::SphericalAberration => (4, 0),
}
}
pub fn evaluate(&self, rho: f64, phi: f64) -> f64 {
let (n, m) = self.indices();
ZernikePolynomial::evaluate(n, m, rho, phi)
}
}
pub fn strehl_marechal(w_rms: f64, wavelength: f64) -> f64 {
let phase_rms = 2.0 * PI * w_rms / wavelength;
(-phase_rms * phase_rms).exp()
}
pub struct SeidelAberrations {
pub spherical: f64,
pub coma: f64,
pub astigmatism: f64,
pub field_curvature: f64,
pub distortion: f64,
}
impl SeidelAberrations {
pub fn rms_on_axis(&self) -> f64 {
self.spherical / (2.0 * 5.0_f64.sqrt())
}
pub fn ptv(&self) -> f64 {
self.spherical.abs() + self.coma.abs() + self.astigmatism.abs() + self.field_curvature.abs()
}
}
#[derive(Debug, Clone, Default)]
pub struct SeidelCoeffs {
pub s1: f64,
pub s2: f64,
pub s3: f64,
pub s4: f64,
pub s5: f64,
}
impl SeidelCoeffs {
pub fn from_surfaces(surfaces: &[SurfaceAberration]) -> Self {
let mut out = Self::default();
for s in surfaces {
out.s1 += s.s1;
out.s2 += s.s2;
out.s3 += s.s3;
out.s4 += s.s4;
out.s5 += s.s5;
}
out
}
pub fn total_spherical(&self) -> f64 {
self.s1
}
pub fn total_coma(&self) -> f64 {
self.s2
}
pub fn total_astigmatism(&self) -> f64 {
self.s3
}
pub fn total_field_curvature(&self) -> f64 {
self.s4
}
pub fn total_distortion(&self) -> f64 {
self.s5
}
}
#[derive(Debug, Clone, Default)]
pub struct SurfaceAberration {
pub s1: f64,
pub s2: f64,
pub s3: f64,
pub s4: f64,
pub s5: f64,
}
impl SurfaceAberration {
pub fn thin_lens(f: f64, n1: f64, n2: f64, object_dist: f64, y_marginal: f64) -> Self {
let u = -object_dist.abs(); let inv_v = 1.0 / f + 1.0 / u;
let v = if inv_v.abs() < 1e-30 {
f64::INFINITY
} else {
1.0 / inv_v
};
let alpha_u = y_marginal / u.abs(); let alpha_v = if v.is_finite() {
y_marginal / v.abs()
} else {
0.0
};
let power = n1 / f;
let a = n1 * alpha_u + power * y_marginal;
let dn = 1.0 / n1 - 1.0 / n2;
let h = n1 * y_marginal * alpha_v;
let s1 = -a * a * y_marginal * y_marginal * dn / (2.0 * n1 * u * u);
let s2 = a * h * y_marginal * dn;
let s3 = h * h * dn;
let s4 = h * h * power / (n1 * n2);
let s5 = if alpha_u.abs() > 1e-30 {
(s3 + s4) * alpha_v / alpha_u
} else {
0.0
};
Self { s1, s2, s3, s4, s5 }
}
}
pub fn wavefront_map(seidel: &SeidelCoeffs, nx: usize, ny: usize, pupil_radius: f64) -> Vec<f64> {
let mut wfe = vec![0.0_f64; nx * ny];
let r = pupil_radius;
for iy in 0..ny {
let y = (iy as f64 / (ny as f64 - 1.0) * 2.0 - 1.0) * r;
for ix in 0..nx {
let x = (ix as f64 / (nx as f64 - 1.0) * 2.0 - 1.0) * r;
let rho2 = (x * x + y * y) / (r * r);
if rho2 > 1.0 {
continue; }
let rho = rho2.sqrt();
let phi = y.atan2(x);
let cos_phi = phi.cos();
let w = seidel.s1 / 8.0 * rho2 * rho2
+ seidel.s2 / 2.0 * rho * rho2 * cos_phi
+ seidel.s3 / 2.0 * rho2 * cos_phi * cos_phi
+ (seidel.s3 + seidel.s4) / 4.0 * rho2
+ seidel.s5 / 2.0 * rho * cos_phi;
wfe[iy * nx + ix] = w;
}
}
wfe
}
pub fn rms_wavefront_error(wfe: &[f64]) -> f64 {
let samples = wfe.to_vec();
let n = samples.len();
if n == 0 {
return 0.0;
}
let mean = samples.iter().sum::<f64>() / n as f64;
let var = samples
.iter()
.map(|&w| (w - mean) * (w - mean))
.sum::<f64>()
/ n as f64;
var.sqrt()
}
pub fn strehl_marechal_waves(rms_waves: f64) -> f64 {
let phase = 2.0 * PI * rms_waves;
(-(phase * phase)).exp()
}
#[inline]
pub fn zernike_piston(_rho: f64, _theta: f64) -> f64 {
1.0
}
#[inline]
pub fn zernike_tilt_x(rho: f64, theta: f64) -> f64 {
rho * theta.cos()
}
#[inline]
pub fn zernike_defocus(rho: f64) -> f64 {
2.0 * rho * rho - 1.0
}
#[inline]
pub fn zernike_spherical(rho: f64) -> f64 {
let r2 = rho * rho;
6.0 * r2 * r2 - 6.0 * r2 + 1.0
}
fn zernike_basis(j: usize, rho: f64, theta: f64) -> f64 {
let r2 = rho * rho;
match j {
0 => 1.0,
1 => rho * theta.cos(),
2 => rho * theta.sin(),
3 => 2.0 * r2 - 1.0,
4 => r2 * (2.0 * theta).cos(),
5 => r2 * (2.0 * theta).sin(),
6 => (3.0 * r2 - 2.0) * rho * theta.cos(),
7 => (3.0 * r2 - 2.0) * rho * theta.sin(),
8 => 6.0 * r2 * r2 - 6.0 * r2 + 1.0,
_ => 0.0,
}
}
pub fn zernike_decompose(
wfe: &[f64],
nx: usize,
ny: usize,
pupil_radius: f64,
n_terms: usize,
) -> Vec<f64> {
assert_eq!(wfe.len(), nx * ny, "wfe length must equal nx*ny");
let n_terms = n_terms.min(9); let mut num = vec![0.0_f64; n_terms];
let mut den = vec![0.0_f64; n_terms];
let r = pupil_radius;
for iy in 0..ny {
let yp = (iy as f64 / (ny as f64 - 1.0) * 2.0 - 1.0) * r;
for ix in 0..nx {
let xp = (ix as f64 / (nx as f64 - 1.0) * 2.0 - 1.0) * r;
let rho2 = (xp * xp + yp * yp) / (r * r);
if rho2 > 1.0 {
continue;
}
let rho = rho2.sqrt();
let theta = yp.atan2(xp);
let w = wfe[iy * nx + ix];
for j in 0..n_terms {
let z = zernike_basis(j, rho, theta);
num[j] += w * z;
den[j] += z * z;
}
}
}
(0..n_terms)
.map(|j| {
if den[j].abs() < 1e-30 {
0.0
} else {
num[j] / den[j]
}
})
.collect()
}
pub fn zernike_noll_to_nm(j: u32) -> (i32, i32) {
if j == 0 {
return (0, 0);
}
let mut n = 0i32;
while (n + 1) * (n + 2) / 2 < j as i32 {
n += 1;
}
let j_start = (n * (n + 1) / 2 + 1) as u32;
let k = j.saturating_sub(j_start) as usize;
let start = if n % 2 == 0 { 0i32 } else { 1 };
let m_abs_seq: Vec<i32> = (0..=(n / 2))
.map(|i| start + 2 * i)
.filter(|&m| m >= 0 && m <= n)
.collect();
let m_abs_idx = k / 2;
let m_abs = if m_abs_idx < m_abs_seq.len() {
m_abs_seq[m_abs_idx]
} else {
0
};
let m = if m_abs == 0 {
0
} else if k % 2 == 0 {
m_abs } else {
-m_abs };
(n, m)
}
pub fn zernike_noll(j: u32, rho: f64, theta: f64) -> f64 {
if j == 0 {
return 0.0;
}
let (n, m) = zernike_noll_to_nm(j);
let norm = if m == 0 {
((n + 1) as f64).sqrt()
} else {
(2.0 * (n + 1) as f64).sqrt()
};
let r = zernike_radial(n, m, rho);
let angular = if m > 0 {
(m as f64 * theta).cos()
} else if m < 0 {
((-m) as f64 * theta).sin()
} else {
1.0
};
norm * r * angular
}
pub fn zernike_radial(n: i32, m: i32, rho: f64) -> f64 {
let abs_m = m.unsigned_abs() as i32;
if (n - abs_m) < 0 || (n - abs_m) % 2 != 0 {
return 0.0;
}
let s_max = (n - abs_m) / 2;
(0..=s_max)
.map(|s| {
let sign = if s % 2 == 0 { 1.0 } else { -1.0 };
let num = factorial(n - s) as f64;
let den = (factorial(s)
* factorial((n + abs_m) / 2 - s)
* factorial((n - abs_m) / 2 - s)) as f64;
sign * num / den * rho.powi(n - 2 * s)
})
.sum()
}
pub fn wavefront_from_zernike(coeffs: &[f64], rho: f64, theta: f64) -> f64 {
coeffs
.iter()
.enumerate()
.map(|(idx, &c)| c * zernike_noll((idx + 1) as u32, rho, theta))
.sum()
}
pub fn rms_wavefront_error_zernike(coeffs: &[f64]) -> f64 {
coeffs.iter().map(|&c| c * c).sum::<f64>().sqrt()
}
pub fn zernike_name(j: u32) -> &'static str {
match j {
1 => "Piston",
2 => "Tip",
3 => "Tilt",
4 => "Defocus",
5 => "Oblique Astigmatism",
6 => "Vertical Astigmatism",
7 => "Vertical Coma",
8 => "Horizontal Coma",
9 => "Vertical Trefoil",
10 => "Oblique Trefoil",
11 => "Primary Spherical",
_ => "Higher-order",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zernike_piston_polynomial() {
for rho in [0.0, 0.5, 1.0] {
assert!((ZernikePolynomial::radial(0, 0, rho) - 1.0).abs() < 1e-12);
}
}
#[test]
fn zernike_defocus_at_center() {
let val = ZernikePolynomial::radial(2, 0, 0.0);
assert!((val + 1.0).abs() < 1e-12);
}
#[test]
fn zernike_defocus_at_edge() {
let val = ZernikePolynomial::radial(2, 0, 1.0);
assert!((val - 1.0).abs() < 1e-12);
}
#[test]
fn zernike_tip_at_edge() {
let val = ZernikePolynomial::evaluate(1, 1, 1.0, 0.0);
assert!((val - 1.0).abs() < 1e-12);
}
#[test]
fn zernike_rms_single_term() {
let coeffs = [1.0];
let rms = ZernikePolynomial::rms_error(&coeffs);
assert!((rms - 1.0).abs() < 1e-12);
}
#[test]
fn zernike_rms_two_terms() {
let coeffs = [3.0, 4.0];
let rms = ZernikePolynomial::rms_error(&coeffs);
assert!((rms - 5.0).abs() < 1e-12);
}
#[test]
fn strehl_zero_aberration() {
let s = strehl_marechal(0.0, 633e-9);
assert!((s - 1.0).abs() < 1e-12);
}
#[test]
fn strehl_lambda_over_14_is_maréchal_limit() {
let wl = 633e-9;
let w_rms = wl / 14.0;
let s = strehl_marechal(w_rms, wl);
assert!(s >= 0.79, "Strehl={s:.4} at λ/14 should be ≈0.80");
}
#[test]
fn zernike_term_spherical_index() {
let (n, m) = ZernikeTerm::SphericalAberration.indices();
assert_eq!(n, 4);
assert_eq!(m, 0);
}
#[test]
fn strehl_decreases_with_aberration() {
let wl = 633e-9;
let s1 = strehl_marechal(wl / 20.0, wl);
let s2 = strehl_marechal(wl / 10.0, wl);
assert!(s1 > s2, "More aberration should give lower Strehl");
}
#[test]
fn seidel_coeffs_zero_surfaces() {
let coeffs = SeidelCoeffs::from_surfaces(&[]);
assert_eq!(coeffs.total_spherical(), 0.0);
assert_eq!(coeffs.total_coma(), 0.0);
assert_eq!(coeffs.total_astigmatism(), 0.0);
assert_eq!(coeffs.total_field_curvature(), 0.0);
assert_eq!(coeffs.total_distortion(), 0.0);
}
#[test]
fn seidel_coeffs_sums_surfaces() {
let surfaces = vec![
SurfaceAberration {
s1: 1.0,
s2: 2.0,
s3: 3.0,
s4: 4.0,
s5: 5.0,
},
SurfaceAberration {
s1: 0.5,
s2: -0.5,
s3: 0.1,
s4: -0.2,
s5: 0.3,
},
];
let c = SeidelCoeffs::from_surfaces(&surfaces);
assert!((c.s1 - 1.5).abs() < 1e-12);
assert!((c.s2 - 1.5).abs() < 1e-12);
assert!((c.s3 - 3.1).abs() < 1e-12);
assert!((c.s4 - 3.8).abs() < 1e-12);
assert!((c.s5 - 5.3).abs() < 1e-12);
}
#[test]
fn surface_thin_lens_finite_object() {
let sa = SurfaceAberration::thin_lens(0.1, 1.0, 1.5, 0.2, 0.01);
assert!(sa.s1.is_finite());
assert!(sa.s2.is_finite());
assert!(sa.s3.is_finite());
assert!(sa.s4.is_finite());
assert!(sa.s5.is_finite());
}
#[test]
fn surface_thin_lens_same_n_gives_zero() {
let sa = SurfaceAberration::thin_lens(0.1, 1.5, 1.5, 0.3, 0.01);
assert!(sa.s1.abs() < 1e-20);
assert!(sa.s2.abs() < 1e-20);
assert!(sa.s3.abs() < 1e-20);
}
#[test]
fn wavefront_map_zero_coeffs_is_flat() {
let seidel = SeidelCoeffs::default();
let wfe = wavefront_map(&seidel, 11, 11, 1.0);
let max_abs = wfe.iter().map(|w| w.abs()).fold(0.0_f64, f64::max);
assert!(max_abs < 1e-30, "All-zero Seidel → flat wavefront");
}
#[test]
fn wavefront_map_outside_pupil_is_zero() {
let seidel = SeidelCoeffs {
s1: 1.0,
..Default::default()
};
let wfe = wavefront_map(&seidel, 11, 11, 1.0);
assert_eq!(wfe[0], 0.0, "Corner (outside pupil) must be zero");
}
#[test]
fn wavefront_map_centre_pixel() {
let seidel = SeidelCoeffs {
s1: 2.0,
..Default::default()
};
let nx = 11;
let ny = 11;
let wfe = wavefront_map(&seidel, nx, ny, 1.0);
let centre = wfe[(ny / 2) * nx + (nx / 2)];
assert!(
centre.abs() < 1e-12,
"At pupil centre ρ=0 spherical gives W=0"
);
}
#[test]
fn rms_wavefront_error_flat_is_zero() {
let wfe = vec![0.0_f64; 25];
assert_eq!(rms_wavefront_error(&wfe), 0.0);
}
#[test]
fn rms_wavefront_error_constant_is_zero() {
let wfe = vec![3.0_f64; 100];
assert!(rms_wavefront_error(&wfe) < 1e-12);
}
#[test]
fn rms_wavefront_error_known_case() {
let wfe = vec![0.0_f64, 1.0, 0.0, -1.0];
let rms = rms_wavefront_error(&wfe);
assert!((rms - 0.5_f64.sqrt()).abs() < 1e-12);
}
#[test]
fn strehl_marechal_waves_zero_is_one() {
assert!((strehl_marechal_waves(0.0) - 1.0).abs() < 1e-12);
}
#[test]
fn strehl_marechal_waves_consistent_with_strehl_marechal() {
let wl = 500e-9;
let w_rms = wl / 10.0;
let s1 = strehl_marechal(w_rms, wl);
let s2 = strehl_marechal_waves(1.0 / 10.0);
assert!((s1 - s2).abs() < 1e-12);
}
#[test]
fn zernike_piston_fn_is_one() {
for (rho, theta) in [(0.0, 0.0), (0.5, 1.0), (1.0, PI)] {
assert!((zernike_piston(rho, theta) - 1.0).abs() < 1e-15);
}
}
#[test]
fn zernike_tilt_x_matches_definition() {
let rho = 0.7;
let theta = PI / 4.0;
let val = zernike_tilt_x(rho, theta);
assert!((val - rho * theta.cos()).abs() < 1e-15);
}
#[test]
fn zernike_defocus_matches_polynomial() {
for rho in [0.0, 0.3, 0.7, 1.0] {
let expected = 2.0 * rho * rho - 1.0;
assert!((zernike_defocus(rho) - expected).abs() < 1e-15);
}
}
#[test]
fn zernike_spherical_at_edge() {
assert!((zernike_spherical(1.0) - 1.0).abs() < 1e-15);
}
#[test]
fn zernike_spherical_at_zero() {
assert!((zernike_spherical(0.0) - 1.0).abs() < 1e-15);
}
#[test]
fn zernike_defocus_at_zero_is_minus_one() {
assert!((zernike_defocus(0.0) + 1.0).abs() < 1e-15);
}
#[test]
fn zernike_decompose_flat_wavefront_piston_only() {
let nx = 21;
let ny = 21;
let wfe = vec![1.0_f64; nx * ny]; let coeffs = zernike_decompose(&wfe, nx, ny, 1.0, 4);
assert!(
(coeffs[0] - 1.0).abs() < 0.05,
"Constant WFE → piston coeff ≈ 1, got {}",
coeffs[0]
);
for (j, &c) in coeffs.iter().enumerate().skip(1) {
assert!(
c.abs() < 0.1,
"term j={j} should be near 0 for flat WFE, got {c}"
);
}
}
#[test]
fn zernike_decompose_length_matches_n_terms() {
let wfe = vec![0.0_f64; 15 * 15];
let coeffs = zernike_decompose(&wfe, 15, 15, 1.0, 5);
assert_eq!(coeffs.len(), 5);
}
#[test]
fn zernike_decompose_n_terms_capped_at_9() {
let wfe = vec![0.0_f64; 11 * 11];
let coeffs = zernike_decompose(&wfe, 11, 11, 1.0, 20);
assert_eq!(coeffs.len(), 9);
}
#[test]
fn zernike_noll_to_nm_piston() {
let (n, m) = zernike_noll_to_nm(1);
assert_eq!(n, 0);
assert_eq!(m, 0);
}
#[test]
fn zernike_noll_to_nm_tip_tilt() {
let (n2, m2) = zernike_noll_to_nm(2);
let (n3, m3) = zernike_noll_to_nm(3);
assert_eq!(n2, 1);
assert_eq!(n3, 1);
assert!(m2.abs() == 1 && m3.abs() == 1);
assert_ne!(m2, m3);
}
#[test]
fn zernike_radial_piston() {
for rho in [0.0, 0.3, 0.7, 1.0] {
assert!((zernike_radial(0, 0, rho) - 1.0).abs() < 1e-12);
}
}
#[test]
fn zernike_radial_tip() {
for rho in [0.0, 0.5, 1.0] {
assert!((zernike_radial(1, 1, rho) - rho).abs() < 1e-12);
}
}
#[test]
fn zernike_noll_piston_is_constant() {
use approx::assert_relative_eq;
let v0 = zernike_noll(1, 0.0, 0.0);
let v1 = zernike_noll(1, 0.7, 1.2);
assert_relative_eq!(v0, v1, max_relative = 1e-10);
}
#[test]
fn wavefront_from_zernike_single_piston_term() {
use approx::assert_relative_eq;
let coeffs = vec![2.0_f64]; let piston_z1 = zernike_noll(1, 0.5, 1.0);
let w = wavefront_from_zernike(&coeffs, 0.5, 1.0);
assert_relative_eq!(w, 2.0 * piston_z1, max_relative = 1e-10);
}
#[test]
fn wavefront_from_zernike_zero_coeffs() {
let coeffs = vec![0.0f64; 9];
let w = wavefront_from_zernike(&coeffs, 0.5, 1.0);
assert!(w.abs() < 1e-30);
}
#[test]
fn rms_wavefront_error_zernike_known() {
use approx::assert_relative_eq;
let coeffs = vec![3.0_f64, 4.0];
let rms = rms_wavefront_error_zernike(&coeffs);
assert_relative_eq!(rms, 5.0, max_relative = 1e-10);
}
#[test]
fn zernike_name_piston_matches() {
assert_eq!(zernike_name(1), "Piston");
assert_eq!(zernike_name(4), "Defocus");
assert_eq!(zernike_name(11), "Primary Spherical");
}
#[test]
fn zernike_name_high_order_fallback() {
assert_eq!(zernike_name(100), "Higher-order");
}
}