Source code for optiland.rays.ray_aiming.parameterization

"""Local launch parameterization for iterative ray aiming.

The Newton/Broyden aiming core drives exactly two scalar parameters per ray.
Historically those were global launch ``(x, y)`` for infinite conjugates and
global direction ``(L, M)`` (with ``N`` held fixed) for finite ones -- both
of which silently assume the beam enters along global +z. For a system
entered along any other direction one of those parameters points along the
beam (a vanishing Jacobian column), and varying ``L, M`` at fixed ``N``
neither preserves the unit norm nor spans the transverse plane.

:class:`LaunchParameterization` replaces both with two true transverse
degrees of freedom ``(xi, eta)``:

- infinite conjugate: the launch point moves in the entry-frame transverse
  plane, ``r(xi, eta) = r_seed + xi * u + eta * v``, while the field
  direction stays fixed;
- finite conjugate: the object point stays fixed and the direction rotates
  in a per-ray orthonormal tangent basis around the seed direction,
  ``k(xi, eta) = normalize(k_seed + xi * e1 + eta * e2)`` with
  ``e1, e2 perpendicular to k_seed``, so every trial and line-search
  candidate is a unit direction by construction.

For the canonical +z entry the infinite-conjugate map reduces exactly to
the historical ``(x, y)`` offsets (``u = +x``, ``v = +y``).

The bases are stored as plain Python floats. Ray aiming is an iterative
solve whose result is polished against real traces, so it never carries
gradient information (same design as ``pupil_map.py``).

Kramer Harrison, 2026
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING, Any

import optiland.backend as be
from optiland.utils import machine_eps

if TYPE_CHECKING:
    from optiland.optic import Optic


def _degenerate_projection_tolerance() -> float:
    """Norm below which a projected basis vector is degenerate, dtype-aware.

    The rejection ``u - (u . k) k`` of two nearly parallel unit vectors
    cancels catastrophically: with eps-level errors in the components, a
    rejection norm below ~sqrt(eps) carries no reliable direction (fewer
    than half the significant digits survive), so the secondary basis
    vector is used instead. ``sqrt(eps)`` is ~1.5e-8 for float64 --
    matching the historical fixed 1e-8 -- and ~3.5e-4 for float32, where a
    fixed 1e-8 sat below round-off and could never trigger.
    """
    return float(machine_eps(be.zeros(1))) ** 0.5


def _to_float(value: Any) -> float:
    return float(be.to_numpy(be.array(value)).reshape(-1)[0])


[docs] @dataclass(frozen=True) class SolveReport: """Outcome of one Newton/Broyden aiming solve. A returned finite ray is not evidence of convergence; consult this report. Residuals are max-abs stop-plane errors in the stop surface's local transverse coordinates (mm). Attributes: seed_residual: Residual of the initial (seed) launch. final_residual: Residual of the returned launch. converged: Whether every ray met the solver tolerance. iterations: Newton/Broyden iterations executed. num_rays: Number of rays in the solve. num_converged: Number of rays that met tolerance. fallback_used: True when the Newton core substituted the sign-preserving paraxial diagonal for at least one ray's Jacobian -- at initialization because the central finite difference was unusable (a perturbed ray missed a surface), or during iteration because a refreshed finite-difference Jacobian remained ill-conditioned. Populated by ``_solve_core`` from the actual conditioning path taken. jacobian_refreshes: Number of iteration-time central-difference Jacobian refreshes triggered by an ill-conditioned (round-off level reciprocal-condition) Newton solve. """ seed_residual: float final_residual: float converged: bool iterations: int num_rays: int num_converged: int fallback_used: bool = False jacobian_refreshes: int = 0
[docs] @dataclass(frozen=True) class LaunchParameterization: """Two-parameter transverse launch model in the entry frame. Attributes: is_infinite: Whether the object is at infinity (position DOF) or finite (direction DOF). u: First entry-frame transverse basis vector (unit, floats). v: Second entry-frame transverse basis vector (unit, floats). """ is_infinite: bool u: tuple[float, float, float] v: tuple[float, float, float]
[docs] @classmethod def for_optic(cls, optic: Optic, is_infinite: bool) -> LaunchParameterization: """Build the parameterization from the optic's entry frame.""" path = optic.surfaces.build_paraxial_path() u = tuple(_to_float(c) for c in path.entry_u) v = tuple(_to_float(c) for c in path.entry_v) return cls(is_infinite=is_infinite, u=u, v=v)
[docs] def bind(self, x, y, z, L, M, N) -> BoundLaunch: """Bind per-ray seed launch states, yielding a (xi, eta) map.""" return BoundLaunch(self, x, y, z, L, M, N)
[docs] class BoundLaunch: """A :class:`LaunchParameterization` bound to per-ray seed states. ``launch(0, 0)`` reproduces the seeds exactly. For finite conjugates the per-ray tangent basis ``(e1, e2)`` is built perpendicular to each seed direction; the basis is refreshed by re-binding whenever the seeds change meaningfully (each solve binds once). """ def __init__(self, param: LaunchParameterization, x, y, z, L, M, N) -> None: self.param = param self.x0 = be.copy(be.as_array_1d(x)) self.y0 = be.copy(be.as_array_1d(y)) self.z0 = be.copy(be.as_array_1d(z)) self.L0 = be.copy(be.as_array_1d(L)) self.M0 = be.copy(be.as_array_1d(M)) self.N0 = be.copy(be.as_array_1d(N)) if not param.is_infinite: degenerate_tol = _degenerate_projection_tolerance() # Normalize the seed direction, then build the per-ray # orthonormal tangent basis perpendicular to it. norm = be.sqrt(self.L0**2 + self.M0**2 + self.N0**2) norm = be.where(norm < degenerate_tol, 1.0, norm) k0 = (self.L0 / norm, self.M0 / norm, self.N0 / norm) self.k0 = k0 u, v = param.u, param.v e1 = self._reject(u, k0) n1 = be.sqrt(e1[0] ** 2 + e1[1] ** 2 + e1[2] ** 2) # Near-parallel seed and u (a field at ~90 deg from the entry # axis): fall back to v, which is then guaranteed transverse. alt = self._reject(v, k0) n_alt = be.sqrt(alt[0] ** 2 + alt[1] ** 2 + alt[2] ** 2) use_alt = n1 < degenerate_tol n1_safe = be.where(use_alt, be.ones_like(n1), n1) n_alt_safe = be.where(n_alt < degenerate_tol, 1.0, n_alt) self.e1 = tuple( be.where(use_alt, alt[i] / n_alt_safe, e1[i] / n1_safe) for i in range(3) ) self.e2 = ( k0[1] * self.e1[2] - k0[2] * self.e1[1], k0[2] * self.e1[0] - k0[0] * self.e1[2], k0[0] * self.e1[1] - k0[1] * self.e1[0], ) @staticmethod def _reject(vec: tuple[float, float, float], k0: tuple) -> tuple: """Component of a constant vector perpendicular to per-ray k0.""" dot = vec[0] * k0[0] + vec[1] * k0[1] + vec[2] * k0[2] return (vec[0] - dot * k0[0], vec[1] - dot * k0[1], vec[2] - dot * k0[2])
[docs] def launch(self, xi, eta) -> tuple: """Physical launch states ``(x, y, z, L, M, N)`` for parameters. Infinite conjugate: seed positions displaced by ``xi*u + eta*v`` (fixed directions). Finite conjugate: seed positions kept, unit directions ``normalize(k0 + xi*e1 + eta*e2)``. """ if self.param.is_infinite: u, v = self.param.u, self.param.v x = self.x0 + xi * u[0] + eta * v[0] y = self.y0 + xi * u[1] + eta * v[1] z = self.z0 + xi * u[2] + eta * v[2] return x, y, z, self.L0, self.M0, self.N0 kx = self.k0[0] + xi * self.e1[0] + eta * self.e2[0] ky = self.k0[1] + xi * self.e1[1] + eta * self.e2[1] kz = self.k0[2] + xi * self.e1[2] + eta * self.e2[2] norm = be.sqrt(kx**2 + ky**2 + kz**2) return self.x0, self.y0, self.z0, kx / norm, ky / norm, kz / norm
[docs] def project(self, x, y, z, L, M, N) -> tuple: """Inverse map: parameters ``(xi, eta)`` of a physical launch state. Exact inverse of :meth:`launch` (used for warm starts from cached or previously solved launches). For finite conjugates the identity ``xi = (k . e1) / (k . k0)`` inverts the normalized tangent update. """ if self.param.is_infinite: u, v = self.param.u, self.param.v dx = be.as_array_1d(x) - self.x0 dy = be.as_array_1d(y) - self.y0 dz = be.as_array_1d(z) - self.z0 xi = dx * u[0] + dy * u[1] + dz * u[2] eta = dx * v[0] + dy * v[1] + dz * v[2] return xi, eta L = be.as_array_1d(L) M = be.as_array_1d(M) N = be.as_array_1d(N) along = L * self.k0[0] + M * self.k0[1] + N * self.k0[2] along = be.where(be.abs(along) < _degenerate_projection_tolerance(), 1.0, along) xi = (L * self.e1[0] + M * self.e1[1] + N * self.e1[2]) / along eta = (L * self.e2[0] + M * self.e2[1] + N * self.e2[2]) / along return xi, eta