paraxial_path#

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 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 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

Functions

angular_tolerance()

Angular collinearity tolerance, backend/dtype aware.

build_paraxial_path(surfaces)

Walk the surface chain once and assemble the shared path metadata.

in_paraxial_seed_scope()

Whether a paraxial seed scope is currently active.

paraxial_seed_scope()

Scope in which scalar paraxial values serve only as real-ray seeds.

position_tolerance(characteristic_scale)

Spatial residual tolerance scaled to the system's size.

require_global_z_geometry(surfaces, operation)

Guard for operations that write axial offsets into global cs.z.

require_nonsingular_tangent_angles(...[, ...])

Reject field-angle components whose tangent is numerically singular.

tangent_singularity_tolerance_deg()

Rejection half-width (degrees) around the odd multiples of 90 degrees.

transverse_basis(direction)

Deterministic transverse pair (u, v) completing direction.

Classes

ParaxialPath(axial_positions, vertices_gcs, ...)

Immutable per-operation snapshot of the folded-path metadata.

ParaxialPathDiagnostic(code, surface_index, ...)

One reason a path lies outside the supported scalar domain.

Exceptions

ParaxialDomainWarning

Scalar paraxial values are approximate for this geometry.

UnsupportedParaxialGeometryError

A geometry outside the supported scalar folded paraxial domain.

exception ParaxialDomainWarning[source]#

Scalar paraxial values are approximate for this geometry.

Emitted (instead of 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.

class ParaxialPath(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 = ())[source]#

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).

Variables:
  • axial_positions (BEArray) – Signed unfolded axial coordinate per surface.

  • vertices_gcs (tuple) – Surface vertex positions in GCS.

  • local_z_axes_gcs (tuple) – Surface local +z axes in GCS.

  • incoming_directions_gcs (tuple) – Physical beam direction arriving at each surface.

  • outgoing_directions_gcs (tuple) – Physical beam direction leaving each surface.

  • parity_before (tuple) – Reflection parity (+1/-1) before each surface.

  • parity_after (tuple) – Reflection parity after each surface.

  • orientation_sign (tuple) – 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 (tuple) – Unit beam direction on the entry leg.

  • entry_u (tuple) – First transverse basis vector of the entry frame.

  • entry_v (tuple) – Second transverse basis vector of the entry frame.

  • all_legs_parallel_global_z (bool) – Every finite propagation leg is parallel to global +/-z (true also for retro and -z-entered systems).

  • positions_are_global_z (bool) – 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 (bool) – 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 (bool) – Negation of positions_are_global_z.

  • diagnostics (tuple) – Reasons this path lies outside the supported scalar domain (empty for supported paths).

  • advisories (tuple) – 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 warn_scalar_approximations().

  • axis_alignments (tuple) – Per-surface alignment z_axis . incoming_direction between the surface’s local +z axis and the physical beam arriving at it. |alignment| == 1 (within angular_tolerance()) identifies a centered/collinear surface; anything else is genuinely oblique.

advisories: tuple = ()#
all_legs_parallel_global_z: bool#
axial_positions: BEArray#
axis_alignments: tuple = ()#
diagnostics: tuple#
effective_orientation_signs() tuple[source]#

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|| <= 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 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 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.

entry_direction: tuple#
entry_frame() tuple[source]#

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 transverse_basis() for the gauge convention and its pole).

property entry_is_positive_z: bool#

Whether the entry direction is global +z within tolerance.

entry_u: tuple#
entry_v: tuple#
incoming_directions_gcs: tuple#
is_folded_or_off_axis: bool#
legacy_aiming_compatible: bool#
local_z_axes_gcs: tuple#
property num_surfaces: int#
orientation_sign: tuple#
property orientation_sign_array: BEArray#

Orientation signs as a backend array aligned with the surfaces.

outgoing_directions_gcs: tuple#
parity_after: tuple#
parity_before: tuple#
point_from_axial_offset(surface_index: int, axial_offset: ScalarOrArray, side: Literal['incoming', 'outgoing'] = 'incoming') tuple[source]#

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.

Parameters:
  • 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.

positions_are_global_z: bool#
require_scalar_paraxial(operation: str = 'scalar paraxial analysis')[source]#

Reject this path if it lies outside the supported scalar domain.

Raises for direct first-order analysis. Inside a 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 ParaxialDomainWarning instead, so aiming for e.g. slightly tilted stop mirrors keeps working – its exactness is guaranteed by the real traces, not the seed.

Parameters:

operation – Name of the requested operation, used in the message.

Raises:

UnsupportedParaxialGeometryError – If any diagnostic was recorded and no seed scope is active.

vertices_gcs: tuple#
warn_scalar_approximations(operation: str = 'scalar paraxial analysis') None[source]#

Surface any advisories as a 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.

Parameters:

operation – Name of the requested operation, used in the message.

class ParaxialPathDiagnostic(code: str, surface_index: int | None, measured: float | None, tolerance: float | None, message: str)[source]#

One reason a path lies outside the supported scalar domain.

Variables:
  • code (str) – Stable diagnostic code (one of the module-level constants).

  • surface_index (int | None) – Index of the offending surface or leg, or None.

  • measured (float | None) – The measured value that tripped the check, or None.

  • tolerance (float | None) – The tolerance it was compared against, or None.

  • message (str) – Concise human-readable explanation.

code: str#
measured: float | None#
message: str#
surface_index: int | None#
tolerance: float | None#
exception UnsupportedParaxialGeometryError[source]#

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.

angular_tolerance() float[source]#

Angular collinearity tolerance, backend/dtype aware.

Returns the float64 intent of 1e-10 widened when the active backend computes at lower precision.

build_paraxial_path(surfaces: list) ParaxialPath[source]#

Walk the surface chain once and assemble the shared path metadata.

Parameters:

surfaces – Ordered sequence of Surface objects (object surface first).

Returns:

The assembled 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.

in_paraxial_seed_scope() bool[source]#

Whether a paraxial seed scope is currently active.

paraxial_seed_scope()[source]#

Scope in which scalar paraxial values serve only as real-ray seeds.

position_tolerance(characteristic_scale: float) float[source]#

Spatial residual tolerance scaled to the system’s size.

Parameters:

characteristic_scale – A length characterizing the system, e.g. the largest finite vertex coordinate magnitude.

require_global_z_geometry(surfaces, operation: str) None[source]#

Guard for operations that write axial offsets into global cs.z.

Parameters:
  • 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.

require_nonsingular_tangent_angles(*components_deg, operation: str = 'angle-field evaluation') None[source]#

Reject field-angle components whose tangent is numerically singular.

Call before every tan(angle) evaluation on component field angles. Angles within tangent_singularity_tolerance_deg() of an odd multiple of 90 degrees raise; everything else passes through untouched.

Parameters:
  • *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.

tangent_singularity_tolerance_deg() float[source]#

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.

transverse_basis(direction: tuple) tuple[tuple, tuple][source]#

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 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.