Source code for optiland.nonsequential.tracer
"""NSQTracer -- thin coordinator for Non-Sequential Raytracing.
NSQTracer holds backend configuration and delegates the full simulation loop
to the backend.
Kramer Harrison, 2026
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from optiland.nonsequential._utils import DEFAULT_BATCH_SIZE
from optiland.nonsequential.diagnostics import Diagnostics
if TYPE_CHECKING:
from optiland.nonsequential.backends.base import TracerBackend
from optiland.nonsequential.scene import NSQScene
[docs]
@dataclass
class SimulationResult:
"""Top-level result returned by NSQTracer.trace() / NSQScene.trace().
Attributes:
detectors: Per-detector result objects, keyed by detector name.
num_rays_total: Total number of rays launched.
num_rays_absorbed: Rays terminated by absorbing components.
num_rays_escaped: Rays that left the scene with no hit.
num_rays_flux_killed: Rays killed for falling below flux threshold.
num_rays_depth_killed: Rays killed for exceeding max_depth.
total_flux_in: Total flux launched by all sources [W].
total_flux_detected: Total flux recorded on all detectors [W].
total_flux_absorbed: Flux absorbed by AbsorbingComponents [W].
total_flux_bulk_absorbed: Flux lost to Beer-Lambert bulk absorption
while travelling through an absorbing medium (k > 0),
e.g. tinted glass -- distinct from ``total_flux_absorbed``,
which is surface (AbsorbingComponent) absorption only [W].
total_flux_escaped: Flux carried by escaped rays [W].
total_flux_lost: Flux lost to flux/depth kill [W].
flux_conservation_error:
``|flux_in - detected - absorbed - bulk_absorbed - escaped
- lost| / flux_in``.
trace_time_sec: Wall-clock time for the trace [s].
ray_paths: Optional per-ray event log dict (``{"events":
structured_array}``), populated when ``record_paths`` is
truthy -- see :mod:`optiland.nonsequential.path_recording`.
diagnostics: Self-diagnosing summary of this trace --
depth truncation, roulette loss, unreached geometry, per
-detector sampling quality, and a threshold-based warning list.
See :meth:`report` and :mod:`optiland.nonsequential.diagnostics`.
"""
detectors: dict[str, object] = field(default_factory=dict)
num_rays_total: int = 0
num_rays_absorbed: int = 0
num_rays_escaped: int = 0
num_rays_flux_killed: int = 0
num_rays_depth_killed: int = 0
total_flux_in: float = 0.0
total_flux_detected: float = 0.0
total_flux_absorbed: float = 0.0
total_flux_bulk_absorbed: float = 0.0
total_flux_escaped: float = 0.0
total_flux_lost: float = 0.0
flux_conservation_error: float = 0.0
trace_time_sec: float = 0.0
ray_paths: dict | None = None
diagnostics: Diagnostics = field(default_factory=Diagnostics)
[docs]
def report(self) -> str:
"""Full human-readable diagnostic report for this trace.
Returns:
A multi-line string -- see :meth:`Diagnostics.report`.
"""
return self.diagnostics.report()
def __repr__(self) -> str:
"""Concise summary instead of dumping every detector/array field.
Includes a warning count so a warning-bearing result is visible
even when only skimmed in a REPL or notebook cell -- call
:meth:`report` for the full diagnostic text.
"""
n_warnings = len(self.diagnostics.warnings())
warn_note = f", {n_warnings} diagnostic warning(s)" if n_warnings else ""
return (
f"SimulationResult(num_rays_total={self.num_rays_total}, "
f"detectors={list(self.detectors)}, "
f"total_flux_in={self.total_flux_in:.6g}, "
f"total_flux_detected={self.total_flux_detected:.6g}, "
f"flux_conservation_error={self.flux_conservation_error:.3e}"
f"{warn_note})"
)
[docs]
class NSQTracer:
"""Thin coordinator: holds backend configuration, delegates trace loop.
The coordinator pattern decouples scene construction from backend
selection. The full simulation loop lives in the backend.
Usage::
tracer = NSQTracer(scene)
result = tracer.trace(num_rays=1_000_000, seed=42)
Attributes:
scene: The NSQScene to simulate.
backend: TracerBackend instance (defaults to NumpyBackend or
TorchBackend based on the active ``optiland.backend``).
"""
def __init__(
self,
scene: NSQScene,
backend: TracerBackend | None = None,
) -> None:
"""Initialize NSQTracer.
Args:
scene: The NSQScene to trace.
backend: Backend to use. If None, selected automatically from
the active ``optiland.backend`` (numpy → NumpyBackend,
torch → TorchBackend).
"""
self.scene = scene
self.backend = backend
[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 simulation and return results.
Args:
num_rays: Total rays to launch.
max_depth: Maximum surface interactions per ray before
termination.
min_flux_fraction: Kill threshold relative to per-ray initial
flux.
batch_size: Rays per processing batch. Does not change the result,
only the speed; see ``DEFAULT_BATCH_SIZE``.
seed: RNG seed for reproducibility.
backend: Backend override. Uses constructor backend if not given.
Auto-selects from active ``optiland.backend`` if neither
is provided.
record_paths: ``False`` records nothing, ``True`` records every
ray's path, and a positive ``int`` records an approximately
that-many-ray subset selected deterministically by
``ray_id`` hash -- see
:mod:`optiland.nonsequential.path_recording`.
Returns:
SimulationResult.
"""
resolved_backend = backend or self.backend or _default_backend(seed)
return resolved_backend.trace(
self.scene,
num_rays=int(num_rays),
max_depth=max_depth,
min_flux_fraction=min_flux_fraction,
batch_size=batch_size,
seed=seed,
record_paths=record_paths,
)
def _default_backend(seed: int | None) -> TracerBackend:
"""Select a backend based on the active optiland.backend.
Args:
seed: RNG seed forwarded to the backend.
Returns:
NumpyBackend or TorchBackend depending on the active backend.
"""
import optiland.backend as be # noqa: PLC0415
if be.get_backend() == "torch":
from optiland.nonsequential.backends.torch_backend import ( # noqa: PLC0415
TorchBackend,
)
return TorchBackend(seed=seed)
from optiland.nonsequential.backends.numpy_backend import ( # noqa: PLC0415
NumpyBackend,
)
return NumpyBackend(seed=seed)