"""Robust Ray Aiming Module
This module implements a chief-ray calibrated robust ray aiming algorithm.
For each field, a cheap chief-ray solve plus four cardinal edge probes
(``(+-1, 0)``, ``(0, +-1)`` on the stop) are fit to a 2x2 affine launch
model (see ``pupil_map.py``). That model seeds every requested pupil point,
which is then driven to its exact target by the same Newton/Broyden polish
used by ``IterativeRayAimer``. Calibration is warm-started from the
previous fit (or the nearest already-solved field) rather than a paraxial
guess extrapolated across the whole field range, which is what allows this
to converge cold at extreme field angles without the recursive homotopy
subdivision the previous implementation relied on.
See ``optiland/jupyter/SPEC_ray_aiming_20260703.md`` for the full design.
Kramer Harrison, 2026
"""
from __future__ import annotations
import contextlib
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import optiland.backend as be
from optiland.paraxial_path import paraxial_seed_scope
from optiland.rays.ray_aiming.base import BaseRayAimer
from optiland.rays.ray_aiming.initialization import get_stop_radius_strategy
from optiland.rays.ray_aiming.iterative import IterativeRayAimer
from optiland.rays.ray_aiming.parameterization import (
LaunchParameterization,
SolveReport,
)
from optiland.rays.ray_aiming.pupil_map import PupilMap, PupilMapCache, to_float
from optiland.rays.ray_aiming.registry import register_aimer
if TYPE_CHECKING:
from optiland.optic import Optic
# Cardinal edge probes on the stop, in (Px, Py) order: east, west, north, south.
_EDGE_PROBES = ((1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0))
[docs]
@dataclass(frozen=True)
class RobustFieldReport:
"""Per-field outcome of one robust aiming call.
Attributes:
Hx, Hy: Normalized field coordinates of the group.
wavelength: Wavelength of the group in micrometers.
final_polish: :class:`SolveReport` of the group's final Newton
polish (or of the failed chief solve when calibration itself
failed).
chief_seed_strategy: How the chief anchor was obtained --
``"initial_guess"`` (caller-supplied guess solved directly),
``"cached_map"`` (fresh cached map reused, no calibration),
``"warm_map"`` (chief solved from a stale/nearest cached map),
``"direct_paraxial"`` (chief solved from the fresh paraxial
seed), ``"marching"`` (field-marching fallback), ``"scan"``
(transverse scan fallback) or ``"failed"``.
used_cached_map: Whether a fresh cached pupil map was reused
without recalibration.
edge_probe_fallbacks: Number of cardinal edge probes that failed to
converge and fell back to the chief launch in the affine fit.
calibration_used: Whether a fresh chief-plus-probes calibration ran
for this field.
fallback_used: Whether any substitute strategy was used for this
field: a failed caller-supplied guess, chief marching or scan,
edge-probe fallbacks, or a Jacobian conditioning fallback
inside the final polish.
"""
Hx: float
Hy: float
wavelength: float
final_polish: SolveReport
chief_seed_strategy: str
used_cached_map: bool
edge_probe_fallbacks: int
calibration_used: bool
fallback_used: bool
[docs]
@dataclass(frozen=True)
class RobustSolveReport:
"""Aggregate outcome of one :meth:`RobustRayAimer.aim_rays` call.
Robust aiming may return NaN for individual vignetted/unreachable
rays; ``converged`` is therefore defined as
``num_converged == num_rays`` and the exact counts are retained.
Attributes:
field_reports: One :class:`RobustFieldReport` per processed field
group, in processing order.
num_rays: Total number of requested rays.
num_converged: Number of rays that met the solver tolerance.
converged: ``num_converged == num_rays``.
seed_residual: Worst (max) seed residual across the field polishes.
final_residual: Worst (max) final residual across the field
polishes.
final_polish_iterations: Largest Newton iteration count among the
field polishes.
fallback_used: Whether any field used a substitute strategy (see
:attr:`RobustFieldReport.fallback_used`).
"""
field_reports: tuple
num_rays: int
num_converged: int
converged: bool
seed_residual: float
final_residual: float
final_polish_iterations: int
fallback_used: bool
@dataclass(frozen=True)
class _CalibrationRecord:
"""Bookkeeping of one fresh per-field calibration."""
chief_strategy: str
chief_report: SolveReport | None
edge_probe_fallbacks: int
def _scan_candidate_offsets(
g_xi: float, g_eta: float, Hx: float, Hy: float, n: int
) -> tuple:
"""Transverse ``(xi, eta)`` offsets of the chief-scan candidates.
The offsets parameterize a line *through the fresh paraxial seed*: the
candidate at index ``n // 2`` is exactly ``(0, 0)`` (the seed itself),
and every other candidate displaces the seed within the entry frame's
transverse plane only. The sweep direction comes from the local field
coordinates ``(Hx, Hy)`` normalized -- the meridional ``eta`` axis for
a zero/degenerate field -- and the sweep half-width scales with the
seed's own transverse offset from the first-surface anchor
(``g_xi, g_eta``), which is invariant under a rigid translation of the
system.
Args:
g_xi, g_eta: Transverse components of (seed - first-surface vertex)
in the entry frame.
Hx, Hy: Normalized field coordinates of the chief solve.
n: Number of candidates (odd keeps the exact seed in the sweep).
Returns:
tuple: ``(xi_offsets, eta_offsets)`` backend arrays of length ``n``.
"""
h_norm = (Hx * Hx + Hy * Hy) ** 0.5
if h_norm < 1e-12:
dir_xi, dir_eta = 0.0, 1.0
else:
dir_xi, dir_eta = Hx / h_norm, Hy / h_norm
seed_offset = (g_xi * g_xi + g_eta * g_eta) ** 0.5
scale = max(50.0, 20.0 * seed_offset)
r = be.linspace(-scale, scale, n)
# Force the center candidate to exactly zero so it reproduces the seed
# bit-for-bit (linspace's midpoint is only zero to round-off).
center = be.arange_indices(n) == n // 2
r = be.where(center, be.zeros_like(r), r)
return dir_xi * r, dir_eta * r
@contextlib.contextmanager
def _cached_paraxial_constants(optic: Optic):
"""Temporarily memoize ``Paraxial.EPD``/``EPL`` on this optic.
Both are system-wide constants (independent of field/pupil), but each
call re-traces the whole system. The chief-ray field-marching fallback
(:meth:`RobustRayAimer._march_chief`) calls ``ParaxialRayAimer.aim_rays``
-- which calls these -- once per marching attempt, and a cold extreme
field can need dozens of attempts; without caching, that cost dominates
total aiming time. Scoped and reversible: the original bound methods are
restored on exit, so this never leaks stale values past one calibration.
"""
para = optic.paraxial
orig_epd = para.EPD
orig_epl = para.EPL
cache: dict[str, Any] = {}
def cached_epd():
if "epd" not in cache:
cache["epd"] = orig_epd()
return cache["epd"]
def cached_epl(path=None):
# ``path`` only avoids a rebuild inside the wrapped call; the cached
# value is the same either way (geometry is fixed within this scope).
if "epl" not in cache:
cache["epl"] = orig_epl(path=path)
return cache["epl"]
para.EPD = cached_epd
para.EPL = cached_epl
try:
yield
finally:
para.EPD = orig_epd
para.EPL = orig_epl
@contextlib.contextmanager
def _relaxed_tolerance(iterative, tol: float):
"""Temporarily loosen ``iterative.tol`` for cheap intermediate solves.
Used only by the chief-ray marching fallback: an intermediate marching
step just needs to be "good enough" to warm-start the next one, since
the final per-ray polish (elsewhere, always at full tolerance) is what
actually guarantees exactness (D1). Tighter-than-needed intermediate
tolerance costs several extra Newton iterations per step for no
accuracy benefit that survives to the final result.
"""
orig_tol = iterative.tol
iterative.tol = max(tol, orig_tol)
try:
yield
finally:
iterative.tol = orig_tol
[docs]
@register_aimer("robust")
class RobustRayAimer(BaseRayAimer):
"""Chief-ray calibrated robust ray aiming algorithm.
Designed to handle challenging optical systems (wide-angle, fisheye)
where a cold paraxial seed for the iterative solver is too far from the
real solution to converge directly. Per field, a chief-ray calibration
(§4.2 of the spec) produces a cheap affine seed model; every requested
ray is then polished to exactness (§4.3) via the reused
``IterativeRayAimer`` Newton/Broyden core. Individual ray failures
(vignetting, TIR) are reported as NaN rather than aborting the batch.
Attributes:
optic (Optic): The optical system instance.
max_iter (int): Maximum number of iterations for the internal solver.
tol (float): Numerical tolerance for convergence.
scale_fields (bool): Retained for constructor-signature stability;
the calibration-based algorithm does not use homotopy
field-scaling, so this is a no-op.
"""
def __init__(
self,
optic: Optic,
max_iter: int = 20,
tol: float = 1e-8,
scale_fields: bool = True,
**kwargs: Any,
) -> None:
"""Initialize the RobustRayAimer.
Args:
optic (Optic): The optical system to aim rays for.
max_iter (int, optional): Maximum number of iterations. Defaults to 20.
tol (float, optional): Error tolerance for convergence. Defaults to 1e-8.
scale_fields (bool, optional): No-op, retained for backward
compatibility. Defaults to True.
**kwargs: Additional keyword arguments passed to BaseRayAimer.
"""
super().__init__(optic, **kwargs)
self.scale_fields = scale_fields
self.max_iter = max_iter
self.tol = tol
self._iterative = IterativeRayAimer(optic, max_iter=max_iter, tol=tol)
self._paraxial = self._iterative._paraxial_aimer
self._cache = PupilMapCache()
#: Aggregate report of the most recent :meth:`aim_rays` call. Set
#: before raising on a total field failure, so the failure can be
#: inspected.
self.last_report: RobustSolveReport | None = None
# Report of the last chief-solve attempt, kept for the failure path.
self._last_chief_failure_report: SolveReport | None = None
[docs]
def aim_rays(
self,
fields: tuple,
wavelengths: Any,
pupil_coords: tuple,
initial_guess: tuple | None = None,
) -> tuple:
"""Calculate ray starting coordinates using chief-ray calibration.
Args:
fields (tuple): Field coordinates ``(Hx, Hy)``.
wavelengths (Any): Wavelengths in microns.
pupil_coords (tuple): Normalized pupil coordinates ``(Px, Py)``.
initial_guess (tuple | None, optional): Optional starting guess.
If provided, the method first attempts to solve directly
using the iterative solver with this guess; only on failure
does it fall back to the full calibrated solve below.
Returns:
tuple: Solved ray parameters ``(x, y, z, L, M, N)``.
Raises:
ValueError: If every ray for a field fails to converge (a
misconfiguration, not ordinary partial vignetting).
"""
# Scalar paraxial values are only seeds here -- every returned ray
# is Newton-polished against real traces -- so out-of-domain
# geometries warn instead of raising inside this scope.
with paraxial_seed_scope():
return self._aim_rays_scoped(
fields, wavelengths, pupil_coords, initial_guess
)
def _aim_rays_scoped(
self,
fields: tuple,
wavelengths: Any,
pupil_coords: tuple,
initial_guess: tuple | None = None,
) -> tuple:
"""Body of :meth:`aim_rays`, run inside the paraxial seed scope."""
guess_failed = False
if initial_guess is not None:
try:
result = self._iterative.aim_rays(
fields, wavelengths, pupil_coords, initial_guess=initial_guess
)
except ValueError:
# Fall through to the calibrated solve below; using
# calibration after a failed requested guess is a fallback
# and is reported as such.
guess_failed = True
else:
self.last_report = self._report_for_initial_guess(fields, wavelengths)
return result
Px, Py = pupil_coords
Px = be.as_array_1d(Px)
Py = be.as_array_1d(Py)
n = len(Px)
Hx, Hy = fields
Hx = be.as_array_1d(Hx)
Hy = be.as_array_1d(Hy)
if len(Hx) == 1 and n > 1:
Hx = Hx * be.ones(n)
if len(Hy) == 1 and n > 1:
Hy = Hy * be.ones(n)
if hasattr(wavelengths, "__len__"):
wl_arr = be.as_array_1d(wavelengths)
if len(wl_arr) == 1 and n > 1:
wl_arr = wl_arr * be.ones(n)
else:
wl_arr = be.ones(n) * float(wavelengths)
Hx_list = be.to_numpy(Hx).reshape(-1).tolist()
Hy_list = be.to_numpy(Hy).reshape(-1).tolist()
wl_list = be.to_numpy(wl_arr).reshape(-1).tolist()
stop_idx = self.optic.surfaces.stop_index
is_inf = getattr(self.optic.object_surface, "is_infinite", False)
r_stop = get_stop_radius_strategy(self.optic, "robust").calculate_stop_radius()
# One launch parameterization per aiming call: all solves below share
# the same entry frame, so the local transverse basis is built once
# and passed through rather than re-derived per solve.
param = LaunchParameterization.for_optic(self.optic, bool(is_inf))
self._cache.sync(self.optic)
# Group rays by field (D3: reuse the same pupil map across pupil
# distributions for the same field), then process fields ordered
# by radial magnitude so later (larger) fields can warm-start from
# already-solved smaller ones (D8 field marching).
groups: dict[tuple[float, float, float], list[int]] = {}
for i in range(n):
key = (Hx_list[i], Hy_list[i], wl_list[i])
groups.setdefault(key, []).append(i)
ordered_keys = sorted(groups, key=lambda k: k[0] ** 2 + k[1] ** 2)
order_parts: list[list[int]] = []
x_parts: list[Any] = []
y_parts: list[Any] = []
z_parts: list[Any] = []
L_parts: list[Any] = []
M_parts: list[Any] = []
N_parts: list[Any] = []
field_reports: list[RobustFieldReport] = []
num_converged_total = 0
def _finalize_report() -> None:
"""Assemble the aggregate report from the field reports so far."""
polishes = [fr.final_polish for fr in field_reports]
self.last_report = RobustSolveReport(
field_reports=tuple(field_reports),
num_rays=n,
num_converged=num_converged_total,
converged=num_converged_total == n,
seed_residual=max(
(p.seed_residual for p in polishes), default=float("inf")
),
final_residual=max(
(p.final_residual for p in polishes), default=float("inf")
),
final_polish_iterations=max(
(p.iterations for p in polishes), default=0
),
fallback_used=guess_failed
or any(fr.fallback_used for fr in field_reports),
)
for key in ordered_keys:
idx = groups[key]
Hxk, Hyk, wlk = key
used_cached_map = False
calibration: _CalibrationRecord | None = None
pmap = self._cache.get_fresh(Hxk, Hyk, wlk)
if pmap is not None:
used_cached_map = True
else:
seed_map = self._cache.get_stale(Hxk, Hyk, wlk)
if seed_map is None:
seed_map = self._cache.nearest(Hxk, Hyk)
try:
with _cached_paraxial_constants(self.optic):
pmap, calibration = self._calibrate_field(
Hxk,
Hyk,
wlk,
stop_idx,
is_inf,
r_stop,
seed_map,
param,
)
except ValueError:
# Total chief failure for this field: record what was
# attempted, publish the partial report, then re-raise
# so the failure can be inspected via last_report.
failed_polish = self._last_chief_failure_report
if failed_polish is None:
failed_polish = SolveReport(
seed_residual=float("inf"),
final_residual=float("inf"),
converged=False,
iterations=0,
num_rays=len(idx),
num_converged=0,
)
field_reports.append(
RobustFieldReport(
Hx=Hxk,
Hy=Hyk,
wavelength=wlk,
final_polish=failed_polish,
chief_seed_strategy="failed",
used_cached_map=False,
edge_probe_fallbacks=0,
calibration_used=True,
fallback_used=True,
)
)
_finalize_report()
raise
self._cache.put(Hxk, Hyk, wlk, pmap)
Px_g = Px[idx]
Py_g = Py[idx]
wl_g = wl_arr[idx]
x0, y0, z0, L0, M0, N0 = pmap.seed(Px_g, Py_g)
tx = Px_g * r_stop
ty = Py_g * r_stop
x, y, z, L, M, N, converged, _, polish = self._iterative._solve_core(
x0, y0, z0, L0, M0, N0, wl_g, stop_idx, is_inf, tx, ty, param=param
)
group_converged = int(be.to_numpy(converged).reshape(-1).sum())
num_converged_total += group_converged
if calibration is None:
strategy = "cached_map"
probe_fallbacks = 0
else:
strategy = calibration.chief_strategy
probe_fallbacks = calibration.edge_probe_fallbacks
field_reports.append(
RobustFieldReport(
Hx=Hxk,
Hy=Hyk,
wavelength=wlk,
final_polish=polish,
chief_seed_strategy=strategy,
used_cached_map=used_cached_map,
edge_probe_fallbacks=probe_fallbacks,
calibration_used=calibration is not None,
fallback_used=guess_failed
or strategy in ("marching", "scan")
or probe_fallbacks > 0
or polish.fallback_used,
)
)
if not be.any(converged):
# Publish the report -- including this field's failed
# polish -- before raising, so the failure is inspectable.
_finalize_report()
raise ValueError(
"RobustRayAimer: every ray failed to converge for field "
f"(Hx={Hxk}, Hy={Hyk}); check the system configuration."
)
# Renormalize direction cosines (G3 invariant).
norm = be.sqrt(L**2 + M**2 + N**2)
L = L / norm
M = M / norm
N = N / norm
# Graceful per-ray failure (D6): non-converged rays -> NaN.
x = be.where(converged, x, be.nan)
y = be.where(converged, y, be.nan)
z = be.where(converged, z, be.nan)
L = be.where(converged, L, be.nan)
M = be.where(converged, M, be.nan)
N = be.where(converged, N, be.nan)
order_parts.append(idx)
x_parts.append(x)
y_parts.append(y)
z_parts.append(z)
L_parts.append(L)
M_parts.append(M)
N_parts.append(N)
order = [i for part in order_parts for i in part]
inv_perm = [0] * n
for pos, orig in enumerate(order):
inv_perm[orig] = pos
x_out = be.concatenate(x_parts)[inv_perm]
y_out = be.concatenate(y_parts)[inv_perm]
z_out = be.concatenate(z_parts)[inv_perm]
L_out = be.concatenate(L_parts)[inv_perm]
M_out = be.concatenate(M_parts)[inv_perm]
N_out = be.concatenate(N_parts)[inv_perm]
_finalize_report()
return x_out, y_out, z_out, L_out, M_out, N_out
def _report_for_initial_guess(
self, fields: tuple, wavelengths: Any
) -> RobustSolveReport:
"""Aggregate report for a call solved directly from a caller guess."""
polish = self._iterative.last_report
if polish is None: # pragma: no cover - aim_rays always sets it
polish = SolveReport(
seed_residual=float("nan"),
final_residual=float("nan"),
converged=True,
iterations=0,
num_rays=0,
num_converged=0,
)
Hx, Hy = fields
hx0 = float(be.to_numpy(be.as_array_1d(Hx)).reshape(-1)[0])
hy0 = float(be.to_numpy(be.as_array_1d(Hy)).reshape(-1)[0])
if hasattr(wavelengths, "__len__"):
wl0 = float(be.to_numpy(be.as_array_1d(wavelengths)).reshape(-1)[0])
else:
wl0 = float(wavelengths)
field_report = RobustFieldReport(
Hx=hx0,
Hy=hy0,
wavelength=wl0,
final_polish=polish,
chief_seed_strategy="initial_guess",
used_cached_map=False,
edge_probe_fallbacks=0,
calibration_used=False,
fallback_used=polish.fallback_used,
)
return RobustSolveReport(
field_reports=(field_report,),
num_rays=polish.num_rays,
num_converged=polish.num_converged,
converged=polish.num_converged == polish.num_rays,
seed_residual=polish.seed_residual,
final_residual=polish.final_residual,
final_polish_iterations=polish.iterations,
fallback_used=polish.fallback_used,
)
def _calibrate_field(
self,
Hx: float,
Hy: float,
wl: float,
stop_idx: int,
is_inf: bool,
r_stop: float,
seed_map: PupilMap | None,
param: LaunchParameterization | None = None,
) -> tuple[PupilMap, _CalibrationRecord]:
"""Chief solve + 4 edge probes -> affine :class:`PupilMap` (§4.2).
Returns:
tuple: The fitted map and the :class:`_CalibrationRecord`
describing how the chief anchor was obtained and how many edge
probes fell back to the chief launch.
"""
if param is None:
param = LaunchParameterization.for_optic(self.optic, bool(is_inf))
chief, strategy, chief_report = self._solve_chief(
Hx, Hy, wl, stop_idx, is_inf, seed_map, param
)
probes = []
probe_fallbacks = 0
for px, py in _EDGE_PROBES:
probe, fell_back = self._solve_probe(
wl, stop_idx, is_inf, px, py, r_stop, chief, param
)
probes.append(probe)
probe_fallbacks += int(fell_back)
record = _CalibrationRecord(
chief_strategy=strategy,
chief_report=chief_report,
edge_probe_fallbacks=probe_fallbacks,
)
return self._fit_affine(chief, probes, param), record
def _solve_chief(
self,
Hx: float,
Hy: float,
wl: float,
stop_idx: int,
is_inf: bool,
seed_map: PupilMap | None,
param: LaunchParameterization | None = None,
) -> tuple[tuple, str, SolveReport | None]:
"""Solve the chief ray (stop target (0, 0)) for this field.
Seed order: warm-started map for this field or the nearest
already-solved field, then a direct paraxial guess. If both fail --
the paraxial seed can be too far from the real solution at extreme
field angles to converge in one Newton solve -- fall back to
marching the chief ray outward in field angle from the axis
(:meth:`_march_chief`), which is what makes a *cold* extreme-field
solve (e.g. WideAngle170FOV) converge without recursive subdivision.
The fixed launch components (direction for infinite conjugates,
object position for finite ones) always come fresh from *this*
field's paraxial trace, never from ``seed_map`` -- they encode the
field angle itself, so reusing another field's fixed components
would silently solve the wrong (e.g. on-axis) problem even though
Newton still converges. Only the free transverse 2-DOF is
warm-started: the seed map's chief launch is projected onto this
field's fresh seed through the shared local parameterization.
Returns:
tuple: ``(launch, strategy, report)`` -- the solved chief
launch state, which strategy produced it (``"warm_map"``,
``"direct_paraxial"``, ``"marching"`` or ``"scan"``), and the
:class:`SolveReport` of the producing solve (``None`` when the
producing step exposes no single report).
Raises:
ValueError: If every strategy fails. The report of the last
attempted solve is stored on
``self._last_chief_failure_report`` before raising.
"""
if param is None:
param = LaunchParameterization.for_optic(self.optic, bool(is_inf))
wl_a = be.array([wl])
tx = be.array([0.0])
ty = be.array([0.0])
self._last_chief_failure_report = None
px0, py0, pz0, pL0, pM0, pN0 = self._paraxial.aim_rays(
(be.array([Hx]), be.array([Hy])),
wl_a,
(be.array([0.0]), be.array([0.0])),
)
if seed_map is not None:
sx0, sy0, sz0, sL0, sM0, sN0 = seed_map.seed(
be.array([0.0]), be.array([0.0])
)
# Carry only the free transverse offsets of the stored chief
# over to this field's fresh seed.
bound = param.bind(px0, py0, pz0, pL0, pM0, pN0)
xi, eta = bound.project(sx0, sy0, sz0, sL0, sM0, sN0)
x0, y0, z0, L0, M0, N0 = bound.launch(xi, eta)
x, y, z, L, M, N, converged, _, report = self._iterative._solve_core(
x0, y0, z0, L0, M0, N0, wl_a, stop_idx, is_inf, tx, ty, param=param
)
self._last_chief_failure_report = report
if be.any(converged):
launch = (
to_float(x),
to_float(y),
to_float(z),
to_float(L),
to_float(M),
to_float(N),
)
return launch, "warm_map", report
x, y, z, L, M, N, converged, _, report = self._iterative._solve_core(
px0, py0, pz0, pL0, pM0, pN0, wl_a, stop_idx, is_inf, tx, ty, param=param
)
self._last_chief_failure_report = report
if be.any(converged):
launch = (
to_float(x),
to_float(y),
to_float(z),
to_float(L),
to_float(M),
to_float(N),
)
return launch, "direct_paraxial", report
marched = self._march_chief(Hx, Hy, wl_a, stop_idx, is_inf, tx, ty, param)
if marched is not None:
launch, report = marched
if report is not None:
self._last_chief_failure_report = report
return launch, "marching", report
if is_inf:
scanned = self._scan_chief(
px0, py0, pz0, pL0, pM0, pN0, wl_a, stop_idx, tx, ty, param, Hx, Hy
)
if scanned is not None:
launch, report = scanned
self._last_chief_failure_report = report
return launch, "scan", report
raise ValueError(
f"RobustRayAimer: chief ray failed to converge for field "
f"(Hx={Hx}, Hy={Hy}) after marching from the axis; check "
f"the system configuration."
)
def _scan_chief(
self,
px0: Any,
py0: Any,
pz0: Any,
pL0: Any,
pM0: Any,
pN0: Any,
wl_a: Any,
stop_idx: int,
tx: Any,
ty: Any,
param: LaunchParameterization,
Hx: float = 0.0,
Hy: float = 0.0,
n: int = 2001,
) -> tuple[tuple, SolveReport] | None:
"""Last-resort chief-ray seed search for extreme (beyond +-90 degree)
field angles.
Sweeps candidate launch points along the transverse line *through
the fresh paraxial seed* ``(px0 ... pN0)`` and returns the first
one the Newton polish converges from, for when neither the paraxial
guess nor field marching converges. The parameterization is bound
to repeated copies of the seed, so the ``r = 0`` candidate
reproduces the seed exactly and every candidate displacement is
transverse to the entry direction. The sweep direction comes from
the field coordinates ``(Hx, Hy)`` (the meridional axis for a
degenerate field) and the sweep width from the seed's transverse
offset relative to the first-surface anchor -- both invariant under
a rigid translation of the system (see
:func:`_scan_candidate_offsets`).
"""
# Seed offset relative to the first physical surface's vertex,
# expressed on the entry frame's transverse basis. Anchoring at the
# vertex (not the global origin) keeps the sweep width invariant
# under rigid translations.
path = self.optic.surfaces.build_paraxial_path()
anchor = path.vertices_gcs[1]
u, v = param.u, param.v
g_rel = (
to_float(px0) - to_float(anchor[0]),
to_float(py0) - to_float(anchor[1]),
to_float(pz0) - to_float(anchor[2]),
)
g_xi = g_rel[0] * u[0] + g_rel[1] * u[1] + g_rel[2] * u[2]
g_eta = g_rel[0] * v[0] + g_rel[1] * v[1] + g_rel[2] * v[2]
xi_off, eta_off = _scan_candidate_offsets(g_xi, g_eta, Hx, Hy, n)
ones = be.ones(n)
sx = ones * to_float(px0)
sy = ones * to_float(py0)
sz = ones * to_float(pz0)
L0 = ones * to_float(pL0)
M0 = ones * to_float(pM0)
N0 = ones * to_float(pN0)
wl_b = ones * to_float(wl_a)
tx_b = ones * to_float(tx)
ty_b = ones * to_float(ty)
# Candidates sit on the transverse line through the seed: binding
# the parameterization at repeated copies of the seed makes
# launch(0, 0) the seed itself and keeps the xi/eta displacements
# in the transverse plane for any entry direction.
bound = param.bind(sx, sy, sz, L0, M0, N0)
x0, y0, z0, L0, M0, N0 = bound.launch(xi_off, eta_off)
x, y, z, L, M, N, converged, _, report = self._iterative._solve_core(
x0, y0, z0, L0, M0, N0, wl_b, stop_idx, True, tx_b, ty_b, param=param
)
if not be.any(converged):
return None
# Among the converged candidates, take the one whose starting
# offset from the fresh seed is smallest: a wide sweep can also
# converge onto physically extreme solution branches far from the
# seed, and an extreme "chief" would poison the probe/affine
# calibration built on top of it. Nearest-to-seed stays on the
# seed's own branch.
conv_np = be.to_numpy(converged).reshape(-1)
offsets_np = (
be.to_numpy(xi_off).reshape(-1) ** 2 + be.to_numpy(eta_off).reshape(-1) ** 2
)
candidates = conv_np.nonzero()[0]
idx = int(candidates[offsets_np[candidates].argmin()])
launch = (
to_float(x[idx : idx + 1]),
to_float(y[idx : idx + 1]),
to_float(z[idx : idx + 1]),
to_float(L[idx : idx + 1]),
to_float(M[idx : idx + 1]),
to_float(N[idx : idx + 1]),
)
return launch, report
def _march_chief(
self,
Hx: float,
Hy: float,
wl_a: Any,
stop_idx: int,
is_inf: bool,
tx: Any,
ty: Any,
param: LaunchParameterization,
max_attempts: int = 150,
min_dt: float = 1e-4,
) -> tuple[tuple, SolveReport | None] | None:
"""March the chief ray from the axis out to (Hx, Hy) in field angle.
A step-halving walk -- each step a single-ray 2-DOF solve
warm-started from the *last successfully converged* launch, never
from a failed one -- replaces the old recursive homotopy as the
cold-start robustness mechanism (D8). It is bounded (a fixed attempt
budget, no recursion) and physically monotonic: only the free launch
DOF carries over between steps, while the fixed DOF (z, and
direction for infinite conjugates / object position for finite ones)
is refreshed from the paraxial trace at each step's actual field
angle.
A step size is never grown back up after a success: this system's
maximum reliable step tends to shrink (never grow) as the field
angle increases, so re-attempting a larger step every time just
wastes evaluations that repeatedly fail the same way.
Returns ``None`` if the walk cannot reach ``t=1`` (the actual
target field) within the attempt budget -- the caller must treat
this as a hard failure, not silently accept whatever intermediate
field angle happened to converge. Returning a wrong-but-converged
intermediate result here is exactly the failure mode this method
exists to prevent (see SPEC_ray_aiming_20260703.md D8): the fixed
launch DOF encodes the field angle itself, so a caller that used a
partial march's result as the final chief ray would be aiming at
the wrong field entirely, not just aiming imprecisely.
On success, returns ``(launch, report)`` where ``report`` is the
:class:`SolveReport` of the final full-tolerance solve (``None``
when the attempt budget ran out exactly at ``t = 1`` before that
solve could run).
"""
t = 0.0
# t=0 (the axis) is trivial and always converges: L=M=0, N=+-1.
launch = self._paraxial.aim_rays(
(be.array([0.0]), be.array([0.0])), wl_a, (be.array([0.0]), be.array([0.0]))
)
launch = tuple(to_float(v) for v in launch)
dt = 1.0
relaxed_tol = max(self._iterative.tol, 1e-4)
for _attempt in range(max_attempts):
if t >= 1.0:
# Intermediate steps used a relaxed tolerance as a cheap
# warm-start; do one final full-tolerance solve so the
# chief anchor itself is exact, not just "close enough".
x0 = be.array([launch[0]])
y0 = be.array([launch[1]])
z0 = be.array([launch[2]])
L0 = be.array([launch[3]])
M0 = be.array([launch[4]])
N0 = be.array([launch[5]])
x, y, z, L, M, N, converged, _, report = self._iterative._solve_core(
x0,
y0,
z0,
L0,
M0,
N0,
wl_a,
stop_idx,
is_inf,
tx,
ty,
param=param,
)
if be.any(converged):
return (
to_float(x),
to_float(y),
to_float(z),
to_float(L),
to_float(M),
to_float(N),
), report
return launch, report
t_next = min(t + dt, 1.0)
Hxt, Hyt = Hx * t_next, Hy * t_next
px0, py0, pz0, pL0, pM0, pN0 = self._paraxial.aim_rays(
(be.array([Hxt]), be.array([Hyt])),
wl_a,
(be.array([0.0]), be.array([0.0])),
)
# Carry only the free transverse offsets of the last converged
# launch onto this step's fresh paraxial seed (the fixed DOF --
# direction for infinite conjugates, object point for finite
# ones -- encodes the field angle and must stay fresh).
bound = param.bind(px0, py0, pz0, pL0, pM0, pN0)
xi, eta = bound.project(
be.array([launch[0]]),
be.array([launch[1]]),
be.array([launch[2]]),
be.array([launch[3]]),
be.array([launch[4]]),
be.array([launch[5]]),
)
x0, y0, z0, L0, M0, N0 = bound.launch(xi, eta)
with _relaxed_tolerance(self._iterative, relaxed_tol):
x, y, z, L, M, N, converged, _, _r = self._iterative._solve_core(
x0,
y0,
z0,
L0,
M0,
N0,
wl_a,
stop_idx,
is_inf,
tx,
ty,
param=param,
)
if not be.any(converged) and is_inf:
# The transverse launch warm-started from the previous step
# can occasionally be a worse seed than a fresh paraxial
# guess at the new angle (e.g. right where marching first
# takes a large stride); retry once from the fresh guess
# before giving up and shrinking the step.
with _relaxed_tolerance(self._iterative, relaxed_tol):
x, y, z, L, M, N, converged, _, _r = self._iterative._solve_core(
px0,
py0,
pz0,
pL0,
pM0,
pN0,
wl_a,
stop_idx,
is_inf,
tx,
ty,
param=param,
)
if be.any(converged):
launch = (
to_float(x),
to_float(y),
to_float(z),
to_float(L),
to_float(M),
to_float(N),
)
t = t_next
# Do not grow dt back up -- see docstring.
else:
# Retry the SAME target angle at half the step, warm-started
# from the last known-good launch -- never advance t on a
# failed step.
dt /= 2.0
if dt < min_dt:
return None
return (launch, None) if t >= 1.0 else None
def _solve_probe(
self,
wl: float,
stop_idx: int,
is_inf: bool,
Px_e: float,
Py_e: float,
r_stop: float,
chief: tuple[float, float, float, float, float, float],
param: LaunchParameterization,
) -> tuple[tuple, bool]:
"""Solve one cardinal edge probe, seeded from the chief launch.
Returns:
tuple: ``(launch, fell_back)`` -- the probe launch state and
whether it fell back to the chief launch because the probe
failed to converge.
"""
x0, y0, z0, L0, M0, N0 = (be.array([v]) for v in chief)
wl_a = be.array([wl])
tx = be.array([Px_e * r_stop])
ty = be.array([Py_e * r_stop])
x, y, z, L, M, N, converged, _, _r = self._iterative._solve_core(
x0, y0, z0, L0, M0, N0, wl_a, stop_idx, is_inf, tx, ty, param=param
)
if not be.any(converged):
# An unreachable edge probe still yields a usable (if less
# accurate) linear seed by falling back to the chief launch,
# rather than propagating NaN into the affine fit.
return chief, True
return (
to_float(x),
to_float(y),
to_float(z),
to_float(L),
to_float(M),
to_float(N),
), False
def _fit_affine(
self,
chief: tuple[float, float, float, float, float, float],
probes: list[tuple[float, float, float, float, float, float]],
param: LaunchParameterization,
) -> PupilMap:
"""Fit the 2x2 affine launch model from the chief ray + 4 probes.
The probes' launch states are projected into the chief-bound local
transverse parameterization, so the fitted offsets are (xi, eta)
coordinates -- valid for any entry direction, and stored as plain
floats (detached by design).
"""
p_east, p_west, p_north, p_south = probes
bound = param.bind(*(be.array([v]) for v in chief))
def free(state: tuple) -> tuple[float, float]:
xi, eta = bound.project(*(be.array([v]) for v in state))
return to_float(xi), to_float(eta)
e1, e2 = free(p_east)
w1, w2 = free(p_west)
n1, n2 = free(p_north)
s1, s2 = free(p_south)
A = (
((e1 - w1) / 2.0, (n1 - s1) / 2.0),
((e2 - w2) / 2.0, (n2 - s2) / 2.0),
)
return PupilMap(base=chief, A=A, param=param)