"""Lateral Color Analysis
This module provides a lateral color (transverse chromatic aberration) analysis
for optical systems.
Kramer Harrison, 2026
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import matplotlib.pyplot as plt
import numpy as np
import optiland.backend as be
from optiland.visualization.system.utils import transform
from .base import BaseAnalysis
if TYPE_CHECKING:
from matplotlib.axes import Axes
from matplotlib.figure import Figure
from optiland.optic import Optic
[docs]
class LateralColor(BaseAnalysis):
"""Lateral color: change of real chief-ray image height with wavelength.
For each field point ``Hy`` in ``linspace(0, 1, num_points)`` (``Hx = 0``)
the real chief ray is traced at every wavelength. The lateral color of a
wavelength is its chief-ray height on the image surface minus that of the
primary wavelength, ``y(λ) - y(primary)``, in the image surface's local
frame and in lens units (mm). A positive value means that wavelength lands
at a larger local y than the primary wavelength, which is farther from the
axis when the image height is positive.
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 field points. Defaults to 64.
Attributes:
optic (Optic): The optic object being analyzed.
wavelengths (list): The wavelengths being analyzed.
num_points (int): The number of field points.
data (dict): ``"Hy"``, the normalized field coordinates, and
``"lateral_color"``, one array per wavelength (same order as
``wavelengths``) holding the lateral color in mm. Chief rays that
do not reach the image surface give NaN.
"""
def __init__(
self, optic: Optic, wavelengths: str | list = "all", num_points: int = 64
):
self.num_points = num_points
super().__init__(optic, wavelengths)
[docs]
def view(
self,
fig_to_plot_on: Figure | None = None,
figsize: tuple[float, float] = (8, 5.5),
*,
show: bool = True,
) -> tuple[Figure, Axes]:
"""Displays a plot of the lateral color versus field.
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 (8, 5.5).
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)
field_np = be.to_numpy(self.data["Hy"]) * self.optic.fields.max_field
for k, wp in enumerate(self.wavelengths):
lateral_color_um = be.to_numpy(self.data["lateral_color"][k]) * 1e3
ax.plot(
lateral_color_um,
field_np,
f"C{k}",
zorder=10,
label=f"{wp.value:.4f} µm",
)
ax.set_xlabel("Lateral Color (µm)")
ax.set_ylabel("Field")
ax.set_ylim([0, self.optic.fields.max_field])
current_xlim = ax.get_xlim()
max_abs_lim = max(np.abs(current_xlim))
if max_abs_lim > 1e-9:
ax.set_xlim([-max_abs_lim, max_abs_lim])
ax.set_title("Lateral Color")
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 the chief ray at each field and wavelength.
Returns:
dict: ``"Hy"`` (normalized field coordinates) and
``"lateral_color"`` (one array per wavelength, in mm).
"""
Hy = be.linspace(0, 1, self.num_points)
primary = self.optic.primary_wavelength
# Trace each distinct wavelength once, including the primary reference.
values = dict.fromkeys([wp.value for wp in self.wavelengths] + [primary])
heights = {value: self._chief_ray_height(Hy, value) for value in values}
lateral_color = [
heights[wp.value] - heights[primary] for wp in self.wavelengths
]
return {"Hy": Hy, "lateral_color": lateral_color}
def _chief_ray_height(self, Hy, wavelength: float):
"""Real chief-ray y on the image surface, in its local frame.
Args:
Hy (be.ndarray): Normalized y field coordinates.
wavelength (float): Wavelength in µm.
Returns:
be.ndarray: Local y per field point; NaN where the ray is vignetted.
"""
zeros = be.zeros_like(Hy)
self.optic.trace_generic(zeros, Hy, zeros, zeros, wavelength)
surfaces = self.optic.surfaces
_, y_local, _ = transform(
surfaces.x[-1, :],
surfaces.y[-1, :],
surfaces.z[-1, :],
self.optic.image_surface,
is_global=True,
)
return be.where(surfaces.intensity[-1, :] > 0, y_local, be.nan)