"""Paraxial Module
This module provides various functionalities for the computation of paraxial
properties of lens systems.
Note that object-space coordinates are defined relative to the first surface
(at index 1), while image-space coordinates are defined relative to the image surface.
This is relevant for the focal points (F1 & F2), principal planes (P1 & P2),
anti-principal planes (P1anti & P2anti), nodal planes (N1 & N2), and anti-nodal
planes (N1anti & N2anti). In the Optiland convention, the 1 denotes object space and
the 2 denotes image space. For example, P1 is the object space principle plane and F2
is the back focal point.
Kramer Harrison, 2024
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import optiland.backend as be
from optiland._deprecation import deprecated
from optiland.fields import ObjectHeightField, ParaxialImageHeightField
from optiland.raytrace.paraxial_ray_tracer import ParaxialRayTracer
if TYPE_CHECKING:
from numpy.typing import ArrayLike
from optiland._types import BEArray, ScalarOrArray
from optiland.optic import Optic
from optiland.paraxial_path import ParaxialPath
from optiland.surfaces import SurfaceGroup
[docs]
class Paraxial:
"""A class representing a paraxial optical system.
This class provides methods to calculate various properties of the optical
system, such as focal lengths, entrance pupil location, exit pupil
location, entrance pupil diameter, exit pupil diameter, image-space
F-number, magnification, and more.
Attributes:
optic (Optic): The optical system being analyzed.
surfaces (SurfaceGroup): The surface group of the optical system.
"""
def __init__(self, optic: Optic):
"""Initializes a Paraxial instance
Args:
optic (Optic): The optical system to analyze.
"""
self.optic = optic
self._ray_tracer = ParaxialRayTracer(self.optic)
@property
def surfaces(self) -> SurfaceGroup:
"""SurfaceGroup: the surface group of the optical system."""
return self.optic.surfaces
[docs]
def f1(self) -> ScalarOrArray:
"""Calculate the front focal length (f1).
Returns:
Front focal length.
"""
z_start = -1
wavelength = self.optic.primary_wavelength
y, u = self.trace_generic(1.0, 0.0, z_start, wavelength, reverse=True, skip=1)
f1 = y[0] / u[-1]
return f1[0]
[docs]
def f2(self) -> ScalarOrArray:
"""Calculate the back focal length (f2), also known as effective focal length.
Returns:
Back focal length.
"""
# start tracing 1 lens unit before first surface
path = self.surfaces.build_paraxial_path()
z_start = path.axial_positions.reshape(-1, 1)[1] - 1
wavelength = self.optic.primary_wavelength
y, u = self.trace_generic(1.0, 0.0, z_start, wavelength, path=path)
f2 = -y[0] / u[-1]
return f2[0]
[docs]
def f2_range(
self,
start: int,
end: int,
wavelength: float | None = None,
*,
path: ParaxialPath | None = None,
) -> ScalarOrArray:
"""Calculate the effective focal length of a range of surfaces.
The range is treated as a lens group in isolation: the returned value
is the paraxial EFL that surfaces ``start`` through ``end`` have with
their own conjugates, evaluated in the media that bound the range in
the parent system.
This is deliberately not a decomposition of the full system's power.
The focal lengths of a system's groups do not, in general, recombine
into :meth:`f2`, since that would additionally require the separations
between the groups' principal planes.
Delegates to :meth:`ray_transfer_matrix`, so it shares the same
validated scalar sequence (and supported scalar domain) as the
explicit paraxial trace.
Args:
start: Index of the first surface of the range, inclusive.
end: Index of the last surface of the range, inclusive.
wavelength: Wavelength in micrometers at which the refractive
indices are evaluated. Defaults to the system's primary
wavelength.
path: Optional prebuilt :class:`~optiland.paraxial_path.ParaxialPath`
to reuse across several first-order calls.
Returns:
Effective focal length of the surface range. A range with no net
power (an afocal group) returns infinity.
Raises:
ValueError: If the surface range is invalid.
UnsupportedParaxialGeometryError: If the geometry lies outside
the supported scalar folded paraxial domain.
"""
matrix = self.ray_transfer_matrix(start, end, wavelength, path=path)
# A ray entering parallel to the axis (y=1, u=0) leaves the range with
# slope u_out = C, so the EFL is -y_in / u_out = -1 / C. This is the
# same ratio f2() forms from an explicit paraxial trace.
C = matrix[1, 0]
if C == 0:
return be.array(float("inf"))
return -1.0 / C
[docs]
def F1(self) -> ScalarOrArray:
"""Calculate the front focal point (F1) location.
Note that this is defined relative to the first surface (at index 1).
Returns:
Front focal point location.
"""
# start tracing 1 lens unit before first surface
z_start = -1
wavelength = self.optic.primary_wavelength
y, u = self.trace_generic(1.0, 0.0, z_start, wavelength, reverse=True, skip=1)
F1 = y[-1] / u[-1]
return F1[0]
[docs]
def F2(self) -> ScalarOrArray:
"""Calculate the back focal point (F2) location.
Note that this is defined relative to the image surface location.
Returns:
Back focal point location.
"""
# start tracing 1 lens unit before first surface
path = self.surfaces.build_paraxial_path()
z_start = path.axial_positions.reshape(-1, 1)[1] - 1
wavelength = self.optic.primary_wavelength
y, u = self.trace_generic(1.0, 0.0, z_start, wavelength, path=path)
F2 = -y[-1] / u[-1]
return F2[0]
[docs]
def P1(self) -> ScalarOrArray:
"""Calculate the front principal plane (P1) location.
Note that this is defined relative to the first surface (at index 1).
Returns:
Front principal plane location.
"""
return self.F1() - self.f1()
[docs]
def P2(self) -> ScalarOrArray:
"""Calculate the back principal plane (P2) location.
Note that this is defined relative to the image surface location.
Returns:
Back principal plane location.
"""
return self.F2() - self.f2()
[docs]
def P1anti(self) -> ScalarOrArray:
"""Calculate the front anti-principal plane (P1anti) location.
Note that this is defined relative to the first surface (at index 1).
Returns:
Front anti-principal plane location.
"""
return self.F1() + self.f1()
[docs]
def P2anti(self) -> ScalarOrArray:
"""Calculate the back anti-principal plane (P2anti) location.
Note that this is defined relative to the image surface location.
Returns:
Back anti-principal plane location.
"""
return self.F2() + self.f2()
[docs]
def N1(self) -> ScalarOrArray:
"""Calculate the front nodal plane (N1) location.
Note that this is defined relative to the first surface (at index 1).
Returns:
Front nodal plane location.
"""
return self.F1() + self.f2()
[docs]
def N2(self) -> ScalarOrArray:
"""Calculate the back nodal plane (N2) location.
Note that this is defined relative to the image surface location.
Returns:
Back nodal plane location.
"""
return self.F2() + self.f1()
[docs]
def N1anti(self) -> ScalarOrArray:
"""Calculate the front anti-nodal plane (N1anti) location.
Note that this is defined relative to the first surface (at index 1).
Returns:
Front anti-nodal plane location.
"""
return self.F1() - self.f2()
[docs]
def N2anti(self) -> ScalarOrArray:
"""Calculate the back anti-nodal plane (N2anti) location.
Note that this is defined relative to the image surface location.
Returns:
Back anti-nodal plane location.
"""
return self.F2() - self.f1()
[docs]
def EPL(self, path: ParaxialPath | None = None) -> ScalarOrArray:
"""Calculate the entrance pupil location (EPL).
This value is relative to the first physical surface (index 1),
matching the convention of other first-order quantities on this
class (``XPL`` is relative to the image surface, ``N1anti`` /
``N2anti`` relative to their respective reference surfaces). The
prescription report and the ``EPL`` optimization operand both
surface this value as-is. Call sites that need a global z (object
space, surface positions, ray launch points) should call
:meth:`entrance_pupil_z` instead.
Args:
path: Optional prebuilt :class:`ParaxialPath` to reuse.
Returns:
Entrance pupil position relative to the first surface
(which lies at z=0 by definition in its local coordinate system).
"""
stop_index = self.surfaces.stop_index
if stop_index == 1:
# Entrance pupil coincides with surface 1, so its location in
# surface 1's local frame is zero. (The earlier ``positions[1, 0]``
# return here mixed conventions — it agreed with the relative
# convention only when surface 1 was at the origin.)
return be.array(0.0)
y0 = 0
u0 = 0.1
if path is None:
path = self.surfaces.build_paraxial_path()
pos = path.axial_positions.reshape(-1, 1)
z0 = pos[-1] - pos[stop_index]
wavelength = self.optic.primary_wavelength
# trace from center of stop on axis
skip = self.surfaces.num_surfaces - stop_index
y, u = self.trace_generic(
y0, u0, z0[0], wavelength, reverse=True, skip=skip, path=path
)
loc_relative = y[-1] / u[-1]
return loc_relative[0]
[docs]
def entrance_pupil_axial_position(
self, path: ParaxialPath | None = None
) -> ScalarOrArray:
"""Entrance pupil location on the unfolded signed axial coordinate.
This is ``EPL()`` re-anchored to the same axial coordinate that
``SurfaceGroup.positions`` uses, so it can be differenced directly
against surface positions. It is a 1-D unfolded axial scalar, never
a Cartesian coordinate; use :meth:`entrance_pupil_point_gcs` for the
pupil's real-space location.
Args:
path: Optional prebuilt :class:`ParaxialPath` to reuse.
"""
if path is None:
path = self.surfaces.build_paraxial_path()
return self.EPL(path=path) + path.axial_positions.reshape(-1, 1)[1, 0]
[docs]
@deprecated("paraxial.entrance_pupil_axial_position()")
def entrance_pupil_z(self, path: ParaxialPath | None = None) -> ScalarOrArray:
"""Entrance pupil location as an axial scalar (legacy name).
``EPL()`` returns a value relative to the first physical surface (per
the documented convention). Call sites that mix the pupil location
with other axial coordinates (object position, surface positions)
should use this helper so the conversion lives in one place. Issue
#613 was caused by call sites silently assuming EPL was global;
routing them through this helper makes the convention explicit at
the boundary.
Despite the historical name, this is NOT a Cartesian global z: it is
the pupil's coordinate along the signed unfolded axis of
``SurfaceGroup.positions``. The two coincide only while every leg of
the beam path runs along +z. For a folded or off-axis-entered system
the pupil's position in space is a point on the entry line -- use
:meth:`entrance_pupil_point_gcs` for that point, and prefer
:meth:`entrance_pupil_axial_position` (same value, honest name) in
new code.
Args:
path: Optional prebuilt :class:`ParaxialPath` to reuse.
"""
return self.entrance_pupil_axial_position(path=path)
[docs]
def entrance_pupil_point_gcs(self, path: ParaxialPath | None = None) -> tuple:
"""Entrance pupil position as a 3-D point in global coordinates.
The entrance pupil is the stop imaged into object space; in the
scalar folded model its apparent point lies on the unfolded entry
line: ``r_EP = r_1 + EPL * d_0``, with ``r_1`` the first physical
surface's vertex and ``d_0`` the unit entry direction.
Args:
path: Optional prebuilt :class:`ParaxialPath` to reuse.
Returns:
The pupil point as an ``(x, y, z)`` tuple of backend scalars.
"""
if path is None:
path = self.surfaces.build_paraxial_path()
epl = self.EPL(path=path)
anchor = path.vertices_gcs[1]
d0 = path.entry_direction
return tuple(anchor[i] + epl * d0[i] for i in range(3))
[docs]
def exit_pupil_point_gcs(self, path: ParaxialPath | None = None) -> tuple:
"""Exit pupil position as a 3-D point in global coordinates.
Maps the axial ``XPL()`` scalar onto the physical image-space leg:
``r_XP = r_I + p_I * XPL * d_I``, with ``r_I`` the image vertex and
``p_I``, ``d_I`` the reflection parity and physical beam direction
arriving at the image plane.
Args:
path: Optional prebuilt :class:`ParaxialPath` to reuse.
Returns:
The pupil point as an ``(x, y, z)`` tuple of backend scalars.
"""
if path is None:
path = self.surfaces.build_paraxial_path()
xpl = self.XPL(path=path)
return path.point_from_axial_offset(-1, xpl, side="incoming")
[docs]
def EPD(self) -> ScalarOrArray:
"""Calculate the entrance pupil diameter (EPD).
Returns:
Entrance pupil diameter.
"""
if self.optic.aperture is None:
raise ValueError(
"No aperture is defined on the optical system, so the pupil "
"size is unknown. Set one with "
'lens.set_aperture(aperture_type="EPD", value=25).'
)
wavelength = self.optic.primary_wavelength
return self.optic.aperture.compute_epd(self, wavelength)
[docs]
def XPL(self, path: ParaxialPath | None = None) -> ScalarOrArray:
"""Calculate the exit pupil location (XPL).
Args:
path: Optional prebuilt :class:`ParaxialPath` to reuse.
Returns:
Exit pupil location relative to the image surface.
"""
stop_index = self.surfaces.stop_index
if path is None:
path = self.surfaces.build_paraxial_path()
z_start = path.axial_positions.reshape(-1, 1)[stop_index]
wavelength = self.optic.primary_wavelength
y, u = self.trace_generic(
0.0, 0.1, z_start, wavelength, skip=stop_index + 1, path=path
)
loc_relative = -y[-1] / u[-1]
return loc_relative[0]
[docs]
def XPD(self) -> ScalarOrArray:
"""Calculate the exit pupil diameter (XPD).
Returns:
Exit pupil diameter.
"""
# find marginal ray height at image surface
ya, ua = self.marginal_ray()
yi = ya[-1]
ui = ua[-1]
# find distance from image surface to exit pupil location
xpl = self.XPL()
# propagate marginal ray to this location
yxp = yi + ui * xpl
return 2 * yxp[0]
[docs]
def FNO(self) -> ScalarOrArray:
"""Calculate the image-space F-number (FNO).
Returns:
float: Image-space F-number.
"""
if self.optic.aperture is None:
raise ValueError(
"No aperture is defined on the optical system, so the pupil "
"size is unknown. Set one with "
'lens.set_aperture(aperture_type="EPD", value=25).'
)
fno = self.optic.aperture.direct_fno()
if fno is not None:
return fno
return self.f2() / self.EPD()
[docs]
def magnification(self) -> ScalarOrArray:
"""Calculate the transverse magnification.
Returns:
The system's transverse magnification.
"""
_, ua = self.marginal_ray()
n = self.optic.surfaces.n(self.optic.primary_wavelength)
mag = n[0] * ua[0] / (n[-1] * ua[-1])
return mag[0]
[docs]
def invariant(self) -> ScalarOrArray:
"""Calculate the Lagrange invariant.
Returns:
The Lagrange invariant of the system.
"""
ya, ua = self.marginal_ray()
yb, ub = self.chief_ray()
n = self.optic.surfaces.n(self.optic.primary_wavelength)
inv = yb[1] * n[1] * ua[1] - ya[1] * n[1] * ub[1]
return inv[0]
[docs]
def marginal_ray(self) -> tuple[BEArray, BEArray]:
"""Calculates the marginal ray heights and angles at each surface.
The marginal ray originates from the center of the object and passes
through the edge of the aperture stop.
Returns:
A tuple containing two arrays:
- y_marginal: Heights of the marginal ray at each surface.
- u_marginal: Slopes of the marginal ray after each surface.
"""
EPD = self.EPD()
path = self.surfaces.build_paraxial_path()
pos = path.axial_positions.reshape(-1, 1)
obj_z = pos[1] - 10 # 10 mm before first surface
if self.optic.object_surface is None:
raise ValueError(
"No object surface is defined on the optical system. The marginal "
"ray starts at the object, so an object surface is required. Add "
"one with `optic.add_surface(index=0, ...)`."
)
if self.optic.object_surface.is_infinite:
ya = EPD / 2
ua = 0
else:
obj_z = self.optic.object_surface.geometry.cs.z
z = self.entrance_pupil_axial_position(path=path) - obj_z
ya = 0
ua = EPD / (2 * z)
wavelength = self.optic.primary_wavelength
return self.trace_generic(ya, ua, obj_z, wavelength, path=path)
[docs]
def chief_ray(self) -> tuple[BEArray, BEArray]:
"""Calculates the chief ray heights and angles at each surface.
The chief ray originates from the edge of the field of view and passes
through the center of the aperture stop.
Returns:
A tuple containing two arrays:
- y_chief: Heights of the chief ray at each surface.
- u_chief: Slopes of the chief ray after each surface.
"""
stop_index = self.optic.surfaces.stop_index
path = self.surfaces.build_paraxial_path()
pos = path.axial_positions.reshape(-1, 1)
wavelength = self.optic.primary_wavelength
num_surf = self.surfaces.num_surfaces
y0 = 0.0
u0 = 0.1 # Arbitrary small angle for unit trace
# Trace a unit ray forward from stop to image
z_fwd = pos[stop_index]
skip_fwd = stop_index
y_fwd_unit, _ = self.trace_generic(
y0, u0, z_fwd, wavelength, skip=skip_fwd, path=path
)
y_img_unit = y_fwd_unit[-1]
# Trace the same unit ray backward from stop to object
z_rev = pos[-1] - pos[stop_index]
skip_rev = num_surf - stop_index
y_rev_unit, u_rev_unit = self.trace_generic(
y0, u0, z_rev, wavelength, reverse=True, skip=skip_rev, path=path
)
y_obj_unit = y_rev_unit[-1]
u_obj_unit = u_rev_unit[-1]
field_definition = self.optic.fields.field_definition
if not self.optic.object_surface.is_infinite and isinstance(
field_definition, ObjectHeightField
):
first_surface_z = pos[1, 0]
object_z = self.optic.object_surface.geometry.cs.z
y_obj_unit = y_obj_unit + (first_surface_z - object_z) * u_obj_unit
# Scale based on field definition
if field_definition is None:
raise ValueError(
"No field definition is set on the optical system. The chief ray "
"is scaled by the field, so a field type is required. Set one with "
'`optic.fields.set_type(...)`, e.g. "angle" or "object_height".'
)
scaling_factor = field_definition.scale_chief_ray_for_field(
self.optic, y_obj_unit, u_obj_unit, y_img_unit
)
# Determine initial ray parameters for final forward trace
if isinstance(self.optic.fields.field_definition, ParaxialImageHeightField):
y_obj_start = y_obj_unit * scaling_factor
else:
y_obj_start = -(y_obj_unit * scaling_factor)
u_obj_start = u_obj_unit * scaling_factor
if self.optic.object_surface.is_infinite:
# For infinite conjugates, chief ray is defined by angle in object space.
# We find its height at the first surface by propagating from the EPL,
# where its height is zero.
z_surf1 = pos[1, 0]
y1_start = u_obj_start * (
z_surf1 - self.entrance_pupil_axial_position(path=path)
)
u1_start = u_obj_start
z1_start = z_surf1
return self.trace_generic(
y1_start, u1_start, z1_start, wavelength, path=path
)
else: # Finite conjugate
# For finite conjugates, ray starts at y_obj_start on the object plane.
z_start = self.optic.object_surface.geometry.cs.z
return self.trace_generic(
y_obj_start, u_obj_start, z_start, wavelength, path=path
)
[docs]
def trace(self, Hy: ArrayLike, Py: ArrayLike, wavelength: float):
"""Trace paraxial ray through the optical system based on specified field
and pupil coordinates.
Args:
Hy (float): Normalized field coordinate (typically in y).
Py (float): Normalized pupil coordinate (typically in y).
wavelength (float): Wavelength of the light in micrometers.
Returns:
tuple[be.ndarray, be.ndarray]: A tuple containing two arrays:
- y_ray: Heights of the traced ray at each surface.
- u_ray: Slopes of the traced ray after each surface.
"""
return self._ray_tracer.trace(Hy, Py, wavelength)
[docs]
def ray_transfer_matrix(
self,
start: int,
end: int,
wavelength: float | None = None,
*,
path: ParaxialPath | None = None,
) -> BEArray:
"""Build the paraxial ray-transfer (ABCD) matrix of a surface range.
The matrix maps a paraxial ray incident on surface ``start`` to the
ray leaving surface ``end``::
[y_out] [A B] [y_in]
[u_out] = [C D] [u_in]
Both indices are inclusive. The input height and slope are those
immediately before surface ``start``, and the output height and slope
those immediately after surface ``end``. Only surfaces inside the
range contribute: no propagation is included before ``start`` or after
``end``.
The matrix is assembled from the same validated scalar sequence as
:meth:`trace_generic` (shared path metadata, scalar-domain
validation, straight-system advisories, and orientation-aware
effective radii and focal lengths), so the matrix and an explicit
paraxial trace of the same range always agree. The supported scalar
domain is that of :class:`~optiland.paraxial_path.ParaxialPath`:
piecewise-centered legs joined by plane fold mirrors, powered
surfaces normal to their local beam segment; geometry outside it
raises :class:`~optiland.paraxial_path.UnsupportedParaxialGeometryError`.
Args:
start: Index of the first surface of the range, inclusive. Must be
at least 1, since surface 0 is the object surface and carries
no power.
end: Index of the last surface of the range, inclusive.
wavelength: Wavelength in micrometers at which the refractive
indices are evaluated. Defaults to the system's primary
wavelength.
path: Optional prebuilt :class:`~optiland.paraxial_path.ParaxialPath`
for the current geometry, so a high-level operation making
several first-order calls pays the path construction once.
Must be a fresh snapshot of the surfaces being analyzed.
Returns:
The 2x2 ray-transfer matrix of the surface range.
Raises:
ValueError: If the surface range is invalid.
UnsupportedParaxialGeometryError: If the geometry lies outside
the supported scalar folded paraxial domain.
"""
num_surfaces = self.surfaces.num_surfaces
if start < 1:
raise ValueError(
f"Invalid start surface, got {start}. The range must begin at "
"an optical surface, so its index must be at least 1 (index 0 "
"is the object surface)."
)
if end > num_surfaces - 1:
raise ValueError(
f"Invalid end surface, got {end}. The system has "
f"{num_surfaces} surfaces, so the largest valid index is "
f"{num_surfaces - 1}."
)
if start > end:
raise ValueError(
f"Invalid surface range, got start={start} and end={end}. The "
"range is inclusive and ordered, so start must not exceed end."
)
if wavelength is None:
wavelength = self.optic.primary_wavelength
sequence = self._ray_tracer.prepare_scalar_sequence(
wavelength, path=path, operation="ray transfer matrix assembly"
)
R = sequence.radii
n = sequence.refractive_indices
pos = sequence.positions
matrix = self._interaction_matrix(
start, R, n, sequence.surfaces[start], sequence.focal_signs[start]
)
for k in range(start + 1, end + 1):
transfer = self._transfer_matrix(pos[k] - pos[k - 1], n)
matrix = be.matmul(
self._interaction_matrix(
k, R, n, sequence.surfaces[k], sequence.focal_signs[k]
),
be.matmul(transfer, matrix),
)
return matrix
def _interaction_matrix(
self,
k: int,
R: BEArray,
n: BEArray,
surface,
focal_sign: float = 1.0,
) -> BEArray:
"""Build the ray-transfer matrix of a single surface interaction.
Receives already-effective values from the shared scalar sequence:
``R`` carries the orientation-corrected radii, and ``focal_sign`` is
the per-surface orientation sign to apply to an explicit paraxial
focal length. No orientation logic is duplicated here.
Args:
k: Index of the surface.
R: Paraxial-effective radii of curvature of all surfaces.
n: Refractive indices following each surface.
surface: The surface object at index ``k``.
focal_sign: Orientation sign for an explicit paraxial surface's
focal length.
Returns:
The 2x2 ray-transfer matrix of the surface interaction.
"""
# Derive the constants from n so they carry the backend's dtype and
# device. R is unsuitable, as it is infinite for a plane surface.
zero = n[k] * 0.0
one = zero + 1.0
if surface.interaction_model.is_reflective:
D = -one
if surface.surface_type == "paraxial":
C = -one / (focal_sign * surface.interaction_model.f)
else:
C = -2.0 / R[k]
else:
D = n[k - 1] / n[k]
if surface.surface_type == "paraxial":
C = -one / (focal_sign * surface.interaction_model.f * n[k])
else:
C = -((n[k] - n[k - 1]) / R[k]) / n[k]
return be.stack([be.stack([one, zero]), be.stack([C, D])])
@staticmethod
def _transfer_matrix(t: BEArray, n: BEArray) -> BEArray:
"""Build the ray-transfer matrix for propagation over a distance.
Args:
t: The axial distance to propagate.
n: Refractive indices following each surface, used only as a
template for the backend's dtype and device.
Returns:
The 2x2 ray-transfer matrix of the propagation.
"""
zero = n[0] * 0.0
one = zero + 1.0
return be.stack([be.stack([one, t + zero]), be.stack([zero, one])])
[docs]
def trace_generic(
self,
y: BEArray | float,
u: BEArray | float,
z: BEArray | float,
wavelength: float,
reverse: bool = False,
skip: int = 0,
path: ParaxialPath | None = None,
) -> tuple[BEArray, BEArray]:
"""Trace generically-defined paraxial rays through the optical system.
Args:
y: The initial height(s) of the rays.
u: The initial slope(s) of the rays.
z: The initial axial position(s) of the rays,
relative to the first surface if tracing forward, or relative
to the last surface if tracing in reverse (before internal reversal).
wavelength: The wavelength of the rays in micrometers.
reverse: If True, trace the rays in reverse
direction (from image to object space). Defaults to False.
skip: The number of surfaces to skip from the
beginning of the trace (or end if reverse). Defaults to 0.
path: Optional prebuilt :class:`ParaxialPath` for the current
geometry (built once per high-level operation). Must be a
fresh snapshot of the surfaces being traced.
Returns:
A tuple containing the height(s)
and slope(s) of the rays at each surface interface after tracing.
"""
return self._ray_tracer.trace_generic(y, u, z, wavelength, reverse, skip, path)