Source code for optiland.aberrations.third_order

"""Third-Order Aberrations

Computes third-order (Seidel) wavefront aberration terms.

Kramer Harrison, 2026
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import optiland.backend as be

if TYPE_CHECKING:
    from collections.abc import Callable

    from optiland._types import BEArray
    from optiland.optic.context import OpticDataContext


[docs] class ThirdOrderAberrations: """Third-order (Seidel) wavefront aberration coefficients. Args: optic: Any object satisfying the OpticDataContext Protocol. """ def __init__(self, optic: OpticDataContext) -> None: self._optic = optic
[docs] def third_order(self) -> tuple[BEArray, ...]: """Compute all third-order aberrations and first-order color terms. Returns: A tuple of arrays: TSC, SC, CC, TCC, TAC, AC, TPC, PC, DC, TAchC, LchC, TchC, S. """ self._precalculations() TSC = self._compute_over_surfaces(self._TSC_term) CC = self._compute_over_surfaces(self._CC_term) TAC = self._compute_over_surfaces(self._TAC_term) TPC = self._compute_over_surfaces(self._TPC_term) DC = self._compute_over_surfaces(self._DC_term) TAchC = self._compute_over_surfaces(self._TAchC_term) TchC = self._compute_over_surfaces(self._TchC_term) SC = -TSC / self._ua[-1] AC = -TAC / self._ua[-1] PC = -TPC / self._ua[-1] LchC = -TAchC / self._ua[-1] S = self._sum_seidels(TSC, CC, TAC, TPC, DC) TCC = CC * 3 return ( TSC.flatten(), SC.flatten(), CC.flatten(), TCC.flatten(), TAC.flatten(), AC.flatten(), TPC.flatten(), PC.flatten(), DC.flatten(), TAchC.flatten(), LchC.flatten(), TchC.flatten(), S, )
[docs] def TSC(self) -> BEArray: """Compute third-order transverse spherical aberration.""" self._precalculations() return self._compute_over_surfaces(self._TSC_term).flatten()
[docs] def SC(self) -> BEArray: """Compute third-order longitudinal spherical aberration.""" self._precalculations() TSC = self._compute_over_surfaces(self._TSC_term) return (-TSC / self._ua[-1]).flatten()
[docs] def CC(self) -> BEArray: """Compute third-order sagittal coma.""" self._precalculations() return self._compute_over_surfaces(self._CC_term).flatten()
[docs] def TCC(self) -> BEArray: """Compute third-order tangential coma.""" return (self.CC() * 3).flatten()
[docs] def TAC(self) -> BEArray: """Compute third-order transverse astigmatism.""" self._precalculations() return self._compute_over_surfaces(self._TAC_term).flatten()
[docs] def AC(self) -> BEArray: """Compute third-order longitudinal astigmatism.""" self._precalculations() TAC = self._compute_over_surfaces(self._TAC_term) return (-TAC / self._ua[-1]).flatten()
[docs] def TPC(self) -> BEArray: """Compute third-order transverse Petzval sum.""" self._precalculations() return self._compute_over_surfaces(self._TPC_term).flatten()
[docs] def PC(self) -> BEArray: """Compute third-order longitudinal Petzval sum.""" self._precalculations() TPC = self._compute_over_surfaces(self._TPC_term) return (-TPC / self._ua[-1]).flatten()
[docs] def DC(self) -> BEArray: """Compute third-order distortion.""" self._precalculations() return self._compute_over_surfaces(self._DC_term).flatten()
def _signed_refractive_indices(self, wavelength: float) -> BEArray: n_raw = self._optic.surfaces.n(wavelength) n_signed = [] sign = 1.0 for k, surf in enumerate(self._optic.surfaces): if getattr(surf.interaction_model, "is_reflective", False): sign = -sign n_signed.append(sign * be.abs(n_raw[k])) return be.array(n_signed) def _get_conic_term(self, k: int, p_ya: int, p_yb: int) -> float: dn = self._n[k] - self._n[k - 1] S_conic = ( dn * self._K[k] * (self._C[k] ** 3) * (self._ya[k] ** p_ya) * (self._yb[k] ** p_yb) ) return S_conic / (2 * self._n[-1] * self._ua[-1]) def _compute_over_surfaces(self, term_func: Callable) -> BEArray: terms = [term_func(k) for k in range(1, self._N - 1)] return be.array(terms) def _dispersion_wavelengths(self) -> tuple[float, float]: """Return the (short, long) wavelengths used for the chromatic terms. The chromatic aberration coefficients differentiate the system between two wavelengths. Those have to be wavelengths the system is actually specified over: an infrared design differenced across the visible F and C lines produces a meaningless number, and for many infrared glasses there is no refractive index data at 0.4861 µm to evaluate in the first place. Uses the extremes of the system's own wavelength set, falling back to the primary wavelength when only one is defined — in which case the chromatic terms are identically zero, which is the correct answer for a monochromatic system. """ values = [float(be.to_numpy(w.value)) for w in self._optic.wavelengths] if not values: # pragma: no cover - an Optic always carries one primary = float(self._optic.primary_wavelength) return primary, primary return min(values), max(values) def _precalculations(self) -> None: self._inv: float = self._optic.paraxial.invariant() self._on_axis = be.isclose(self._inv, be.array(0.0)) self._n = self._signed_refractive_indices(self._optic.primary_wavelength) short, long = self._dispersion_wavelengths() self._dn = self._signed_refractive_indices( short ) - self._signed_refractive_indices(long) self._N: int = self._optic.surfaces.num_surfaces self._C = 1 / self._optic.surfaces.radii self._ya, self._ua = self._optic.paraxial.marginal_ray() self._yb, self._ub = self._optic.paraxial.chief_ray() self._hp = self._inv / (self._n[-1] * self._ua[-1]) self._K = self._optic.surfaces.conic i_list, ip_list, B_list, Bp_list = [], [], [], [] for k in range(1, self._N - 1): i_val = (self._C[k] * self._ya[k] + self._ua[k - 1])[0] ip_val = (self._C[k] * self._yb[k] + self._ub[k - 1])[0] i_list.append(i_val) ip_list.append(ip_val) if self._on_axis: B_list.append(0) Bp_list.append(0) else: denom = 2 * self._n[k] * self._inv B_val = ( self._n[k - 1] * (self._n[k] - self._n[k - 1]) * self._ya[k] * (self._ua[k] + i_val) / denom )[0] Bp_val = ( self._n[k - 1] * (self._n[k] - self._n[k - 1]) * self._yb[k] * (self._ub[k] + ip_val) / denom )[0] B_list.append(B_val) Bp_list.append(Bp_val) self._i = be.array(i_list) self._ip = be.array(ip_list) self._B = be.array(B_list) self._Bp = be.array(Bp_list) def _TSC_on_axis_term(self, k: int) -> float: i_val = self._C[k] * self._ya[k] + self._ua[k - 1] term = ( self._n[k - 1] * (self._n[k] - self._n[k - 1]) * self._ya[k] * (self._ua[k] + i_val) * i_val**2 ) spherical = term / (2 * self._n[k] * self._n[-1] * self._ua[-1]) return spherical + self._get_conic_term(k, p_ya=4, p_yb=0) def _TSC_term(self, k: int) -> float: if self._on_axis: return self._TSC_on_axis_term(k) spherical = self._B[k - 1] * self._i[k - 1] ** 2 * self._hp return spherical + self._get_conic_term(k, p_ya=4, p_yb=0) def _CC_term(self, k: int) -> float: spherical = self._B[k - 1] * self._i[k - 1] * self._ip[k - 1] * self._hp return spherical + self._get_conic_term(k, p_ya=3, p_yb=1) def _TAC_term(self, k: int) -> float: spherical = self._B[k - 1] * self._ip[k - 1] ** 2 * self._hp return spherical + self._get_conic_term(k, p_ya=2, p_yb=2) def _TPC_term(self, k: int) -> BEArray: return ( (self._n[k] - self._n[k - 1]) * self._C[k] * self._hp * self._inv / (2 * self._n[k] * self._n[k - 1]) ) def _DC_term(self, k: int) -> BEArray: spherical = self._hp * ( self._Bp[k - 1] * self._i[k - 1] * self._ip[k - 1] + 0.5 * (self._ub[k] ** 2 - self._ub[k - 1] ** 2) ) return spherical + self._get_conic_term(k, p_ya=1, p_yb=3) def _chromatic_prefactor(self, k: int) -> BEArray: """Shared factor of the two chromatic terms at surface ``k``. Note the two different index bases in play. ``_ya`` is the full marginal-ray array returned by ``paraxial.marginal_ray()``, indexed by surface with ``_ya[0]`` at the object, so the height *at* surface ``k`` is ``_ya[k]``. ``_i`` and ``_ip`` are lists built over ``range(1, N - 1)``, so the value for surface ``k`` is ``_i[k - 1]``. Mixing the two up costs a one-surface shift in the ray height. """ return ( -self._ya[k] / (self._n[-1] * self._ua[-1]) * (self._dn[k - 1] - self._n[k - 1] / self._n[k] * self._dn[k]) ) def _TAchC_term(self, k: int) -> BEArray: return self._chromatic_prefactor(k) * self._i[k - 1] def _TchC_term(self, k: int) -> BEArray: return self._chromatic_prefactor(k) * self._ip[k - 1] def _sum_seidels( self, TSC: BEArray, CC: BEArray, TAC: BEArray, TPC: BEArray, DC: BEArray, ) -> BEArray: factor = self._n[-1] * self._ua[-1] * 2 return be.array( [ -be.sum(TSC) * factor, -be.sum(CC) * factor, -be.sum(TAC) * factor, -be.sum(TPC) * factor, -be.sum(DC) * factor, ] )