Source code for optiland.aperture.object_na
"""Object-Space Numerical Aperture (objectNA) Aperture
Kramer Harrison, 2026
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import optiland.backend as be
from optiland.aperture.base import BaseSystemAperture
if TYPE_CHECKING:
from optiland._types import BEArray, ScalarOrArray
from optiland.optic import Optic
from optiland.paraxial import Paraxial
[docs]
class ObjectNAAperture(BaseSystemAperture):
"""Aperture specified as an object-space numerical aperture.
The entrance pupil diameter is derived from the object-space NA using the
object distance, primary wavelength, and the refractive index of the medium
at the object surface.
Args:
value: Object-space numerical aperture (NA = n * sin(θ)).
"""
_ap_type_key = "objectNA"
def __init__(self, value: ScalarOrArray) -> None:
self._value = value
@property
def ap_type(self) -> str:
return "objectNA"
@property
def value(self) -> ScalarOrArray:
return self._value
@property
def supports_telecentric(self) -> bool:
return True
@property
def is_scalable(self) -> bool:
return False
[docs]
def object_space_sine(
self, optic: Optic, wavelength: float | None = None
) -> BEArray:
"""Convert NA to a launch sine in the actual object medium.
Args:
optic: Optical system providing the object material.
wavelength: Reference wavelength; defaults to the primary wavelength,
independently of the wavelengths of the rays being aimed.
Returns:
Scalar backend-valued sine, retaining NA and refractive-index
gradients. Single-element arrays of any shape are normalized.
Floating input precision is preserved; untyped values use a floating
counterpart's dtype, or the configured precision if neither has one.
Raises:
ValueError: If the object is missing, NA or index is not a single
value, the index is not finite and positive, or NA lies outside
``0 <= NA < n``. Zero defines a chief-only cone; a grazing cone
has no finite forward slope.
"""
if optic.object_surface is None:
raise ValueError("objectNA aperture requires a defined object surface.")
if wavelength is None:
wavelength = optic.primary_wavelength
# Preserve input precision: rounding to the backend default can turn
# a valid NA < n into NA == n before validation.
values = [optic.object_surface.material_post.n(wavelength), self._value]
for i, value in enumerate(values):
if hasattr(value, "dtype") and not be.is_torch_tensor(value):
# Keep floating precision; other host dtypes retain the usual
# backend conversion (some Torch integer types lack comparisons).
values[i] = (
be.asarray(value, dtype=None)
if getattr(value.dtype, "kind", None) == "f"
else be.array(value)
)
for i, value in enumerate(values):
if hasattr(value, "dtype"):
continue
dtype = getattr(values[1 - i], "dtype", None)
values[i] = (
be.asarray(value, dtype=dtype)
if getattr(dtype, "kind", None) == "f"
or getattr(dtype, "is_floating_point", False)
else be.array(value)
)
index, na = values
if be.size(index) != 1:
raise ValueError("Object refractive index must contain exactly one value.")
index = index.reshape(())
if not bool(be.all(be.isfinite(index) & (index > 0))):
raise ValueError("Object refractive index must be finite and positive.")
if be.size(na) != 1:
raise ValueError("Object NA must contain exactly one value.")
na = na.reshape(())
if not bool(be.all(be.isfinite(na) & (na >= 0) & (na < index))):
raise ValueError("Object NA must be finite and satisfy 0 <= NA < n.")
sine = na / index
return sine if be.is_torch_tensor(sine) else be.asarray(sine, dtype=sine.dtype)
[docs]
def compute_epd(self, paraxial: Paraxial, wavelength: float | None = None) -> float:
"""Compute EPD from object-space NA.
Args:
paraxial: Paraxial engine providing access to system geometry and
material data.
wavelength: Primary wavelength in micrometers. When ``None``,
falls back to ``paraxial.optic.primary_wavelength``.
Returns:
Entrance pupil diameter.
Raises:
ValueError: If the object surface is missing, its refractive index
is invalid, or NA lies outside ``0 <= NA < n``.
"""
sine = self.object_space_sine(paraxial.optic, wavelength)
obj_z = paraxial.optic.object_surface.geometry.cs.z
u0 = be.arcsin(sine)
z = paraxial.entrance_pupil_axial_position() - obj_z
# A diameter: a virtual entrance pupil behind the object (z < 0) is as wide
# as one in front of it at the same distance, not negative.
return 2 * be.abs(z) * be.tan(u0)
[docs]
def scale(self, factor: float) -> ObjectNAAperture:
"""Return ``self`` — NA is dimensionless and does not scale.
Args:
factor: Ignored.
Returns:
This same instance (immutable, so returning self is safe).
"""
return self
[docs]
def to_dict(self) -> dict:
return {"type": "objectNA", "value": self._value}
@classmethod
def _from_dict(cls, data: dict) -> ObjectNAAperture:
return cls(data["value"])