Source code for optiland.nonsequential.sources.base

"""Base source and Spectrum for Non-Sequential Raytracing.

Kramer Harrison, 2026
"""

from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING

import numpy as np

from optiland.nonsequential.rng import EventSlot

if TYPE_CHECKING:
    from optiland.coordinate_system import CoordinateSystem
    from optiland.nonsequential.ray_bundle import NSQRayBundle
    from optiland.nonsequential.rng import NSQRng


[docs] @dataclass class Spectrum: """Wavelength distribution for Monte Carlo sampling. Attributes: wavelengths: Wavelength values [µm]. weights: Relative spectral power weights (unnormalized). """ wavelengths: np.ndarray weights: np.ndarray def __post_init__(self) -> None: """Normalize weights and build inverse-CDF for sampling.""" self.wavelengths = np.asarray(self.wavelengths, dtype=np.float64) self.weights = np.asarray(self.weights, dtype=np.float64) if self.wavelengths.shape != self.weights.shape: raise ValueError("wavelengths and weights must have the same shape.") if np.any(self.wavelengths > 20.0): raise ValueError("Wavelengths must be in µm (expected range 0.1-20 µm).") # Build cumulative distribution for inverse-CDF sampling self._cdf = np.cumsum(self.weights) self._cdf /= self._cdf[-1]
[docs] @classmethod def monochromatic(cls, wavelength: float) -> Spectrum: """Create a monochromatic spectrum at a single wavelength. Args: wavelength: Wavelength [µm]. Returns: A Spectrum with a single wavelength. """ return cls( wavelengths=np.array([wavelength]), weights=np.array([1.0]), )
[docs] def sample(self, ray_id: np.ndarray, bounce: np.ndarray, rng: NSQRng) -> np.ndarray: """Sample one wavelength per ray from the spectrum. Uses inverse-CDF (quantile) sampling. Args: ray_id: Per-ray identifiers, shape (N,). bounce: Per-ray bounce/step index, shape (N,) or scalar. rng: Keyed PCG32 RNG. Returns: Sampled wavelengths [µm], shape (N,). """ num = len(ray_id) if len(self.wavelengths) == 1: return np.full(num, self.wavelengths[0]) u = rng.uniform(ray_id, bounce, EventSlot.SOURCE_WAVELENGTH) indices = np.searchsorted(self._cdf, u) indices = np.clip(indices, 0, len(self.wavelengths) - 1) return self.wavelengths[indices]
[docs] class BaseNSQSource(ABC): """Abstract base class for non-sequential ray sources. Attributes: cs: Coordinate system defining source position/orientation. spectrum: Wavelength distribution for Monte Carlo sampling. total_flux: Source total flux [W] (or photons/sec). """ def __init__( self, cs: CoordinateSystem, spectrum: Spectrum, total_flux: float = 1.0, ) -> None: """Initialize BaseNSQSource. Args: cs: Coordinate system for source position/orientation. spectrum: Wavelength distribution. total_flux: Total emitted flux [W]. """ self.cs = cs self.spectrum = spectrum # Keep as-is (float or torch.Tensor) so autograd graph is not severed self.total_flux = total_flux
[docs] @abstractmethod def generate(self, ray_id: np.ndarray, rng: NSQRng) -> NSQRayBundle: """Generate one ray per id in global coordinates. Each ray carries: - position sampled from source geometry - direction sampled from source emission pattern - wavelength sampled from spectrum (Monte Carlo) - initial flux = total_flux / len(ray_id) All random draws are keyed by ``ray_id`` (at ``bounce=0``), so a ray's birth-time sampling depends only on its own id -- never on ``batch_size`` or on the order sources/batches are processed in. Args: ray_id: Unique identifiers for the rays to generate, shape (N,). rng: Keyed PCG32 RNG. Returns: NSQRayBundle with all rays alive, ``ray_id`` set, and flux = total_flux / len(ray_id). """