"""Spectral detector for Non-Sequential Raytracing.
Accumulates per-wavelength irradiance on a planar surface.
Kramer Harrison, 2026
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Literal
import numpy as np
from optiland.nonsequential._utils import as_detached_param, to_numpy
from optiland.nonsequential.components.base import _get_transform
from optiland.nonsequential.components.geometry.analytic.plane import (
FinitePlaneGeometry,
)
from optiland.nonsequential.detectors.base import BaseDetector
from optiland.nonsequential.results.spectral_result import SpectralResult
if TYPE_CHECKING:
from optiland.coordinate_system import CoordinateSystem
from optiland.nonsequential.ray_bundle import NSQRayBundle
# Longest wavelength Optiland's material catalogs cover is well under this, so
# any bin edge above it is a nanometre value that slipped through as µm.
_MAX_PLAUSIBLE_WAVELENGTH_UM = 100.0
[docs]
class SpectralDetector(BaseDetector):
"""Per-wavelength irradiance detector on a planar rectangular surface.
Records flux in a 3D (x, y, wl) grid.
Attributes:
cs: Coordinate system.
width: Detector width [mm].
height: Detector height [mm].
num_pixels_x: Number of pixels along x.
num_pixels_y: Number of pixels along y.
wavelength_bins: Wavelength bin edges [µm].
splat: Spatial splatting mode -- 'bilinear', 'gaussian', or 'hard'.
Splatting is spatial (x, y) only; the wavelength bin is always
hard-assigned.
splat_sigma: Gaussian splat sigma in pixels (used when
``splat='gaussian'``).
"""
def __init__(
self,
cs: CoordinateSystem,
width: float,
height: float,
num_pixels_x: int,
num_pixels_y: int,
wavelength_bins: np.ndarray,
splat: Literal["bilinear", "gaussian", "hard"] = "bilinear",
splat_sigma: float = 0.5,
name: str = "",
absorb: bool = True,
) -> None:
"""Initialize SpectralDetector.
Args:
cs: Coordinate system for detector position/orientation.
width: Detector width [mm].
height: Detector height [mm].
num_pixels_x: Number of pixels along x.
num_pixels_y: Number of pixels along y.
wavelength_bins: Wavelength bin edges [µm], shape (n_lambda + 1,).
splat: Spatial splatting mode. Accepts 'bilinear', 'gaussian',
or 'hard'.
splat_sigma: Gaussian splat sigma in pixels. Only used when
``splat='gaussian'``.
name: Optional label.
absorb: Whether a hit terminates the ray (default True).
Raises:
ValueError: If ``wavelength_bins`` are not plausibly in µm. Bin
edges are compared against ``rays.wavelength``, which is in
µm; nanometre edges would silently clip every ray into the
first bin.
"""
geometry = FinitePlaneGeometry(width=width, height=height)
super().__init__(cs, geometry, name=name, absorb=absorb)
_reason = "it accumulates into a NumPy histogram"
self.width = as_detached_param(width, "width", "SpectralDetector", _reason)
self.height = as_detached_param(height, "height", "SpectralDetector", _reason)
self.num_pixels_x = int(num_pixels_x)
self.num_pixels_y = int(num_pixels_y)
self.splat = splat
self.splat_sigma = float(splat_sigma)
self.wavelength_bins = np.asarray(wavelength_bins, dtype=np.float64)
if self.wavelength_bins.min() > _MAX_PLAUSIBLE_WAVELENGTH_UM:
raise ValueError(
f"wavelength_bins must be in µm, but the smallest bin edge is "
f"{self.wavelength_bins.min():g}. Values this large look like "
f"nanometres - divide by 1000 (e.g. 550 nm -> 0.55)."
)
n_lambda = len(wavelength_bins) - 1
self._flux_map = np.zeros(
(num_pixels_y, num_pixels_x, n_lambda), dtype=np.float64
)
self._num_rays_hit = 0
self._x_edges = np.linspace(
-self.width / 2.0, self.width / 2.0, num_pixels_x + 1
)
self._y_edges = np.linspace(
-self.height / 2.0, self.height / 2.0, num_pixels_y + 1
)
[docs]
def record(self, rays: NSQRayBundle, t: np.ndarray, hit_mask: np.ndarray) -> None:
"""Accumulate per-wavelength flux from hit rays.
Args:
rays: Current ray bundle.
t: Hit distances [mm], shape (N,).
hit_mask: Boolean mask of hitting rays, shape (N,).
"""
hit_mask_np = to_numpy(hit_mask).astype(bool)
if not hit_mask_np.any():
return
translation, rot = _get_transform(self.cs)
t_vec = np.array(translation, dtype=float)
R = np.array(rot, dtype=float)
x_g = to_numpy(rays.x)
y_g = to_numpy(rays.y)
z_g = to_numpy(rays.z)
L_g = to_numpy(rays.L)
M_g = to_numpy(rays.M)
N_g = to_numpy(rays.N)
t_np = to_numpy(t)
flux_np = to_numpy(rays.flux)
wl_np = to_numpy(rays.wavelength)
idx = np.where(hit_mask_np)[0]
t_hit = t_np[idx]
hx_g = x_g[idx] + t_hit * L_g[idx]
hy_g = y_g[idx] + t_hit * M_g[idx]
hz_g = z_g[idx] + t_hit * N_g[idx]
pos_g = np.stack([hx_g, hy_g, hz_g], axis=1)
pos_l = (pos_g - t_vec) @ R
hx_l = pos_l[:, 0]
hy_l = pos_l[:, 1]
flux_hit = flux_np[idx]
wl_hit = wl_np[idx]
iwl = np.clip(
np.searchsorted(self.wavelength_bins, wl_hit, side="right") - 1,
0,
self._flux_map.shape[2] - 1,
)
nx, ny = self.num_pixels_x, self.num_pixels_y
dx = self.width / nx
dy = self.height / ny
if self.splat == "hard":
self._record_hard(hx_l, hy_l, flux_hit, iwl, nx, ny)
elif self.splat == "gaussian":
self._record_gaussian(hx_l, hy_l, flux_hit, iwl, nx, ny, dx, dy)
else:
self._record_bilinear(hx_l, hy_l, flux_hit, iwl, nx, ny, dx, dy)
self._num_rays_hit += hit_mask_np.sum()
def _record_hard(self, hx_l, hy_l, flux_hit, iwl, nx, ny) -> None:
"""Hard-bin spatial accumulation (see ``IrradianceDetector._record_hard``)."""
ix = np.clip(np.searchsorted(self._x_edges, hx_l, side="right") - 1, 0, nx - 1)
iy = np.clip(np.searchsorted(self._y_edges, hy_l, side="right") - 1, 0, ny - 1)
np.add.at(self._flux_map, (iy, ix, iwl), flux_hit)
def _record_bilinear(self, hx_l, hy_l, flux_hit, iwl, nx, ny, dx, dy) -> None:
"""Bilinear spatial splat (see ``IrradianceDetector._record_bilinear``)."""
px = (hx_l + self.width / 2.0) / dx - 0.5
py = (hy_l + self.height / 2.0) / dy - 0.5
ix0 = np.floor(px).astype(np.int64)
iy0 = np.floor(py).astype(np.int64)
wx1 = px - ix0
wy1 = py - iy0
wx0 = 1.0 - wx1
wy0 = 1.0 - wy1
for dix, diy, wx, wy in (
(0, 0, wx0, wy0),
(1, 0, wx1, wy0),
(0, 1, wx0, wy1),
(1, 1, wx1, wy1),
):
ix = np.clip(ix0 + dix, 0, nx - 1)
iy = np.clip(iy0 + diy, 0, ny - 1)
np.add.at(self._flux_map, (iy, ix, iwl), flux_hit * wx * wy)
def _record_gaussian(self, hx_l, hy_l, flux_hit, iwl, nx, ny, dx, dy) -> None:
"""Gaussian spatial splat, truncated and renormalised per ray so
truncation never loses energy (see
``IrradianceDetector._record_gaussian``)."""
sigma = self.splat_sigma
if sigma <= 0.0:
self._record_hard(hx_l, hy_l, flux_hit, iwl, nx, ny)
return
radius = max(1, int(np.ceil(3.0 * sigma)))
px = (hx_l + self.width / 2.0) / dx - 0.5
py = (hy_l + self.height / 2.0) / dy - 0.5
ix0 = np.floor(px).astype(np.int64)
iy0 = np.floor(py).astype(np.int64)
offsets = range(-radius, radius + 1)
gx = {d: np.exp(-0.5 * ((ix0 + d - px) / sigma) ** 2) for d in offsets}
gy = {d: np.exp(-0.5 * ((iy0 + d - py) / sigma) ** 2) for d in offsets}
sx = sum(gx.values())
sy = sum(gy.values())
norm = sx * sy
for dix in offsets:
ix = np.clip(ix0 + dix, 0, nx - 1)
for diy in offsets:
iy = np.clip(iy0 + diy, 0, ny - 1)
weight = (gx[dix] * gy[diy]) / norm
np.add.at(self._flux_map, (iy, ix, iwl), flux_hit * weight)
[docs]
def get_result(self) -> SpectralResult:
"""Return accumulated spectral result.
Returns:
SpectralResult with irradiance [W/mm^2] per pixel per wavelength bin.
"""
pixel_area = (self.width / self.num_pixels_x) * (
self.height / self.num_pixels_y
)
irradiance = self._flux_map / pixel_area
x_centres = 0.5 * (self._x_edges[:-1] + self._x_edges[1:])
y_centres = 0.5 * (self._y_edges[:-1] + self._y_edges[1:])
# wl_centres in µm
wl_centres = 0.5 * (self.wavelength_bins[:-1] + self.wavelength_bins[1:])
return SpectralResult(
irradiance=irradiance.copy(),
x_coords=x_centres,
y_coords=y_centres,
wavelengths=wl_centres,
total_flux=float(self._flux_map.sum()),
num_rays_hit=self._num_rays_hit,
)
[docs]
def reset(self) -> None:
"""Clear accumulated data."""
self._flux_map[:] = 0.0
self._num_rays_hit = 0