"""Folded-path paraxial metadata.
This module is the single source of truth for the scalar folded paraxial
model introduced by issues #726/#728: it walks the surface chain once and
records, per surface, the vertex and local axis in global coordinates, the
incoming/outgoing beam directions, the reflection parity, the signed unfolded
axial coordinate, and the orientation sign that powered surfaces need for
their paraxial-effective radius. Every consumer of folded first-order data
(``SurfaceGroup.positions``, the paraxial ray tracer, the ray aimers, the
pupil-point helpers) builds or receives one :class:`ParaxialPath` instead of
re-deriving frames, directions and parity independently.
Supported scalar domain: piecewise-centered systems whose changes of
propagation direction are produced by plane fold mirrors, entered along an
arbitrary finite unit direction. Powered surfaces must be normal to their
local propagation segment. Anything outside that domain (oblique powered
mirrors, tilted powered refractive surfaces, refractive interfaces that steer
the nominal axis, transversely decentered vertex chains, non-object surfaces
at infinity on a folded arm) is recorded as a diagnostic and rejected with
:class:`UnsupportedParaxialGeometryError` when scalar paraxial analysis or
ray aiming is requested -- real ray tracing remains available for physically
valid geometries even when the scalar paraxial model is rejected.
Kramer Harrison, 2026
"""
from __future__ import annotations
import contextlib
import contextvars
import warnings
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
import optiland.backend as be
if TYPE_CHECKING:
from optiland._types import BEArray, ScalarOrArray
# A direction this close to an axis is treated as on that axis. This is the
# float64 intent; use angular_tolerance() for a dtype-aware value.
_AXIS_TOL = 1e-10
# Machine epsilons by floating precision, used to widen tolerances when the
# backend computes in float32.
_EPS_BY_PRECISION = {"float32": 1.1920929e-07, "float64": 2.220446049250313e-16}
# Diagnostic codes (stable identifiers; error messages reference them).
DEGENERATE_ENTRY_AXIS = "DEGENERATE_ENTRY_AXIS"
NONCOLLINEAR_VERTEX_CHAIN = "NONCOLLINEAR_VERTEX_CHAIN"
TILTED_REFRACTIVE_SURFACE = "TILTED_REFRACTIVE_SURFACE"
OBLIQUE_POWERED_MIRROR = "OBLIQUE_POWERED_MIRROR"
UNSUPPORTED_PARAXIAL_INTERACTION = "UNSUPPORTED_PARAXIAL_INTERACTION"
NONOBJECT_INFINITY = "NONOBJECT_INFINITY"
AMBIGUOUS_WIDE_ANGLE_FIELD = "AMBIGUOUS_WIDE_ANGLE_FIELD"
SINGULAR_ANGLE_TANGENT = "SINGULAR_ANGLE_TANGENT"
[docs]
class UnsupportedParaxialGeometryError(ValueError):
"""A geometry outside the supported scalar folded paraxial domain.
Raised instead of silently returning plausible-but-wrong first-order
numbers. Real ray tracing (``optic.surfaces.trace``) remains available
for the same geometry.
"""
[docs]
class ParaxialDomainWarning(UserWarning):
"""Scalar paraxial values are approximate for this geometry.
Emitted (instead of :class:`UnsupportedParaxialGeometryError`) when the
out-of-domain scalar paraxial engine is used only to seed a real-ray
iterative solve -- the final aimed rays are verified against real
traces, so the approximation never surfaces as a first-order result.
"""
# While set, out-of-domain scalar paraxial use warns instead of raising.
# The ray aimers set this around their solves: their paraxial numbers are
# only seeds for a real-ray Newton polish, so an approximate value is useful
# there and the exactness guarantee comes from the real traces. Direct
# first-order analysis (``Paraxial.f2()`` and friends) stays strict.
_SEED_SCOPE = contextvars.ContextVar("optiland_paraxial_seed_scope", default=False)
[docs]
@contextlib.contextmanager
def paraxial_seed_scope():
"""Scope in which scalar paraxial values serve only as real-ray seeds."""
token = _SEED_SCOPE.set(True)
try:
yield
finally:
_SEED_SCOPE.reset(token)
[docs]
def in_paraxial_seed_scope() -> bool:
"""Whether a paraxial seed scope is currently active."""
return _SEED_SCOPE.get()
[docs]
def angular_tolerance() -> float:
"""Angular collinearity tolerance, backend/dtype aware.
Returns the float64 intent of ``1e-10`` widened when the active backend
computes at lower precision.
"""
try:
precision = str(be.get_precision())
except Exception:
precision = "float64"
eps = _EPS_BY_PRECISION["float32" if "32" in precision else "float64"]
return max(_AXIS_TOL, 100.0 * eps)
[docs]
def position_tolerance(characteristic_scale: float) -> float:
"""Spatial residual tolerance scaled to the system's size.
Args:
characteristic_scale: A length characterizing the system, e.g. the
largest finite vertex coordinate magnitude.
"""
return angular_tolerance() * max(1.0, abs(characteristic_scale))
[docs]
def tangent_singularity_tolerance_deg() -> float:
"""Rejection half-width (degrees) around the odd multiples of 90 degrees.
A component field angle closer than this to ``90 + k * 180`` degrees is
rejected before its tangent is evaluated: floating-point ``tan`` returns
a huge finite number there instead of failing, and every quantity built
from it (launch points, object positions, chief-ray scales) silently
loses its meaning.
The width is derived from the active backend precision. The tangent's
relative conditioning error at distance ``delta`` (radians) from the
pole is approximately ``eps * (pi / 2) / delta``; requiring it to stay
below ``sqrt(eps)`` -- i.e. the value keeps at least half its
significant digits -- gives ``delta >= (pi / 2) * sqrt(eps)``, which is
``90 * sqrt(eps)`` in degrees. For float64 this is ~1.3e-6 degrees; for
float32 ~0.031 degrees. Valid nonsingular one-dimensional wide fields
(89, 91, 95, 105 degrees, ...) lie far outside it.
"""
try:
precision = str(be.get_precision())
except Exception:
precision = "float64"
eps = _EPS_BY_PRECISION["float32" if "32" in precision else "float64"]
return 90.0 * eps**0.5
[docs]
def require_nonsingular_tangent_angles(
*components_deg, operation: str = "angle-field evaluation"
) -> None:
"""Reject field-angle components whose tangent is numerically singular.
Call before every ``tan(angle)`` evaluation on component field angles.
Angles within :func:`tangent_singularity_tolerance_deg` of an odd
multiple of 90 degrees raise; everything else passes through untouched.
Args:
*components_deg: Angle components in degrees (scalars or arrays).
operation: Name of the calling operation, used in the message.
Raises:
UnsupportedParaxialGeometryError: With code
``SINGULAR_ANGLE_TANGENT`` for the first offending component.
"""
tol = tangent_singularity_tolerance_deg()
for component in components_deg:
values = be.to_numpy(be.atleast_1d(be.array(component))).reshape(-1)
distance = abs(abs(values) % 180.0 - 90.0)
bad = distance <= tol
if bad.any():
i = int(bad.nonzero()[0][0])
raise UnsupportedParaxialGeometryError(
f"[{SINGULAR_ANGLE_TANGENT}] {operation}: the field angle "
f"component {values[i]:.10g} deg lies within {tol:.3g} deg "
"of an odd multiple of 90 degrees, where its tangent is "
"numerically singular (floating-point tan returns a huge "
"finite number instead of failing). Use a field angle away "
"from the pole; nonsingular wide angles such as 89, 91, 95 "
"or 105 degrees remain supported."
)
[docs]
@dataclass(frozen=True)
class ParaxialPathDiagnostic:
"""One reason a path lies outside the supported scalar domain.
Attributes:
code: Stable diagnostic code (one of the module-level constants).
surface_index: Index of the offending surface or leg, or ``None``.
measured: The measured value that tripped the check, or ``None``.
tolerance: The tolerance it was compared against, or ``None``.
message: Concise human-readable explanation.
"""
code: str
surface_index: int | None
measured: float | None
tolerance: float | None
message: str
def __str__(self) -> str:
parts = [f"[{self.code}]"]
if self.surface_index is not None:
parts.append(f"surface {self.surface_index}:")
parts.append(self.message)
if self.measured is not None:
parts.append(f"(measured {self.measured:.6g}")
if self.tolerance is not None:
parts.append(f"vs tolerance {self.tolerance:.6g})")
else:
parts.append(")")
return " ".join(parts)
def _to_float(value: ScalarOrArray) -> float:
"""Plain Python float from a scalar or 0-d/1-element backend array."""
return float(be.to_numpy(be.array(value)).reshape(-1)[0])
def _dot(a: tuple, b: tuple) -> ScalarOrArray:
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
def _norm(a: tuple) -> ScalarOrArray:
return be.sqrt(a[0] * a[0] + a[1] * a[1] + a[2] * a[2])
def _normalize(a: tuple) -> tuple:
norm = _norm(a)
return (a[0] / norm, a[1] / norm, a[2] / norm)
def _cross(a: tuple, b: tuple) -> tuple:
return (
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
)
def _is_finite_vec(a: tuple) -> bool:
return all(bool(be.all(be.isfinite(be.array(_to_float(c))))) for c in a)
[docs]
def transverse_basis(direction: tuple) -> tuple[tuple, tuple]:
"""Deterministic transverse pair ``(u, v)`` completing ``direction``.
``v`` is global +y projected off the axis and normalized -- the sagittal
direction of a fold in the x-z plane. Within :func:`angular_tolerance` of
a +/-y entry that projection degenerates (the unavoidable pole of any
deterministic rule), and ``u`` becomes global +x projected instead.
``(u, v, direction)`` is right-handed, and for a +z entry the pair
reduces exactly to the global +x/+y axes, so field semantics continue
the on-axis meaning. The gauge is deliberately a global reference --
never the object surface's orientation -- so a tilted or rolled object
plane cannot roll the field axes.
"""
d = direction
y_proj = (
be.array(0.0) - d[1] * d[0],
be.array(1.0) - d[1] * d[1],
be.array(0.0) - d[1] * d[2],
)
norm = _norm(y_proj)
# The pole decision is dtype-aware: angular_tolerance() widens with the
# active backend precision, so a float32 run switches to the +x gauge
# while the projection still carries meaningful digits.
if bool(be.all(norm > angular_tolerance())):
v = tuple(c / norm for c in y_proj)
u = _cross(v, d)
return u, v
x_proj = (
be.array(1.0) - d[0] * d[0],
be.array(0.0) - d[0] * d[1],
be.array(0.0) - d[0] * d[2],
)
norm = _norm(x_proj)
u = tuple(c / norm for c in x_proj)
v = _cross(d, u)
return u, v
[docs]
@dataclass(frozen=True)
class ParaxialPath:
"""Immutable per-operation snapshot of the folded-path metadata.
All vectors are in global coordinates (GCS), stored as tuples of backend
scalars. ``axial_positions`` is the signed unfolded axial coordinate the
scalar paraxial model is written in -- not a global Cartesian z (use
``vertices_gcs`` for real-space points).
Attributes:
axial_positions: Signed unfolded axial coordinate per surface.
vertices_gcs: Surface vertex positions in GCS.
local_z_axes_gcs: Surface local +z axes in GCS.
incoming_directions_gcs: Physical beam direction arriving at each
surface.
outgoing_directions_gcs: Physical beam direction leaving each surface.
parity_before: Reflection parity (+1/-1) before each surface.
parity_after: Reflection parity after each surface.
orientation_sign: Per-surface sign ``s_k = parity_before *
sgn(z_axis . incoming_direction)`` that maps authored radii and
focal lengths to paraxial-effective ones.
entry_direction: Unit beam direction on the entry leg.
entry_u: First transverse basis vector of the entry frame.
entry_v: Second transverse basis vector of the entry frame.
all_legs_parallel_global_z: Every finite propagation leg is parallel
to global +/-z (true also for retro and -z-entered systems).
positions_are_global_z: ``axial_positions`` coincides bit-for-bit
with the global z of each vertex (entry along +z, every mirror
normal on the z axis). This -- not
``all_legs_parallel_global_z`` -- is the capability check for
operations that write axial offsets into ``cs.z``.
legacy_aiming_compatible: The historical global-z aiming branch is
exactly correct: positions are global z AND the entry line runs
through global x = y = 0 along +z.
is_folded_or_off_axis: Negation of ``positions_are_global_z``.
diagnostics: Reasons this path lies outside the supported scalar
domain (empty for supported paths).
advisories: Findings on a *supported* straight path that scalar
first-order results silently ignore (tilted powered surfaces,
interior decenters). Unlike ``diagnostics`` these never gate an
operation -- the historical numbers are still returned -- they
only make the approximation visible via
:meth:`warn_scalar_approximations`.
axis_alignments: Per-surface alignment ``z_axis . incoming_direction``
between the surface's local +z axis and the physical beam
arriving at it. ``|alignment| == 1`` (within
:func:`angular_tolerance`) identifies a centered/collinear
surface; anything else is genuinely oblique.
"""
axial_positions: BEArray
vertices_gcs: tuple
local_z_axes_gcs: tuple
incoming_directions_gcs: tuple
outgoing_directions_gcs: tuple
parity_before: tuple
parity_after: tuple
orientation_sign: tuple
entry_direction: tuple
entry_u: tuple
entry_v: tuple
all_legs_parallel_global_z: bool
positions_are_global_z: bool
legacy_aiming_compatible: bool
is_folded_or_off_axis: bool
diagnostics: tuple
advisories: tuple = ()
axis_alignments: tuple = ()
@property
def num_surfaces(self) -> int:
return len(self.vertices_gcs)
@property
def orientation_sign_array(self) -> BEArray:
"""Orientation signs as a backend array aligned with the surfaces."""
return be.array([float(s) for s in self.orientation_sign])
[docs]
def effective_orientation_signs(self) -> tuple:
"""Per-surface signs mapping authored powers to scalar-effective ones.
This is the single collinear orientation policy shared by the
explicit paraxial tracer and the ray-transfer-matrix assembly
(they must never disagree because they selected different power
conventions):
- A centered/collinear surface (``|1 - |z_axis . d_in|| <=``
:func:`angular_tolerance`) gets ``s_k = parity_before *
sgn(z_axis . d_in)`` -- on straight and folded paths alike, so a
physically equivalent surface authored with its local axis
reversed is normalized to the same effective scalar power. The
canonical default authoring always has ``s_k = +1``, preserving
historical values bit-for-bit.
- A genuinely oblique surface on a straight-classified path keeps
the historical raw value (sign ``+1``); the approximation is
surfaced via :meth:`warn_scalar_approximations`, never silently
re-signed by a heuristic.
- A genuinely oblique surface on a folded/off-axis path is outside
the scalar domain (``require_scalar_paraxial`` raises). Inside a
:func:`paraxial_seed_scope` -- where that rejection is downgraded
to a warning because the values only seed a real-ray solve -- the
collinear-limit sign is kept, which is the continuous limit of
the supported geometry as the tilt goes to zero.
"""
if self.is_folded_or_off_axis or not self.axis_alignments:
return self.orientation_sign
tol = angular_tolerance()
return tuple(
sign if abs(abs(alignment) - 1.0) <= tol else 1.0
for sign, alignment in zip(
self.orientation_sign, self.axis_alignments, strict=True
)
)
@property
def entry_is_positive_z(self) -> bool:
"""Whether the entry direction is global +z within tolerance."""
tol = angular_tolerance()
ex = abs(_to_float(self.entry_direction[0]))
ey = abs(_to_float(self.entry_direction[1]))
ez = _to_float(self.entry_direction[2])
return ex <= tol and ey <= tol and ez > 0.0
[docs]
def require_scalar_paraxial(self, operation: str = "scalar paraxial analysis"):
"""Reject this path if it lies outside the supported scalar domain.
Raises for direct first-order analysis. Inside a
:func:`paraxial_seed_scope` (the ray aimers' solves, where scalar
paraxial values only seed a real-ray Newton polish) the same
finding is reported as a :class:`ParaxialDomainWarning` instead, so
aiming for e.g. slightly tilted stop mirrors keeps working -- its
exactness is guaranteed by the real traces, not the seed.
Args:
operation: Name of the requested operation, used in the message.
Raises:
UnsupportedParaxialGeometryError: If any diagnostic was recorded
and no seed scope is active.
"""
if not self.diagnostics:
return
details = "\n".join(f" - {d}" for d in self.diagnostics)
message = (
f"{operation} is not supported for this geometry: the system "
f"lies outside the scalar folded paraxial domain "
f"(piecewise-centered legs joined by plane fold mirrors, powered "
f"surfaces normal to their local beam segment).\n{details}\n"
"Real ray tracing (optic.surfaces.trace) remains available for "
"this geometry; a general vector paraxial model would be "
"required for first-order analysis."
)
if in_paraxial_seed_scope():
warnings.warn(
"scalar paraxial values are approximate for this geometry "
"and are used only to seed a real-ray solve:\n" + message,
ParaxialDomainWarning,
stacklevel=2,
)
return
raise UnsupportedParaxialGeometryError(message)
[docs]
def warn_scalar_approximations(
self, operation: str = "scalar paraxial analysis"
) -> None:
"""Surface any advisories as a :class:`ParaxialDomainWarning`.
Straight +z systems with tilted powered surfaces or interior
decenters have always had their scalar first-order values computed
as if every surface were centered and normal to the axis. That
behavior (and every returned number) is unchanged; this method only
makes the approximation visible instead of silent. Real ray tracing
accounts for the tilt/decenter exactly.
Args:
operation: Name of the requested operation, used in the message.
"""
if not self.advisories:
return
details = "\n".join(f" - {d}" for d in self.advisories)
warnings.warn(
f"{operation}: scalar first-order results ignore surface "
"tilts/decenters on this system (each surface is treated as "
"centered and normal to the axis, the historical behavior):\n"
f"{details}\n"
"Real ray tracing (optic.surfaces.trace) accounts for them "
"exactly.",
ParaxialDomainWarning,
stacklevel=2,
)
[docs]
def point_from_axial_offset(
self,
surface_index: int,
axial_offset: ScalarOrArray,
side: Literal["incoming", "outgoing"] = "incoming",
) -> tuple:
"""Map an unfolded axial offset from a surface vertex to a GCS point.
The point is ``r_k + parity * offset * direction`` with the parity
and physical direction of the selected side of surface ``k``, which
is how a signed axial distance (an ``EPL``/``XPL``-style scalar)
becomes a real-space location on the correct leg.
Args:
surface_index: Surface the offset is measured from (negative
indices allowed, as in normal sequence indexing).
axial_offset: Signed unfolded axial offset from that surface.
side: Whether to use the incoming or outgoing beam direction
and parity at that surface.
Returns:
The GCS point as an ``(x, y, z)`` tuple of backend scalars.
"""
vertex = self.vertices_gcs[surface_index]
if side == "incoming":
parity = self.parity_before[surface_index]
direction = self.incoming_directions_gcs[surface_index]
else:
parity = self.parity_after[surface_index]
direction = self.outgoing_directions_gcs[surface_index]
return tuple(vertex[i] + parity * axial_offset * direction[i] for i in range(3))
[docs]
def entry_frame(self) -> tuple:
"""Entry frame ``(anchor, axial_anchor, direction, u, v)``.
``anchor`` is the first physical surface's vertex, ``axial_anchor``
its unfolded axial coordinate, ``direction`` the unit entry
direction, and ``(u, v)`` the transverse basis completing it (see
:func:`transverse_basis` for the gauge convention and its pole).
"""
anchor = self.vertices_gcs[1]
axial = self.axial_positions[1]
return anchor, axial, self.entry_direction, self.entry_u, self.entry_v
def _entry_direction(frames: list) -> tuple[tuple, bool]:
"""Unit vector of the first leg, object vertex to first surface vertex.
Read off the two vertices rather than off the object surface's own
orientation: a tilted object plane does not steer the beam, so its
normal is not the axis. An object at infinity leaves an infinite
component in whichever axes the beam runs along, which carries the
direction on its own once the finite components are dropped.
Returns:
``(direction, degenerate)`` where ``degenerate`` is True when the
+z default was used because there was nothing to read -- a lone
surface, or an object sitting on top of the first surface.
"""
default = (be.array(0.0), be.array(0.0), be.array(1.0))
if len(frames) < 2:
return default, True
first, second = frames[0][0], frames[1][0]
step = [second[k] - first[k] for k in range(3)]
diverging = [bool(be.any(be.isinf(be.array(s)))) for s in step]
if any(diverging):
step = [
be.sign(s) if is_inf else be.array(0.0)
for s, is_inf in zip(step, diverging, strict=True)
]
norm = be.sqrt(sum(s * s for s in step))
if not bool(be.all(norm > _AXIS_TOL)):
return default, True
return tuple(s / norm for s in step), False
def _off_axis(vector: tuple) -> bool:
"""Whether a unit vector points anywhere but along +/-z."""
return bool(abs(vector[0]) > _AXIS_TOL) or bool(abs(vector[1]) > _AXIS_TOL)
def _surface_is_powered(surface) -> bool:
"""Whether a surface bends paraxial rays (finite radius or explicit f)."""
if getattr(surface, "surface_type", None) == "paraxial":
return True
radius = getattr(getattr(surface, "geometry", None), "radius", None)
if radius is None:
return False
return bool(be.all(be.isfinite(be.array(_to_float(radius)))))
def _surface_is_refractive_boundary(surface) -> bool:
"""Whether a surface separates two different materials (and refracts)."""
pre = getattr(surface, "material_pre", None)
post = getattr(surface, "material_post", None)
if pre is None or post is None:
return False
try:
return pre != post
except Exception:
return True
[docs]
def build_paraxial_path(surfaces: list) -> ParaxialPath:
"""Walk the surface chain once and assemble the shared path metadata.
Args:
surfaces: Ordered sequence of Surface objects (object surface first).
Returns:
The assembled :class:`ParaxialPath`. Unsupported-geometry findings
are recorded as diagnostics on the path (this function never raises
for them); call ``path.require_scalar_paraxial()`` at the boundary
of any scalar first-order computation.
"""
frames = [surf.geometry.cs.frame_in_gcs for surf in surfaces]
mirrors = [
bool(getattr(surf.interaction_model, "is_reflective", False))
for surf in surfaces
]
n = len(frames)
vertices = tuple(frames[k][0] for k in range(n))
axes = tuple(frames[k][1] for k in range(n))
entry, degenerate_entry = _entry_direction(frames)
# A finite object authored past the first surface (a virtual object on a
# +z chain) reads as a -z first leg off the vertices, but the beam still
# travels +z. Only that exact historical case is overridden; a chain
# whose physical legs also descend in z is a genuinely -z-entered system.
object_is_infinite = (
bool(getattr(surfaces[0], "is_infinite", False)) if n else False
)
if (
n >= 3
and not object_is_infinite
and not degenerate_entry
and not _off_axis(entry)
and _to_float(entry[2]) < 0.0
):
first_leg = [vertices[2][k] - vertices[1][k] for k in range(3)]
leg_norm = be.sqrt(sum(s * s for s in first_leg))
if bool(be.all(be.isfinite(leg_norm))) and _to_float(leg_norm) > _AXIS_TOL:
leg_dir = tuple(s / leg_norm for s in first_leg)
if not _off_axis(leg_dir) and _to_float(leg_dir[2]) > 0.0:
entry = (be.array(0.0), be.array(0.0), be.array(1.0))
any_mirror_off_axis = any(
is_mirror and _off_axis(normal)
for (_, normal), is_mirror in zip(frames, mirrors, strict=True)
)
entry_off_axis = _off_axis(entry)
entry_negative_z = (not entry_off_axis) and n >= 2 and _to_float(entry[2]) < 0.0
positions_are_global_z = (
not entry_off_axis and not entry_negative_z and not any_mirror_off_axis
)
is_folded_or_off_axis = not positions_are_global_z
ang_tol = angular_tolerance()
diagnostics: list[ParaxialPathDiagnostic] = []
advisories: list[ParaxialPathDiagnostic] = []
if is_folded_or_off_axis and degenerate_entry and any_mirror_off_axis:
diagnostics.append(
ParaxialPathDiagnostic(
code=DEGENERATE_ENTRY_AXIS,
surface_index=0,
measured=None,
tolerance=_AXIS_TOL,
message=(
"the entry direction cannot be inferred (object vertex "
"coincides with the first surface vertex) and the system "
"is folded, so the +z fallback is not unambiguously valid"
),
)
)
# Characteristic scale for spatial tolerances: largest finite vertex
# coordinate magnitude.
scale = 1.0
for vertex in vertices:
for component in vertex:
value = _to_float(component)
if abs(value) != float("inf") and abs(value) > scale:
scale = abs(value)
pos_tol = position_tolerance(scale)
# --- The walk -------------------------------------------------------
direction = entry
parity = 1.0
incoming: list[tuple] = []
outgoing: list[tuple] = []
parity_before: list[float] = []
parity_after: list[float] = []
orientation: list[float] = []
alignments: list[float] = []
axial: list = []
if n:
first = vertices[0]
axial.append(
first[2]
if _is_finite_vec(first)
else sum(first[k] * direction[k] for k in range(3))
)
for k in range(n):
incoming.append(direction)
parity_before.append(parity)
axis_dot = _to_float(_dot(axes[k], direction))
sign = 1.0 if axis_dot >= 0.0 else -1.0
orientation.append(parity * sign)
alignments.append(axis_dot)
surf = surfaces[k]
powered = _surface_is_powered(surf) if k > 0 else False
oblique = abs(abs(axis_dot) - 1.0) > ang_tol
if is_folded_or_off_axis and k > 0 and oblique:
if powered and mirrors[k]:
diagnostics.append(
ParaxialPathDiagnostic(
code=OBLIQUE_POWERED_MIRROR,
surface_index=k,
measured=axis_dot,
tolerance=ang_tol,
message=(
"powered mirror at oblique incidence is "
"astigmatic; |axis . beam| must equal 1"
),
)
)
elif powered:
diagnostics.append(
ParaxialPathDiagnostic(
code=TILTED_REFRACTIVE_SURFACE,
surface_index=k,
measured=axis_dot,
tolerance=ang_tol,
message=(
"tilted powered refractive surface introduces "
"transverse first-order coupling; |axis . beam| "
"must equal 1"
),
)
)
elif not mirrors[k] and _surface_is_refractive_boundary(surf):
diagnostics.append(
ParaxialPathDiagnostic(
code=TILTED_REFRACTIVE_SURFACE,
surface_index=k,
measured=axis_dot,
tolerance=ang_tol,
message=(
"tilted plane refractive interface steers the "
"nominal axis (Snell refraction), which the "
"scalar folded path does not model"
),
)
)
elif k > 0 and oblique:
# Straight-classified path: the numbers stay the historical
# ones (tilt ignored); record an advisory so the approximation
# is no longer silent.
if powered:
advisories.append(
ParaxialPathDiagnostic(
code=TILTED_REFRACTIVE_SURFACE,
surface_index=k,
measured=axis_dot,
tolerance=ang_tol,
message=(
"tilted powered surface on a straight system; "
"scalar first-order results treat it as normal "
"to the axis"
),
)
)
elif not mirrors[k] and _surface_is_refractive_boundary(surf):
advisories.append(
ParaxialPathDiagnostic(
code=TILTED_REFRACTIVE_SURFACE,
surface_index=k,
measured=axis_dot,
tolerance=ang_tol,
message=(
"tilted plane refractive interface on a straight "
"system; the real beam is steered (Snell), which "
"scalar first-order results ignore"
),
)
)
if mirrors[k]:
projection = _dot(direction, axes[k])
direction = tuple(
direction[i] - 2 * projection * axes[k][i] for i in range(3)
)
direction = _normalize(direction)
parity = -parity
outgoing.append(direction)
parity_after.append(parity)
if k + 1 < n:
nxt = vertices[k + 1]
prev = vertices[k]
step = sum((nxt[i] - prev[i]) * direction[i] for i in range(3))
if be.all(be.isfinite(axial[-1])) and be.all(be.isfinite(step)):
axial.append(axial[-1] + parity * step)
residual = tuple(
(nxt[i] - prev[i]) - step * direction[i] for i in range(3)
)
residual_norm = _to_float(_norm(residual))
if residual_norm > pos_tol:
if is_folded_or_off_axis:
diagnostics.append(
ParaxialPathDiagnostic(
code=NONCOLLINEAR_VERTEX_CHAIN,
surface_index=k + 1,
measured=residual_norm,
tolerance=pos_tol,
message=(
"vertex lies off the physical beam leg "
"(transverse decenter); the scalar folded "
"model has no transverse first-order "
"coupling to carry it"
),
)
)
else:
advisories.append(
ParaxialPathDiagnostic(
code=NONCOLLINEAR_VERTEX_CHAIN,
surface_index=k + 1,
measured=residual_norm,
tolerance=pos_tol,
message=(
"decentered surface on a straight "
"system; scalar first-order results "
"treat it as centered on the axis"
),
)
)
else:
# A leg to or from infinity carries no fold, so re-anchor on
# global z instead of accumulating an inf that would cancel
# into a nan.
axial.append(nxt[2])
if is_folded_or_off_axis and not _is_finite_vec(nxt):
diagnostics.append(
ParaxialPathDiagnostic(
code=NONOBJECT_INFINITY,
surface_index=k + 1,
measured=None,
tolerance=None,
message=(
"non-object surface at infinity on a folded "
"or off-axis path; the unfolded axial "
"coordinate cannot anchor it"
),
)
)
# Legacy read: while every leg runs along +/-z entered along +z, the
# axial coordinate is exactly the global z of each vertex -- returned
# bit-for-bit unchanged (the walk above would agree to round-off, but
# the canonical path must be exact).
if positions_are_global_z:
axial_positions = be.array([origin[2] for origin, _ in frames])
else:
axial_positions = be.array(axial) if axial else be.array([])
entry_u, entry_v = transverse_basis(entry)
all_parallel = not entry_off_axis and not any_mirror_off_axis
anchor_on_origin = False
if n >= 2:
ax = abs(_to_float(vertices[1][0]))
ay = abs(_to_float(vertices[1][1]))
anchor_on_origin = ax <= pos_tol and ay <= pos_tol
legacy_aiming_compatible = positions_are_global_z and (n < 2 or anchor_on_origin)
return ParaxialPath(
axial_positions=axial_positions,
vertices_gcs=vertices,
local_z_axes_gcs=axes,
incoming_directions_gcs=tuple(incoming),
outgoing_directions_gcs=tuple(outgoing),
parity_before=tuple(parity_before),
parity_after=tuple(parity_after),
orientation_sign=tuple(orientation),
entry_direction=entry,
entry_u=entry_u,
entry_v=entry_v,
all_legs_parallel_global_z=all_parallel,
positions_are_global_z=positions_are_global_z,
legacy_aiming_compatible=legacy_aiming_compatible,
is_folded_or_off_axis=is_folded_or_off_axis,
diagnostics=tuple(diagnostics),
advisories=tuple(advisories),
axis_alignments=tuple(alignments),
)
[docs]
def require_global_z_geometry(surfaces, operation: str) -> None:
"""Guard for operations that write axial offsets into global ``cs.z``.
Args:
surfaces: The ``SurfaceGroup`` (or surface sequence) to check.
operation: Human-readable name of the gated operation.
Raises:
UnsupportedParaxialGeometryError: If the unfolded axial coordinate
does not coincide with global z, so a scalar z-offset mutation
would move surfaces off their physical legs.
"""
surface_list = list(surfaces)
path = build_paraxial_path(surface_list)
if path.positions_are_global_z:
return
raise UnsupportedParaxialGeometryError(
f"{operation} mutates surface positions along global z only, but "
"this system's beam path is folded off the +z axis (or entered "
"along a different direction), so its unfolded axial coordinate is "
"not global z. Applying the offset would move surfaces off their "
"physical legs. A future implementation must translate downstream "
"geometry by delta_r = parity * delta_q * leg_direction instead. "
"No geometry was modified."
)