Source code for optiland.nonsequential.detectors.ray_database

"""Ray database detector for Non-Sequential Raytracing.

Stores individual ray phase-space data at the detector surface.

Kramer Harrison, 2026
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import numpy as np

from optiland.nonsequential._utils import to_numpy
from optiland.nonsequential.detectors.base import BaseDetector
from optiland.nonsequential.results.ray_database import RayDatabase

if TYPE_CHECKING:
    from optiland.coordinate_system import CoordinateSystem
    from optiland.nonsequential.components.geometry.base import ComponentGeometry
    from optiland.nonsequential.ray_bundle import NSQRayBundle


[docs] class RayDatabaseDetector(BaseDetector): """Stores individual ray phase-space data at the detector surface. When store_rays=False, only aggregated flux is stored for maximum throughput. When store_rays=True, full per-ray data is accumulated. Attributes: cs: Coordinate system. geometry: Surface geometry defining the detector extent. store_rays: If True, store individual ray data. If False, only aggregate flux (faster for GPU runs). max_rays: Maximum number of rays to store. If set, uses a circular buffer. None means unlimited. """ def __init__( self, cs: CoordinateSystem, geometry: ComponentGeometry, store_rays: bool = True, max_rays: int | None = None, name: str = "", absorb: bool = True, ) -> None: """Initialize RayDatabaseDetector. Args: cs: Coordinate system. geometry: Surface geometry. store_rays: If True, store per-ray phase-space data. max_rays: Maximum rays to store (circular buffer). None = unlimited. name: Optional label. absorb: Whether a hit terminates the ray (default True). """ super().__init__(cs, geometry, name=name, absorb=absorb) self.store_rays = store_rays self.max_rays = max_rays # Storage lists (appended per batch, concatenated on get_result) self._x: list[np.ndarray] = [] self._y: list[np.ndarray] = [] self._z: list[np.ndarray] = [] self._L: list[np.ndarray] = [] self._M: list[np.ndarray] = [] self._N: list[np.ndarray] = [] self._flux: list[np.ndarray] = [] self._wavelength: list[np.ndarray] = [] self._total_flux: float = 0.0 self._num_rays_hit: int = 0
[docs] def record(self, rays: NSQRayBundle, t: np.ndarray, hit_mask: np.ndarray) -> None: """Record hit ray data. 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 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) # Advance hit positions hx = (x_g + t_np * L_g)[hit_mask_np] hy = (y_g + t_np * M_g)[hit_mask_np] hz = (z_g + t_np * N_g)[hit_mask_np] hL = L_g[hit_mask_np] hM = M_g[hit_mask_np] hN = N_g[hit_mask_np] hflux = flux_np[hit_mask_np] hwl = wl_np[hit_mask_np] self._total_flux += float(hflux.sum()) self._num_rays_hit += len(hx) if self.store_rays: self._x.append(hx) self._y.append(hy) self._z.append(hz) self._L.append(hL) self._M.append(hM) self._N.append(hN) self._flux.append(hflux) self._wavelength.append(hwl) # Apply circular buffer limit if self.max_rays is not None: total = sum(len(a) for a in self._x) if total > self.max_rays: # Rebuild keeping only the last max_rays entries all_x = np.concatenate(self._x)[-self.max_rays :] all_y = np.concatenate(self._y)[-self.max_rays :] all_z = np.concatenate(self._z)[-self.max_rays :] all_L = np.concatenate(self._L)[-self.max_rays :] all_M = np.concatenate(self._M)[-self.max_rays :] all_N = np.concatenate(self._N)[-self.max_rays :] all_f = np.concatenate(self._flux)[-self.max_rays :] all_wl = np.concatenate(self._wavelength)[-self.max_rays :] self._x = [all_x] self._y = [all_y] self._z = [all_z] self._L = [all_L] self._M = [all_M] self._N = [all_N] self._flux = [all_f] self._wavelength = [all_wl]
[docs] def get_result(self) -> RayDatabase: """Return accumulated ray database. Returns: RayDatabase with stored ray phase-space data. """ if self.store_rays and self._x: return RayDatabase( x=np.concatenate(self._x), y=np.concatenate(self._y), z=np.concatenate(self._z), L=np.concatenate(self._L), M=np.concatenate(self._M), N=np.concatenate(self._N), flux=np.concatenate(self._flux), wavelength=np.concatenate(self._wavelength), ) # Aggregated-only mode: return empty database with flux summary return RayDatabase( x=np.array([]), y=np.array([]), z=np.array([]), L=np.array([]), M=np.array([]), N=np.array([]), flux=np.array([self._total_flux]), wavelength=np.array([0.0]), )
[docs] def reset(self) -> None: """Clear accumulated data.""" self._x.clear() self._y.clear() self._z.clear() self._L.clear() self._M.clear() self._N.clear() self._flux.clear() self._wavelength.clear() self._total_flux = 0.0 self._num_rays_hit = 0