"""Angle Field Module
Kramer Harrison, 2025
"""
from __future__ import annotations
import optiland.backend as be
from .base import BaseFieldDefinition
[docs]
@BaseFieldDefinition.register("angle")
class AngleField(BaseFieldDefinition):
"""Defines fields by angle (in degrees) relative to the optical axis."""
[docs]
def get_ray_origins(self, optic, Hx, Hy, Px, Py, vx, vy):
"""Calculate the initial positions for rays originating at the object.
Args:
Hx (float): Normalized x field coordinate.
Hy (float): Normalized y field coordinate.
Px (float or be.ndarray): x-coordinate of the pupil point.
Py (float or be.ndarray): y-coordinate of the pupil point.
vx (float): Vignetting factor in the x-direction.
vy (float): Vignetting factor in the y-direction.
Returns:
tuple: A tuple containing the x, y, and z coordinates of the
object position.
"""
obj = optic.object_surface
EPL = optic.paraxial.EPL()
max_field = be.array(optic.fields.max_field)
field_x = max_field * be.array(Hx)
field_y = max_field * be.array(Hy)
if obj.is_infinite:
EPD = optic.paraxial.EPD()
offset = self._get_starting_z_offset(optic)
x = -be.tan(be.radians(field_x)) * (offset + EPL)
y = -be.tan(be.radians(field_y)) * (offset + EPL)
z = optic.surfaces.positions[1] - offset
x0 = be.array(Px) * EPD / 2 * be.array(vx) + x
y0 = be.array(Py) * EPD / 2 * be.array(vy) + y
z0 = be.full_like(Px, z)
else:
z0 = optic.surfaces.positions[0]
x0 = -be.tan(be.radians(field_x)) * (EPL - z0)
y0 = -be.tan(be.radians(field_y)) * (EPL - z0)
if be.size(x0) == 1:
x0 = be.full_like(Px, x0)
if be.size(y0) == 1:
y0 = be.full_like(Px, y0)
if be.size(z0) == 1:
z0 = be.full_like(Px, z0)
return x0, y0, z0
[docs]
def get_paraxial_object_position(self, optic, Hy, y1, EPL):
"""Calculate the position of the object in the paraxial optical system.
Args:
Hy (float): The normalized field height.
y1 (ndarray): The initial y-coordinate of the ray.
EPL (float): The entrance pupil location.
Returns:
tuple: A tuple containing the y and z coordinates of the object
position.
"""
max_field = be.array(optic.fields.max_field)
field_y = max_field * be.array(Hy)
y = -be.tan(be.radians(field_y)) * EPL
z = optic.surfaces.positions[1]
y0 = y1 + y
z0 = be.ones_like(y1) * z
return y0, z0
[docs]
def scale_chief_ray_for_field(self, optic, y_obj_unit, u_obj_unit, y_img_unit):
"""Calculates the scaling factor for a unit chief ray based on the field
definition.
This is used in the paraxial chief_ray calculation. It uses the results
of a forward and backward "unit" trace from the stop to determine the
final scaling factor.
Args:
optic (Optic): The optical system.
y_obj_unit (float): The object-space height of the unit ray.
u_obj_unit (float): The object-space angle of the unit ray.
y_img_unit (float): The image-space height of the unit ray.
Returns:
float: The scaling factor.
"""
max_field_angle = optic.fields.max_y_field
target_slope = be.tan(be.deg2rad(max_field_angle))
return target_slope / u_obj_unit
def _get_starting_z_offset(self, optic):
"""Calculate the starting ray z-coordinate offset for systems with an
object at infinity. This is relative to the first surface of the optic.
This method chooses a starting point that is equivalent to the entrance
pupil diameter of the optic.
Args:
optic (Optic): The optical system being traced.
Returns:
float: The z-coordinate offset relative to the first surface.
"""
z = optic.surfaces.positions[1:-1]
offset = optic.paraxial.EPD()
return offset - be.min(z)