Source code for optiland.nonsequential.detectors.base
"""Base detector for Non-Sequential Raytracing.
Kramer Harrison, 2026
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
import optiland.backend as be
from optiland.nonsequential.components.base import _get_transform
if TYPE_CHECKING:
import numpy as np
from optiland.coordinate_system import CoordinateSystem
from optiland.nonsequential.components.geometry.base import AABB, ComponentGeometry
from optiland.nonsequential.ray_bundle import NSQRayBundle
[docs]
class BaseDetector(ABC):
"""Abstract base class for detectors in the NSQ scene.
Detectors record ray data at a surface. They intersect rays (via their
geometry) and accumulate hit data across simulation batches.
Detectors are **absorbing by default**: a ray that reaches a detector is
recorded and then terminated. Setting ``absorb=False`` records the ray
but lets it continue unchanged, so a detector can be tilted into a
converging beam to sample it mid-system without terminating it.
Several detectors may share one scene; among the detectors a ray would
still reach, only the nearest one sees it, so stacking *absorbing*
detectors down a beam records the beam at the nearest plane and nothing
beyond it -- use ``absorb=False`` (or trace one scene per plane) to
profile a beam at several planes.
Attributes:
cs: Coordinate system defining detector position and orientation.
geometry: Surface geometry that defines the detector area.
name: Optional human-readable label.
absorb: Whether a hit terminates the ray. False => the ray is
recorded and passes through unaffected.
"""
def __init__(
self,
cs: CoordinateSystem,
geometry: ComponentGeometry,
name: str = "",
absorb: bool = True,
) -> None:
"""Initialize BaseDetector.
Args:
cs: Coordinate system.
geometry: Surface geometry.
name: Optional label.
absorb: Whether a hit terminates the ray (default True).
False makes the detector transmissive: the hit is recorded
and the ray continues with its direction unchanged.
"""
self.cs = cs
self.geometry = geometry
self.name = name
self.absorb = bool(absorb)
[docs]
def intersect(
self, rays: NSQRayBundle
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Find ray intersections with this detector surface.
Args:
rays: Ray bundle in global coordinates.
Returns:
Tuple (t, normals, hit_mask) in global frame.
"""
translation, rot = _get_transform(self.cs)
positions_g = be.stack([rays.x, rays.y, rays.z], axis=1)
directions_g = be.stack([rays.L, rays.M, rays.N], axis=1)
t_arr = be.array(translation)
R_arr = be.array(rot)
positions_l = (positions_g - t_arr) @ R_arr
directions_l = directions_g @ R_arr
t_hit, normals_l, hit_mask, _n_geom_l = self.geometry.ray_intersect(
positions_l, directions_l
)
# Geometry may return numpy arrays even in torch-backend mode (geometry
# internals are numpy-based). Convert to the current backend format so
# that be.where dispatches correctly in both NumPy and Torch paths.
t_hit = be.array(t_hit)
normals_l = be.array(normals_l)
hit_mask = be.array(hit_mask)
# T_EPSILON guard: prevent self-intersection
T_EPSILON = 1e-9
t_hit = be.where(t_hit > T_EPSILON, t_hit, be.full_like(t_hit, be.inf))
hit_mask = hit_mask & (t_hit > T_EPSILON)
alive_be = be.array(rays.alive)
t_hit = be.where(alive_be, t_hit, be.full_like(t_hit, be.inf))
hit_mask = hit_mask & alive_be
normals_g = normals_l @ R_arr.T
return t_hit, normals_g, hit_mask
[docs]
@abstractmethod
def record(self, rays: NSQRayBundle, t: np.ndarray, hit_mask: np.ndarray) -> None:
"""Accumulate ray data for rays that hit this detector.
Args:
rays: Current ray bundle. Positions have NOT yet been advanced
to the hit point; use t to compute hit positions.
t: Hit distances [mm], shape (N,).
hit_mask: Boolean mask of rays hitting this detector, shape (N,).
"""
[docs]
@abstractmethod
def get_result(self):
"""Return the accumulated result object.
Returns:
A result object (IrradianceMap, FarFieldPattern, etc.).
"""
[docs]
@abstractmethod
def reset(self) -> None:
"""Clear accumulated data for reuse in a new simulation."""
@property
def bounding_box(self) -> AABB:
"""AABB of this detector in global coordinates."""
transform = _get_transform(self.cs)
return self.geometry.bounding_box(transform)