Source code for analysis.longitudinal_aberration

"""Longitudinal Aberration Analysis

This module provides a longitudinal aberration analysis for optical systems.

Preetam Maske, 2026
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import matplotlib.pyplot as plt

import optiland.backend as be

from .base import BaseAnalysis

if TYPE_CHECKING:
    from matplotlib.axes import Axes
    from matplotlib.figure import Figure

    from optiland.optic import Optic


[docs] class LongitudinalAberration(BaseAnalysis): """Longitudinal aberration: axial focus position of each pupil zone. On axis (``Hx = Hy = 0``), a real ray is traced through each pupil zone ``Py`` in ``linspace(1 / num_points, 1, num_points)`` (``Px = 0``) at every wavelength. ``Py = 0`` is skipped because the chief ray never crosses the axis. In the image surface's local frame, the ray's axis crossing measured from the image surface vertex is ``Δz = z - y * N / M``, in lens units (mm), along the local z axis. On a flat image surface ``z = 0`` and this is ``-y * N / M``; keeping ``z`` makes the result independent of the image surface's sag. When the final rays travel toward +z, as in a refractive system, a positive value means that zone focuses past the image surface. The analysis assumes a rotationally symmetric system, so the meridional (``Py``) zones describe the whole pupil. Raises: ValueError: If ``num_points`` is less than 1. Args: optic (Optic): The optic object to analyze. wavelengths (str | list, optional): The wavelengths to analyze. Defaults to 'all'. num_points (int, optional): The number of pupil zones. Defaults to 64. Attributes: optic (Optic): The optic object being analyzed. wavelengths (list): The wavelengths being analyzed. num_points (int): The number of pupil zones. data (dict): ``"Py"``, the normalized pupil zones, and ``"delta_z"``, one array per wavelength (same order as ``wavelengths``) holding the axis crossing in mm. Rays that do not reach the image surface, or that leave parallel to the axis, give NaN. """ def __init__( self, optic: Optic, wavelengths: str | list = "all", num_points: int = 64 ): if num_points < 1: raise ValueError(f"num_points must be at least 1, got {num_points}.") self.num_points = num_points super().__init__(optic, wavelengths)
[docs] def view( self, fig_to_plot_on: Figure | None = None, figsize: tuple[float, float] = (6, 6), *, show: bool = True, ) -> tuple[Figure, Axes]: """Displays a plot of the longitudinal aberration versus pupil zone. Args: fig_to_plot_on (plt.Figure, optional): The figure to plot on. If None, a new figure will be created. Defaults to None. figsize (tuple[float, float], optional): The size of the figure. Defaults to (6, 6). show (bool): If True (default), calls plt.show(). Set False for headless use. Returns: tuple: The current figure and its axes. """ is_gui_embedding = fig_to_plot_on is not None if is_gui_embedding: current_fig = fig_to_plot_on current_fig.clear() ax = current_fig.add_subplot(111) else: current_fig, ax = plt.subplots(figsize=figsize) pupil_np = be.to_numpy(self.data["Py"]) for k, wp in enumerate(self.wavelengths): ax.plot( be.to_numpy(self.data["delta_z"][k]), pupil_np, f"C{k}", zorder=10, label=f"{wp.value:.4f} µm", ) ax.set_xlabel("Longitudinal Aberration (mm)") ax.set_ylabel("Normalized Pupil Zone") ax.set_ylim([0, 1]) current_xlim = ax.get_xlim() max_abs_lim = max(abs(limit) for limit in current_xlim) if max_abs_lim > 1e-9: ax.set_xlim([-max_abs_lim, max_abs_lim]) ax.set_title("Longitudinal Aberration") ax.axvline(x=0, color="k", linewidth=0.5) ax.legend(bbox_to_anchor=(1.05, 0.5), loc="center left") ax.grid(True) current_fig.tight_layout() if is_gui_embedding and hasattr(current_fig, "canvas"): current_fig.canvas.draw_idle() if show and not is_gui_embedding: plt.show() return current_fig, ax
def _generate_data(self) -> dict: """Traces one on-axis ray per pupil zone at each wavelength. Returns: dict: ``"Py"`` (normalized pupil zones) and ``"delta_z"`` (one array per wavelength, in mm). """ Py = be.linspace(1 / self.num_points, 1, self.num_points) delta_z = [self._axis_crossing(Py, wp.value) for wp in self.wavelengths] return {"Py": Py, "delta_z": delta_z} def _axis_crossing(self, Py, wavelength: float): """Axial distance from the image surface to where each ray meets the axis. Args: Py (be.ndarray): Normalized pupil y coordinates. wavelength (float): Wavelength in µm. Returns: be.ndarray: ``z - y * N / M`` per zone, in the image surface's local frame; NaN where the ray is vignetted or parallel to the axis. """ # The returned rays are a fresh object, so localizing them leaves the # recorded per-surface data in the global frame. rays = self.optic.trace_generic(0.0, 0.0, 0.0, Py, wavelength) self.optic.image_surface.geometry.localize(rays) # Rays leaving parallel to the axis (M = 0) never cross it. Dividing by # a placeholder 1 avoids an inf/NaN warning; the mask discards them. crosses = rays.M != 0 safe_M = be.where(crosses, rays.M, 1.0) delta_z = rays.z - rays.y * rays.N / safe_M return be.where((rays.i > 0) & crosses, delta_z, be.nan)