Source code for optiland.rays.ray_aiming.pupil_map

"""Pupil Map Module

This module implements the per-field affine launch model used by the
chief-ray calibrated robust ray aimer (see ``robust.py``), together with a
warm-start cache keyed by ``(Hx, Hy, wavelength)``.

The pupil map is a cheap seed generator only: it is exact at the chief ray
and four cardinal edge probes, and a good linear approximation elsewhere.
The final Newton/Broyden polish (in ``iterative.py``) makes every ray exact,
so the map never needs to carry gradient information -- it is stored as
plain Python floats, which are inherently detached from any autograd graph.

Kramer Harrison, 2026
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

import optiland.backend as be

if TYPE_CHECKING:
    from optiland.optic import Optic
    from optiland.rays.ray_aiming.parameterization import LaunchParameterization


[docs] def to_float(x: Any) -> float: """Extract a plain Python float from a scalar or length-1 backend array.""" return float(be.to_numpy(x).reshape(-1)[0])
[docs] class PupilMap: """Per-field affine launch model in local transverse coordinates. ``seed(Px, Py)`` evaluates ``(xi, eta) = A @ [Px, Py]`` and maps the result through the chief ray's bound :class:`~optiland.rays.ray_aiming.parameterization.LaunchParameterization`: for infinite conjugates the launch point moves in the entry-frame transverse plane around the chief launch (fixed field direction); for finite conjugates the object point is fixed and the direction rotates in the tangent basis around the chief direction, staying unit-norm. The stored coordinates are local transverse offsets, never global ``(x, y)`` -- a system entered off the z axis seeds correctly. Attributes: base: Chief launch state ``(x, y, z, L, M, N)`` as plain floats. A: Affine matrix ``((a11, a12), (a21, a22))`` mapping normalized pupil coordinates to ``(xi, eta)``. param: The launch parameterization (entry-frame basis, conjugate mode) the offsets are expressed in. """ __slots__ = ("base", "A", "param") def __init__( self, base: tuple[float, float, float, float, float, float], A: tuple[tuple[float, float], tuple[float, float]], param: LaunchParameterization, ) -> None: self.base = base self.A = A self.param = param @property def is_infinite(self) -> bool: return self.param.is_infinite
[docs] def seed(self, Px: Any, Py: Any) -> tuple: """Evaluate the affine model for pupil coordinates (Px, Py). Args: Px: Normalized pupil x-coordinates. Py: Normalized pupil y-coordinates. Returns: tuple: Full launch guess ``(x, y, z, L, M, N)``. """ Px = be.as_array_1d(Px) Py = be.as_array_1d(Py) (a11, a12), (a21, a22) = self.A xi = a11 * Px + a12 * Py eta = a21 * Px + a22 * Py ones = be.ones_like(Px) bx, by, bz, bL, bM, bN = self.base bound = self.param.bind( ones * bx, ones * by, ones * bz, ones * bL, ones * bM, ones * bN ) return bound.launch(xi, eta)
[docs] class PupilMapCache: """Warm-start cache of :class:`PupilMap`, keyed by ``(Hx, Hy, wavelength)``. The cache is never cleared on a system change. Instead, a lightweight fingerprint of the aiming-relevant system state is tracked per entry: a cache hit is only "fresh" (reusable without recomputation) if the fingerprint at store time still matches the current one. A stale or missing entry is always still usable as a warm-start *seed* for a fresh calibration -- correctness never depends on the cache, only speed. """ def __init__(self, precision: int = 6) -> None: self.precision = precision self._store: dict[tuple[float, float, float], PupilMap] = {} self._fingerprint_at_store: dict[tuple[float, float, float], Any] = {} self._current_fingerprint: Any = None def _key(self, Hx: float, Hy: float, wl: float) -> tuple[float, float, float]: p = self.precision return (round(float(Hx), p), round(float(Hy), p), round(float(wl), p))
[docs] def sync(self, optic: Optic) -> None: """Recompute the current system fingerprint (once per aiming call).""" self._current_fingerprint = self.fingerprint(optic)
[docs] def get_fresh(self, Hx: float, Hy: float, wl: float) -> PupilMap | None: """Return the cached map only if the system hasn't changed since it was stored -- an exact-reuse hit that skips recalibration entirely. """ key = self._key(Hx, Hy, wl) pmap = self._store.get(key) if pmap is None: return None if self._fingerprint_at_store.get(key) != self._current_fingerprint: return None return pmap
[docs] def get_stale(self, Hx: float, Hy: float, wl: float) -> PupilMap | None: """Return the map for this exact key regardless of freshness.""" return self._store.get(self._key(Hx, Hy, wl))
[docs] def nearest(self, Hx: float, Hy: float) -> PupilMap | None: """Return the cached map whose field is nearest in (Hx, Hy). Used for field-marching warm starts (D8): a newly requested field with no cached entry seeds its chief solve from the closest field already solved, rather than a cold paraxial guess. """ if not self._store: return None target = (float(Hx), float(Hy)) best_key = min( self._store, key=lambda k: (k[0] - target[0]) ** 2 + (k[1] - target[1]) ** 2, ) return self._store[best_key]
[docs] def put(self, Hx: float, Hy: float, wl: float, pmap: PupilMap) -> None: key = self._key(Hx, Hy, wl) self._store[key] = pmap self._fingerprint_at_store[key] = self._current_fingerprint
[docs] def fingerprint(self, optic: Optic) -> Any: """Compute a lightweight hash of aiming-relevant system state. Only surfaces up to and including the stop matter for aiming, so post-stop surfaces (and unrelated optic metadata) are excluded to keep this cheap enough to call on every aiming request. """ stop_index = optic.surfaces.stop_index surf_data = tuple( str(optic.surfaces[i].to_dict()) for i in range(stop_index + 1) ) aperture_data = str(optic.aperture.to_dict()) if optic.aperture else None fields_data = str(optic.fields.to_dict()) wl_data = str(optic.wavelengths.to_dict()) return hash((surf_data, aperture_data, fields_data, wl_data))