"""NSQScene -- single user-facing entry point for Non-Sequential Raytracing.
NSQScene owns three typed registries (components, sources, detectors) and
exposes builder methods, a .trace() shortcut, and .view()/.view3d() for
visualization.
Kramer Harrison, 2026
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import optiland.backend as be
from optiland.nonsequential._utils import DEFAULT_BATCH_SIZE
from optiland.nonsequential.components.registry import ComponentRegistry
from optiland.nonsequential.detectors.registry import DetectorRegistry
from optiland.nonsequential.sources.registry import SourceRegistry
if TYPE_CHECKING:
import os
from optiland.coordinate_system import CoordinateSystem
from optiland.nonsequential.backends.base import TracerBackend
from optiland.nonsequential.components.base import BaseComponent
from optiland.nonsequential.components.configs import (
DoubletConfig,
LensConfig,
MirrorConfig,
)
from optiland.nonsequential.tracer import SimulationResult
[docs]
class NSQScene:
"""Single user-facing entry point for Non-Sequential Raytracing.
NSQScene owns three typed registries and exposes builder methods for
common optical elements (lenses, mirrors, doublets), sources, and
detectors. The tracer works on flat surface/source/detector lists
exposed via read-only properties.
Usage::
scene = NSQScene()
scene.add_source('S1', cs, PointSourceConfig(...))
scene.add_lens('L1', cs, LensConfig(...))
scene.add_detector('D1', cs, IrradianceDetectorConfig(...))
result = scene.trace(num_rays=1_000_000, seed=42)
Mutation note: Components are held by reference. Modifying a component's
CoordinateSystem or geometry after scene construction is valid and takes
effect on the next trace() call. If a BVH acceleration structure is later
introduced, call scene.invalidate_cache() after any structural change.
Attributes:
component_registry: Registry of named compound components.
source_registry: Registry of named sources.
detector_registry: Registry of named detectors.
"""
def __init__(self) -> None:
"""Initialize an empty NSQScene."""
self.component_registry = ComponentRegistry()
self.source_registry = SourceRegistry()
self.detector_registry = DetectorRegistry()
# Rare-path sampling policy: importance biasing, bounded
# splitting (NumPy forward engine only), and Russian roulette. The
# default reproduces the engine's pre-PR11 behaviour exactly -- see
# optiland.nonsequential.ir.scene_ir.SamplingPolicy.
from optiland.nonsequential.ir.scene_ir import ( # noqa: PLC0415
SamplingPolicy,
)
self.sampling_policy = SamplingPolicy()
@property
def surfaces(self) -> list[BaseComponent]:
"""Flat list of all component sub-surfaces in registration order."""
return self.component_registry.surfaces
@property
def sources(self):
"""Ordered list of all registered sources."""
return self.source_registry.sources
@property
def detectors(self):
"""Ordered list of all registered detectors."""
return self.detector_registry.detectors
@property
def component_names(self) -> list[str]:
"""Names of the registered components, in registration order."""
return list(self.component_registry._registry.keys())
@property
def source_names(self) -> list[str]:
"""Names of the registered sources, in registration order."""
return list(self.source_registry._registry.keys())
@property
def detector_names(self) -> list[str]:
"""Names of the registered detectors, in registration order.
These are the keys of ``SimulationResult.detectors``.
"""
return list(self.detector_registry._registry.keys())
[docs]
def add_lens(
self,
name: str,
cs: CoordinateSystem,
config: LensConfig,
) -> None:
"""Add a single refractive lens to the scene.
Args:
name: Unique name for the lens in the registry.
cs: Front-vertex coordinate system.
config: LensConfig describing the lens geometry.
"""
from optiland.nonsequential.components.lens import Lens # noqa: PLC0415
self.component_registry.add(name, Lens(name, cs, config))
[docs]
def add_mirror(
self,
name: str,
cs: CoordinateSystem,
config: MirrorConfig,
) -> None:
"""Add a reflective mirror to the scene.
Args:
name: Unique name for the mirror in the registry.
cs: Surface coordinate system.
config: MirrorConfig describing the mirror geometry.
"""
from optiland.nonsequential.components.mirror import Mirror # noqa: PLC0415
self.component_registry.add(name, Mirror(name, cs, config))
[docs]
def add_doublet(
self,
name: str,
cs: CoordinateSystem,
config: DoubletConfig,
) -> None:
"""Add a cemented achromatic doublet to the scene.
Args:
name: Unique name for the doublet in the registry.
cs: Front-vertex coordinate system.
config: DoubletConfig describing the doublet geometry.
"""
from optiland.nonsequential.components.doublet import Doublet # noqa: PLC0415
self.component_registry.add(name, Doublet(name, cs, config))
[docs]
def add_component(self, name: str, component: BaseComponent) -> None:
"""Add a raw BaseComponent (advanced use).
Args:
name: Unique name for this component in the registry.
component: Pre-built BaseComponent to register.
"""
from optiland.nonsequential.components.compound import ( # noqa: PLC0415
CompoundComponent,
)
class _SingleSurface(CompoundComponent):
def __init__(self_, n: str, c: BaseComponent) -> None:
self_._name = n
self_._component = c
@property
def name(self_) -> str:
return self_._name
@property
def surfaces(self_) -> list[BaseComponent]:
return [self_._component]
@property
def coordinate_system(self_):
return self_._component.cs
self.component_registry.add(name, _SingleSurface(name, component))
[docs]
def add_source(
self,
name: str,
cs: CoordinateSystem,
config,
) -> None:
"""Add a source to the scene.
Args:
name: Unique string name for the source.
cs: Coordinate system for the source.
config: Source config dataclass (PointSourceConfig,
CollimatedSourceConfig, or ExtendedSourceConfig).
"""
source = _build_source(cs, config)
self.source_registry.add(name, source)
[docs]
def add_detector(
self,
name: str,
cs: CoordinateSystem,
config,
) -> None:
"""Add a detector to the scene.
Args:
name: Unique string name for the detector.
cs: Coordinate system for the detector.
config: Detector config dataclass (IrradianceDetectorConfig,
SpectralDetectorConfig, FarFieldDetectorConfig, or
RayDatabaseConfig).
"""
detector = _build_detector(cs, config)
self.detector_registry.add(name, detector)
[docs]
def remove_component(self, name: str) -> None:
"""Remove a compound component by name.
Args:
name: Registry name of the component to remove.
"""
self.component_registry.remove(name)
[docs]
def remove_source(self, name: str) -> None:
"""Remove a source by name.
Args:
name: Registry name of the source to remove.
"""
self.source_registry.remove(name)
[docs]
def remove_detector(self, name: str) -> None:
"""Remove a detector by name.
Args:
name: Registry name of the detector to remove.
"""
self.detector_registry.remove(name)
[docs]
def trace(
self,
num_rays: int,
max_depth: int = 16,
min_flux_fraction: float = 1e-6,
batch_size: int = DEFAULT_BATCH_SIZE,
seed: int | None = None,
backend: TracerBackend | None = None,
record_paths: bool | int = False,
) -> SimulationResult:
"""Run the Monte Carlo simulation and return results.
Args:
num_rays: Total rays to launch.
max_depth: Maximum surface hits per ray.
min_flux_fraction: Russian-roulette threshold, relative to
per-ray initial flux -- combined with the scene's
``sampling_policy.rr_start_flux`` (the larger of the two
wins). Below threshold, rays are killed with an unbiased
probability and survivors' flux is boosted accordingly,
rather than truncated outright.
batch_size: Rays per processing batch. Does not change the result,
only the speed; see ``DEFAULT_BATCH_SIZE``.
seed: RNG seed.
backend: TracerBackend to use. Defaults to NumpyBackend or
TorchBackend based on the active ``optiland.backend``.
record_paths: ``False`` (default) records nothing. ``True``
records every ray's full path. A positive ``int`` records
an approximately that-many-ray subset, selected
deterministically by ``ray_id`` hash, so a
large trace stays cheap while still yielding a bounded
sample for visualization/diagnosis, e.g.
``scene.trace(num_rays=10_000_000, record_paths=1_000)``.
Returns:
SimulationResult with per-detector results and statistics.
"""
from optiland.nonsequential.tracer import NSQTracer # noqa: PLC0415
self.validate()
tracer = NSQTracer(self, backend=backend)
return tracer.trace(
num_rays,
max_depth=max_depth,
min_flux_fraction=min_flux_fraction,
batch_size=batch_size,
seed=seed,
record_paths=record_paths,
)
[docs]
def view(
self,
result: SimulationResult | None = None,
**kwargs,
) -> None:
"""Render a 2D (matplotlib) cross-section of the scene.
Args:
result: Optional SimulationResult with ray paths to overlay.
**kwargs: Forwarded to NSQViewer2D.view().
"""
from optiland.nonsequential.visualization import NSQViewer2D # noqa: PLC0415
NSQViewer2D(self).view(result, **kwargs)
[docs]
def view3d(
self,
result: SimulationResult | None = None,
**kwargs,
) -> None:
"""Render a 3D (VTK) scene visualization.
Args:
result: Optional SimulationResult with ray paths to overlay.
**kwargs: Forwarded to NSQViewer3D.view().
"""
from optiland.nonsequential.visualization import NSQViewer3D # noqa: PLC0415
NSQViewer3D(self).view(result, **kwargs)
[docs]
def to_json(self, path: str | os.PathLike) -> None:
"""Serialize the scene to a versioned JSON file.
Serializes all components, sources, and detectors. Simulation
results and accumulated detector data are **not** included.
Tensor values (e.g. ``torch.Tensor`` parameters) are detached and
written as plain floats. ``requires_grad`` is not persisted; to
differentiate a loaded scene, re-wrap the relevant parameters in
``torch.tensor(..., requires_grad=True)`` after loading.
Args:
path: Destination file path (created or overwritten).
Raises:
TypeError: If a component/source/detector type is not serializable.
ValueError: If a material cannot be round-tripped (e.g. no
catalog name is available).
Example::
scene.to_json("my_scene.json")
"""
from optiland.nonsequential.serialization import ( # noqa: PLC0415
scene_to_json,
)
scene_to_json(self, path)
[docs]
@classmethod
def from_json(cls, path: str | os.PathLike) -> NSQScene:
"""Load a scene from a versioned JSON file.
The loaded scene is plain-valued: all numeric parameters are Python
floats, not tensors. Re-wrap parameters in
``torch.tensor(..., requires_grad=True)`` if you need gradients.
Args:
path: Path to a JSON file previously written by
:meth:`to_json`.
Returns:
Reconstructed :class:`NSQScene`.
Raises:
FileNotFoundError: If ``path`` does not exist.
ValueError: If the ``nsq_schema_version`` is missing or does not
match the current loader.
Example::
scene = NSQScene.from_json("my_scene.json")
"""
from optiland.nonsequential.serialization import ( # noqa: PLC0415
scene_from_json,
)
return scene_from_json(path)
[docs]
def validate(self) -> None:
"""Validate the scene for common configuration errors.
Raises:
ValueError: If no sources or no detectors are registered.
"""
if not self.source_registry.sources:
raise ValueError("Scene has no sources. Add at least one source.")
if not self.detector_registry.detectors:
raise ValueError("Scene has no detectors. Add at least one detector.")
def _resolve_total_flux(config) -> float:
"""Resolve a source config's radiometric flux [W].
``total_flux_lumens``, when set, takes precedence over ``total_flux``
and is converted to watts via
:func:`optiland.nonsequential.units.lumens_to_watts` using the config's
own spectrum -- NSQ's trace loop is radiometric throughout, so this
conversion happens once, here, at scene-construction time.
Args:
config: A source config with ``total_flux``, ``total_flux_lumens``,
and ``spectrum`` attributes.
Returns:
Radiometric flux [W].
Raises:
ValueError: If ``total_flux_lumens`` is set and ``spectrum`` has
negligible overlap with the visible band (guardrail).
"""
lumens = getattr(config, "total_flux_lumens", None)
if lumens is None:
return config.total_flux
if config.total_flux != 1.0:
import warnings # noqa: PLC0415
warnings.warn(
f"{type(config).__name__} was given both total_flux="
f"{config.total_flux!r} and total_flux_lumens={lumens!r}; "
"total_flux_lumens takes precedence and total_flux is ignored.",
stacklevel=3,
)
from optiland.nonsequential.units import lumens_to_watts # noqa: PLC0415
return lumens_to_watts(lumens, config.spectrum)
def _build_source(cs: CoordinateSystem, config) -> object:
"""Instantiate a BaseNSQSource from a config dataclass.
Args:
cs: Coordinate system for the source.
config: One of PointSourceConfig, CollimatedSourceConfig,
ExtendedSourceConfig.
Returns:
Constructed source object.
Raises:
TypeError: If the config type is not recognised.
"""
from optiland.nonsequential.sources.collimated import ( # noqa: PLC0415
CollimatedSource,
)
from optiland.nonsequential.sources.configs import ( # noqa: PLC0415
CollimatedSourceConfig,
ExtendedSourceConfig,
PointSourceConfig,
)
from optiland.nonsequential.sources.extended import ExtendedSource # noqa: PLC0415
from optiland.nonsequential.sources.point import PointSource # noqa: PLC0415
if isinstance(config, PointSourceConfig):
return PointSource(
cs=cs,
spectrum=config.spectrum,
total_flux=_resolve_total_flux(config),
half_angle_deg=config.half_angle_deg,
medium=getattr(config, "medium", None),
)
if isinstance(config, CollimatedSourceConfig):
return CollimatedSource(
cs=cs,
spectrum=config.spectrum,
total_flux=_resolve_total_flux(config),
aperture_radius=config.aperture_radius,
profile=config.profile,
gaussian_sigma=config.gaussian_sigma,
medium=getattr(config, "medium", None),
)
if isinstance(config, ExtendedSourceConfig):
return ExtendedSource(
cs=cs,
spectrum=config.spectrum,
total_flux=_resolve_total_flux(config),
width=config.width,
height=config.height,
aperture_radius=config.aperture_radius,
half_angle_deg=config.half_angle_deg,
medium=getattr(config, "medium", None),
)
raise TypeError(
f"Unrecognised source config type: {type(config).__name__}. "
"Expected PointSourceConfig, CollimatedSourceConfig, or ExtendedSourceConfig."
)
def _build_detector(cs: CoordinateSystem, config) -> object:
"""Instantiate a BaseDetector from a config dataclass.
Args:
cs: Coordinate system for the detector.
config: One of IrradianceDetectorConfig, SpectralDetectorConfig,
FarFieldDetectorConfig, RayDatabaseConfig.
Returns:
Constructed detector object.
Raises:
TypeError: If the config type is not recognised.
"""
from optiland.nonsequential.detectors.configs import ( # noqa: PLC0415
FarFieldDetectorConfig,
IrradianceDetectorConfig,
RayDatabaseConfig,
SpectralDetectorConfig,
)
from optiland.nonsequential.detectors.far_field import (
FarFieldDetector, # noqa: PLC0415
)
from optiland.nonsequential.detectors.irradiance import (
IrradianceDetector, # noqa: PLC0415
)
from optiland.nonsequential.detectors.ray_database import ( # noqa: PLC0415
RayDatabaseDetector,
)
from optiland.nonsequential.detectors.spectral import (
SpectralDetector, # noqa: PLC0415
)
if isinstance(config, IrradianceDetectorConfig):
return IrradianceDetector(
cs=cs,
width=config.width,
height=config.height,
num_pixels_x=config.num_pixels_x,
num_pixels_y=config.num_pixels_y,
splat=config.splat,
splat_sigma=config.splat_sigma,
absorb=config.absorb,
)
if isinstance(config, SpectralDetectorConfig):
wl_bins = be.linspace(config.wl_min, config.wl_max, config.num_bins + 1)
return SpectralDetector(
cs=cs,
width=config.width,
height=config.height,
num_pixels_x=config.num_pixels_x,
num_pixels_y=config.num_pixels_y,
wavelength_bins=wl_bins,
splat=config.splat,
splat_sigma=config.splat_sigma,
absorb=config.absorb,
)
if isinstance(config, FarFieldDetectorConfig):
return FarFieldDetector(
cs=cs,
theta_max_deg=90.0,
num_bins_theta=config.num_theta,
num_bins_phi=config.num_phi,
absorb=config.absorb,
)
if isinstance(config, RayDatabaseConfig):
from optiland.nonsequential.components.geometry.analytic.plane import ( # noqa: PLC0415
FinitePlaneGeometry,
)
geometry = FinitePlaneGeometry(width=config.width, height=config.height)
return RayDatabaseDetector(
cs=cs,
geometry=geometry,
# 0 ("unlimited", the config default) maps to RayDatabaseDetector's
# own None-means-unlimited convention (this was
# previously accepted and silently dropped -- the circular-buffer
# limit never took effect).
max_rays=config.max_rays if config.max_rays > 0 else None,
absorb=config.absorb,
)
raise TypeError(f"Unrecognised detector config type: {type(config).__name__}.")