Source code for fields.field_types.angle

"""Angle Field Module

Kramer Harrison, 2025
"""

from __future__ import annotations

import optiland.backend as be
from optiland.paraxial_path import (
    AMBIGUOUS_WIDE_ANGLE_FIELD,
    UnsupportedParaxialGeometryError,
    require_nonsingular_tangent_angles,
)
from optiland.utils import globalize_coordinates

from .base import BaseFieldDefinition

# Below this angle (degrees) a field component is treated as zero for the
# wide-angle ambiguity check.
_MATERIAL_ANGLE_DEG = 1e-9


def _validate_unambiguous_field(field_x, field_y) -> None:
    """Reject two-dimensional angle fields whose direction is ambiguous.

    The current component-angle representation composes ``tan(fx)`` and
    ``tan(fy)`` with a hemisphere rule based on the total angle. That
    uniquely defines a direction for one-dimensional fields at any angle
    and for two-dimensional fields inside 90 degrees; a two-dimensional
    field at or beyond 90 degrees has no unique three-dimensional direction
    in this representation, so it is rejected rather than silently mapped.

    Raises:
        UnsupportedParaxialGeometryError: With code
            ``AMBIGUOUS_WIDE_ANGLE_FIELD`` when both components are
            materially nonzero and either component or the total field
            angle crosses 90 degrees.
    """
    fx = be.to_numpy(be.atleast_1d(be.array(field_x))).reshape(-1)
    fy = be.to_numpy(be.atleast_1d(be.array(field_y))).reshape(-1)
    if fx.size == 1 and fy.size > 1:
        fx = fx.repeat(fy.size)
    if fy.size == 1 and fx.size > 1:
        fy = fy.repeat(fx.size)
    both = (abs(fx) > _MATERIAL_ANGLE_DEG) & (abs(fy) > _MATERIAL_ANGLE_DEG)
    total = (fx**2 + fy**2) ** 0.5
    wide = (abs(fx) >= 90.0) | (abs(fy) >= 90.0) | (total >= 90.0)
    bad = both & wide
    if bad.any():
        i = int(bad.nonzero()[0][0])
        raise UnsupportedParaxialGeometryError(
            f"[{AMBIGUOUS_WIDE_ANGLE_FIELD}] the two-dimensional angle "
            f"field (fx={fx[i]:.6g} deg, fy={fy[i]:.6g} deg, total "
            f"{total[i]:.6g} deg) reaches 90 degrees. The current "
            "component-angle representation does not uniquely define a "
            "three-dimensional direction in that regime; use a "
            "one-dimensional field, or wait for an explicit wide-angle "
            "field convention (polar angle + azimuth or direction "
            "cosines)."
        )


[docs] @BaseFieldDefinition.register("angle") class AngleField(BaseFieldDefinition): """Defines fields by angle (in degrees) relative to the optical axis. For an object at infinity the field direction is composed from the two component angles against the entry axis; ``(u, v)`` pupil/field offsets use the entry frame's transverse basis for folded or off-axis-entered systems. Two-dimensional fields at or beyond 90 degrees total are ambiguous under this representation and are rejected (see :func:`_validate_unambiguous_field`). """
[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: _validate_unambiguous_field(field_x, field_y) require_nonsingular_tangent_angles( field_x, field_y, operation="infinite-conjugate ray origins" ) EPD = optic.paraxial.EPD() offset = self._get_starting_z_offset(optic) d = offset + EPL # Past 90 deg, N = cos(theta) is negative, so the launch point # must sit downstream of the pupil (ray runs backward into it) # rather than upstream -- otherwise (L, M, N), which for # infinite conjugates is never refined past this seed, folds # back into (-90, 90) instead of reaching the true angle. theta_total = be.sqrt(field_x**2 + field_y**2) s = be.where(be.cos(be.radians(theta_total)) < 0, -1.0, 1.0) frame = optic.surfaces._entry_frame() if frame is None: z_pupil = optic.paraxial.entrance_pupil_axial_position() x = -s * be.tan(be.radians(field_x)) * d y = -s * be.tan(be.radians(field_y)) * d z = z_pupil - s * d x0 = be.array(Px) * EPD / 2 * be.array(vx) + x y0 = be.array(Py) * EPD / 2 * be.array(vy) + y z0 = be.zeros_like(Px) + z else: # The beam path is folded off the z axis: launch on the # entry line, a distance d behind the entrance pupil's # apparent (unfolded) position, with the pupil and field # offsets laid out on the entry frame's transverse basis. # Field angles measure against the entry axis, which is the # same thing the z-based branch means wherever both are # defined. See SurfaceGroup._entry_frame. anchor, axial, d0, u0, v0 = frame ep = optic.paraxial.entrance_pupil_axial_position() back = ep - axial - s * d tu = ( be.array(Px) * EPD / 2 * be.array(vx) - s * be.tan(be.radians(field_x)) * d ) tv = ( be.array(Py) * EPD / 2 * be.array(vy) - s * be.tan(be.radians(field_y)) * d ) x0 = anchor[0] + back * d0[0] + tu * u0[0] + tv * v0[0] y0 = anchor[1] + back * d0[1] + tu * u0[1] + tv * v0[1] z0 = anchor[2] + back * d0[2] + tu * u0[2] + tv * v0[2] else: require_nonsingular_tangent_angles( field_x, field_y, operation="finite-conjugate ray origins" ) dist_to_ep = ( optic.paraxial.entrance_pupil_axial_position() - optic.surfaces.positions[0] ) x_local = be.atleast_1d(be.array(-be.tan(be.radians(field_x)) * dist_to_ep)) y_local = be.atleast_1d(be.array(-be.tan(be.radians(field_y)) * dist_to_ep)) z_local = obj.geometry.sag(x_local, y_local) # Globalize the local coordinates x0, y0, z0 = globalize_coordinates(obj, x_local, y_local, z_local) if be.size(x0) == 1: x0 = be.full_like(be.atleast_1d(Px), x0) if be.size(y0) == 1: y0 = be.full_like(be.atleast_1d(Px), y0) if be.size(z0) == 1: z0 = be.full_like(be.atleast_1d(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) require_nonsingular_tangent_angles( field_y, operation="paraxial object-position construction" ) 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 require_nonsingular_tangent_angles( max_field_angle, operation="chief-ray field scaling" ) 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] - optic.surfaces.positions[1] offset = optic.paraxial.EPD() return offset - be.min(z)