"""Ray Operands Module
This module provides a class that calculates various ray tracing values for an
optical system. It is used in conjunction with the optimization module to
optimize optical systems.
Kramer Harrison, 2024
"""
from __future__ import annotations
import optiland.backend as be
from optiland import wavefront
from optiland.distribution import GaussianQuadrature
[docs]
class RayOperand:
"""A class that provides static methods for performing ray tracing
calculations on an optic.
Methods:
x_intercept: Calculates the x-coordinate of the intercept point on a
specific surface.
y_intercept: Calculates the y-coordinate of the intercept point on a
specific surface.
z_intercept: Calculates the z-coordinate of the intercept point on a
specific surface.
L: Calculates the direction cosine L of the ray on a specific surface.
M: Calculates the direction cosine M of the ray on a specific surface.
N: Calculates the direction cosine N of the ray on a specific surface.
rms_spot_size: Calculates the root mean square (RMS) spot size on a
specific surface.
OPD_difference: Calculates the optical path difference (OPD)
difference for a given ray distribution.
"""
[docs]
@staticmethod
def x_intercept(optic, surface_number, Hx, Hy, Px, Py, wavelength):
"""Calculates the x-coordinate of the intercept point on a specific
surface.
Args:
optic: The optic object.
surface_number: The number of the surface.
Hx: The normalized x field coordinate.
Hy: The normalized y field coordinate.
Px: The normalized x pupil coordinate.
Py: The normalized y pupil coordinate.
wavelength: The wavelength of the ray.
Returns:
The x-coordinate of the intercept point.
"""
optic.trace_generic(Hx, Hy, Px, Py, wavelength)
return optic.surfaces.x[surface_number, 0]
[docs]
@staticmethod
def y_intercept(optic, surface_number, Hx, Hy, Px, Py, wavelength):
"""Calculates the y-coordinate of the intercept point on a specific
surface.
Args:
optic: The optic object.
surface_number: The number of the surface.
Hx: The normalized x field coordinate.
Hy: The normalized y field coordinate.
Px: The normalized x pupil coordinate.
Py: The normalized y pupil coordinate.
wavelength: The wavelength of the ray.
Returns:
The y-coordinate of the intercept point.
"""
optic.trace_generic(Hx, Hy, Px, Py, wavelength)
return optic.surfaces.y[surface_number, 0]
[docs]
@staticmethod
def z_intercept(optic, surface_number, Hx, Hy, Px, Py, wavelength):
"""Calculates the z-coordinate of the intercept point on a specific
surface.
Args:
optic: The optic object.
surface_number: The number of the surface.
Hx: The normalized x field coordinate.
Hy: The normalized y field coordinate.
Px: The normalized x pupil coordinate.
Py: The normalized y pupil coordinate.
wavelength: The wavelength of the ray.
Returns:
The z-coordinate of the intercept point.
"""
optic.trace_generic(Hx, Hy, Px, Py, wavelength)
return optic.surfaces.z[surface_number, 0]
[docs]
@staticmethod
def x_intercept_lcs(optic, surface_number, Hx, Hy, Px, Py, wavelength):
"""Calculates the x-coordinate of the intercept point on a specific
surface in its lcs, ie wrt to its vertex.
Args:
optic: The optic object.
surface_number: The number of the surface.
Hx: The normalized x field coordinate.
Hy: The normalized y field coordinate.
Px: The normalized x pupil coordinate.
Py: The normalized y pupil coordinate.
wavelength: The wavelength of the ray.
Returns:
The x-coordinate of the intercept point.
"""
optic.trace_generic(Hx, Hy, Px, Py, wavelength)
intercept = optic.surfaces.x[surface_number, 0]
decenter = optic.surfaces[surface_number].geometry.cs.x
return intercept - decenter
[docs]
@staticmethod
def y_intercept_lcs(optic, surface_number, Hx, Hy, Px, Py, wavelength):
"""Calculates the y-coordinate of the intercept point on a specific
surface in its lcs, ie wrt to its vertex.
Args:
optic: The optic object.
surface_number: The number of the surface.
Hx: The normalized x field coordinate.
Hy: The normalized y field coordinate.
Px: The normalized x pupil coordinate.
Py: The normalized y pupil coordinate.
wavelength: The wavelength of the ray.
Returns:
The y-coordinate of the intercept point.
"""
optic.trace_generic(Hx, Hy, Px, Py, wavelength)
intercept = optic.surfaces.y[surface_number, 0]
decenter = optic.surfaces[surface_number].geometry.cs.y
return intercept - decenter
[docs]
@staticmethod
def z_intercept_lcs(optic, surface_number, Hx, Hy, Px, Py, wavelength):
"""Calculates the z-coordinate of the intercept point on a specific
surface in its lcs, ie wrt to its vertex.
Args:
optic: The optic object.
surface_number: The number of the surface.
Hx: The normalized x field coordinate.
Hy: The normalized y field coordinate.
Px: The normalized x pupil coordinate.
Py: The normalized y pupil coordinate.
wavelength: The wavelength of the ray.
Returns:
The z-coordinate of the intercept point.
"""
optic.trace_generic(Hx, Hy, Px, Py, wavelength)
intercept = optic.surfaces.z[surface_number, 0]
decenter = optic.surfaces[surface_number].geometry.cs.z
# For some reason decenter can sometimes be a single-element array.
# In that case, retreive the float inside.
# This is a workaround until a solution is found.
if be.is_array_like(decenter):
decenter = decenter.item()
return intercept - decenter
[docs]
@staticmethod
def L(optic, surface_number, Hx, Hy, Px, Py, wavelength):
"""Calculates the direction cosine L of the ray on a specific surface.
Args:
optic: The optic object.
surface_number: The number of the surface.
Hx: The normalized x field coordinate.
Hy: The normalized y field coordinate.
Px: The normalized x pupil coordinate.
Py: The normalized y pupil coordinate.
wavelength: The wavelength of the ray.
Returns:
The direction cosine L of the ray.
"""
optic.trace_generic(Hx, Hy, Px, Py, wavelength)
return optic.surfaces.L[surface_number, 0]
[docs]
@staticmethod
def M(optic, surface_number, Hx, Hy, Px, Py, wavelength):
"""Calculates the direction cosine M of the ray on a specific surface.
Args:
optic: The optic object.
surface_number: The number of the surface.
Hx: The normalized x field coordinate.
Hy: The normalized y field coordinate.
Px: The normalized x pupil coordinate.
Py: The normalized y pupil coordinate.
wavelength: The wavelength of the ray.
Returns:
The direction cosine M of the ray.
"""
optic.trace_generic(Hx, Hy, Px, Py, wavelength)
return optic.surfaces.M[surface_number, 0]
[docs]
@staticmethod
def N(optic, surface_number, Hx, Hy, Px, Py, wavelength):
"""Calculates the direction cosine N of the ray on a specific surface.
Args:
optic: The optic object.
surface_number: The number of the surface.
Hx: The normalized x field coordinate.
Hy: The normalized y field coordinate.
Px: The normalized x pupil coordinate.
Py: The normalized y pupil coordinate.
wavelength: The wavelength of the ray.
Returns:
The direction cosine N of the ray.
"""
optic.trace_generic(Hx, Hy, Px, Py, wavelength)
return optic.surfaces.N[surface_number, 0]
[docs]
@staticmethod
def AOI(optic, surface_number, Hx, Hy, Px, Py, wavelength):
"""
Calculates the real ray angle of incidence in degrees at a specific surface.
This angle is always positive, and it is the angle between the incident ray and
the surface normal.
Args:
optic: The optic object.
surface_number: The number of the surface.
Hx: The normalized x field coordinate.
Hy: The normalized y field coordinate.
Px: The normalized x pupil coordinate.
Py: The normalized y pupil coordinate.
wavelength: The wavelength of the ray.
Returns:
The angle of incidence in degrees (always positive as in zemax).
"""
optic.trace_generic(Hx, Hy, Px, Py, wavelength)
surface = optic.surfaces[surface_number]
geometry = surface.geometry
L_inc = optic.surfaces.L[surface_number - 1, 0]
M_inc = optic.surfaces.M[surface_number - 1, 0]
N_inc = optic.surfaces.N[surface_number - 1, 0]
from optiland.rays import RealRays
rays_at_surface = RealRays(
x=optic.surfaces.x[surface_number, 0],
y=optic.surfaces.y[surface_number, 0],
z=optic.surfaces.z[surface_number, 0],
L=L_inc,
M=M_inc,
N=N_inc,
intensity=1.0, # irrelevant for AOI
wavelength=wavelength,
)
# public surface_normal method
nx, ny, nz = geometry.surface_normal(rays=rays_at_surface)
# dot product between incident ray and surface normal
dot_product = be.abs(L_inc * nx + M_inc * ny + N_inc * nz)
# handle potential floating point errors where dot_product > 1.0
dot_product_clip = be.minimum(dot_product, be.array(1.0))
angle_rad = be.arccos(dot_product_clip)
angle_deg = be.rad2deg(angle_rad)
# For some reason angle_deg can sometimes be a single-element array.
# In that case, retreive the float inside.
# This is a workaround until a solution is found.
if be.is_array_like(angle_deg):
angle_deg = angle_deg.item()
return angle_deg
[docs]
@staticmethod
def rms_spot_size(
optic,
surface_number,
Hx,
Hy,
num_rays,
wavelength,
distribution="hexapolar",
nan_policy="propagate",
):
"""Calculates the root mean square (RMS) spot size on a specific surface.
Args:
optic: The optic object.
surface_number: The number of the surface.
Hx: The normalized x field coordinate.
Hy: The normalized y field coordinate.
num_rays: The number of rays to trace.
wavelength: The wavelength of the rays.
distribution: The distribution of the rays. Default is 'hexapolar'.
nan_policy: How to handle NaN ray intersections, which typically
arise from total internal reflection or vignetted rays. One
of "propagate" (default, return NaN if any intersection is
NaN), "omit" (ignore NaN intersections and compute the RMS
from the remaining valid rays), or "raise" (raise a
ValueError if any intersection is NaN).
Returns:
The RMS spot size on the specified surface.
Raises:
ValueError: If nan_policy is "raise" and a NaN ray intersection
is encountered, or if nan_policy is not a recognized value.
"""
valid_nan_policies = ("propagate", "omit", "raise")
if nan_policy not in valid_nan_policies:
raise ValueError(
f"Invalid nan_policy '{nan_policy}'. Must be one of "
f"{valid_nan_policies}."
)
def _has_nan(*arrays):
return any(be.any(be.isnan(a)) for a in arrays)
# Note: reductions always use the NaN-omitting `be.nanmean`, rather
# than `be.mean`, because `be.mean` is not NaN-consistent across
# backends (the torch backend's `mean` already silently ignores
# NaNs, while numpy's propagates them). Using `nanmean` everywhere
# and handling `nan_policy` explicitly via `_has_nan` keeps behavior
# identical on both backends.
if wavelength == "all":
x = []
y = []
for wave in optic.wavelengths.get_wavelengths():
optic.trace(Hx, Hy, wave, num_rays, distribution)
x.append(optic.surfaces.x[surface_number, :].flatten())
y.append(optic.surfaces.y[surface_number, :].flatten())
has_nan = _has_nan(*x, *y)
wave_idx = optic.wavelengths.primary_index
mean_x = be.nanmean(x[wave_idx])
mean_y = be.nanmean(y[wave_idx])
r2 = [(x[i] - mean_x) ** 2 + (y[i] - mean_y) ** 2 for i in range(len(x))]
rms = be.sqrt(be.nanmean(be.concatenate(r2)))
else:
optic.trace(Hx, Hy, wavelength, num_rays, distribution)
x = optic.surfaces.x[surface_number, :].flatten()
y = optic.surfaces.y[surface_number, :].flatten()
has_nan = _has_nan(x, y)
r2 = (x - be.nanmean(x)) ** 2 + (y - be.nanmean(y)) ** 2
rms = be.sqrt(be.nanmean(r2))
if has_nan:
if nan_policy == "raise":
raise ValueError(
"rms_spot_size encountered a NaN ray intersection on "
f"surface {surface_number}. This typically indicates "
"total internal reflection or a vignetted ray. Use "
"nan_policy='omit' to compute the RMS from valid rays "
"only, or leave nan_policy='propagate' (default) to "
"return NaN."
)
if nan_policy == "propagate":
return rms * be.nan
return rms
[docs]
@staticmethod
def OPD_difference(
optic,
Hx,
Hy,
num_rays,
wavelength,
distribution="gaussian_quad",
):
"""Calculates the mean optical path difference (OPD) difference for a
given ray distribution.
Args:
optic: The optic object.
Hx: The normalized x field coordinate.
Hy: The normalized y field coordinate.
num_rays: The number of rays to trace.
wavelength: The wavelength of the rays.
distribution: The distribution of the rays.
Default is 'gaussian_quad'.
Returns:
The OPD difference for the given ray distribution.
"""
weights = None
if distribution == "gaussian_quad":
distribution = GaussianQuadrature()
distribution.generate_points(num_rays)
weights = distribution.weights
wf = wavefront.Wavefront(
optic,
[(Hx, Hy)],
[wavelength],
num_rays,
distribution,
)
wavefront_data = wf.get_data((Hx, Hy), wavelength)
opd = wavefront_data.opd
if weights is None:
weights = 1.0 / len(wf.distribution.x)
delta = be.abs(opd - be.mean(opd)) * weights
return be.sum(delta)
[docs]
@staticmethod
def clearance(
optic,
line_ray_surface_idx,
line_ray_field_coords,
line_ray_pupil_coords,
point_ray_surface_idx,
point_ray_field_coords,
point_ray_pupil_coords,
wavelength,
):
"""Computes the signed perpendicular distance in the YZ plane from a
reference line (Line A) to a reference point (Point B).
Line A is defined by a ray (RA) traced at field FA, after it leaves
surface SA. Point B is the intersection of a ray (RB) traced at
field FB with surface SB.
This operand is useful for creating clearance or interference constraints,
particularly in off-axis reflective systems.
The sign convention follows the direction of propagation of Line A: for
Line A propagating generally in the +Z direction (N direction cosine >
0), the signed distance is positive if Point B is on the +Y side of
Line A. For Line A propagating generally in the -Z direction (N
direction cosine < 0), the sign convention naturally flips (positive
indicates Point B is on the -Y side) -- no separate correction should
be applied for this case.
Args:
optic: The optical system model.
line_ray_surface_idx: The index of the surface (SA) from which
Line A originates (i.e., ray data is taken *after* this
surface).
line_ray_field_coords: A tuple (Hx, Hy) representing the
normalized field coordinates for the ray defining Line A (FA).
line_ray_pupil_coords: A tuple (Px, Py) representing the
normalized pupil coordinates for the ray defining Line A (FA).
point_ray_surface_idx: The index of the surface (SB) with which
the ray defining Point B intersects.
point_ray_field_coords: A tuple (Hx, Hy) representing the
normalized field coordinates for the ray defining Point B (FB).
point_ray_pupil_coords: A tuple (Px, Py) representing the
normalized pupil coordinates for the ray defining Point B (FB).
wavelength: The wavelength at which to trace the rays.
Returns:
float: The signed perpendicular distance in the YZ plane from
Line A to Point B. Returns 0.0 if Line A has zero length
in the YZ plane (i.e., mA and nA are both zero).
"""
FA_Hx, FA_Hy = line_ray_field_coords
FA_Px, FA_Py = line_ray_pupil_coords
optic.trace_generic(FA_Hx, FA_Hy, FA_Px, FA_Py, wavelength)
yA = optic.surfaces.y[line_ray_surface_idx, 0]
zA = optic.surfaces.z[line_ray_surface_idx, 0]
mA = optic.surfaces.M[line_ray_surface_idx, 0]
nA = optic.surfaces.N[line_ray_surface_idx, 0]
FB_Hx, FB_Hy = point_ray_field_coords
FB_Px, FB_Py = point_ray_pupil_coords
optic.trace_generic(FB_Hx, FB_Hy, FB_Px, FB_Py, wavelength)
yB = optic.surfaces.y[point_ray_surface_idx, 0]
zB = optic.surfaces.z[point_ray_surface_idx, 0]
denominator = be.sqrt(mA**2 + nA**2)
epsilon = 1e-9
if be.abs(denominator) < epsilon:
d = 0.0
else:
numerator = nA * (yB - yA) - mA * (zB - zA)
d = numerator / denominator
return d