Source code for optiland.nonsequential.ir.scene_ir

"""SceneIR -- the top-level data-only scene description.

Kramer Harrison, 2026
"""

from __future__ import annotations

from dataclasses import dataclass, field, fields, is_dataclass
from typing import Any, Literal

import numpy as np

from optiland.backend.utils import is_torch_tensor
from optiland.nonsequential.ir.bsdf_ir import BsdfIR
from optiland.nonsequential.ir.medium_ir import MediumIR

# Primitive geometry kinds this revamp's lowering knows how to produce.
# "mesh" is included for completeness but requires the optional trimesh
# dependency to round-trip back into a live MeshGeometry.
PrimitiveKind = Literal["conic", "plane", "annulus", "frustum", "sphere", "mesh"]

# Which physical interaction a primitive's hit dispatches to, independent of
# any BsdfIR scatter overlay. A future BSDF-lobe rework could fold
# refractive/reflective/absorbing into BsdfIR itself, at which point this
# field would become redundant -- it has not been folded in yet.
ComponentKind = Literal["refractive", "reflective", "absorbing"]

EmitterKind = Literal["point", "collimated", "extended"]

SensorKind = Literal["irradiance", "spectral", "far_field", "ray_database"]


[docs] @dataclass(frozen=True) class PrimitiveIR: """One surface, as plain data. Attributes: id: Index into ``SceneIR.primitives``; also referenced by ``interior_medium_id``/``exterior_medium_id`` of neighbouring volumes once ``Volume`` objects are wired into the IR from these ids (``Volume`` itself already exists in ``components/volume.py``; the IR-level wiring does not yet). kind: Geometry family; ``params`` is interpreted according to it. to_world: ``(4, 4)`` homogeneous local -> global transform. params: Kind-specific geometry parameters, e.g. for ``"conic"``: ``{"radius": ..., "conic": ..., "aperture_radius": ...}``. Values may be plain floats or backend arrays (a differentiable ``torch.Tensor`` stays attached; the translatability checklist only forbids closures and stateful objects, not autograd-carrying arrays). bsdf: Attached scatter model, if any (``BsdfIR(kind="none")`` when the surface is bare specular/refractive/absorbing). interior_medium_id: Index into ``SceneIR.media`` for the medium this surface bounds on its back side. Descriptive metadata only: the authoritative sidedness determination lives in each geometry's ``n_geom`` (see ``ComponentGeometry.ray_intersect`` and ``RefractiveComponent.interact``), not in these ids -- the interpreter still reads the medium directly off the live component. No volume topology is wired through the IR yet. exterior_medium_id: As above, for the front side. volume_id: Index into ``SceneIR.volumes``, or ``None`` when this primitive is not (yet) a volume boundary. Always ``None`` until volumes are wired into the IR. component_kind: Which physical interaction this primitive's hit dispatches to (see :data:`ComponentKind`). scatter_fraction: Probability that a hit ray is routed through ``bsdf`` rather than the specular/refractive path. name: Human-readable label, for diagnostics. """ id: int kind: PrimitiveKind to_world: np.ndarray params: dict[str, Any] bsdf: BsdfIR interior_medium_id: int exterior_medium_id: int volume_id: int | None component_kind: ComponentKind scatter_fraction: float name: str = ""
[docs] @dataclass(frozen=True) class VolumeIR: """A closed, outward-oriented set of boundary primitives (reserved). Not populated by :func:`~optiland.nonsequential.ir.lower.lower` -- ``SceneIR.volumes`` is always ``()``. Medium sidedness does not need a ``Volume`` registry to be correct: each geometry's ``n_geom`` fixes the front/back determination directly (see ``RefractiveComponent.interact``). ``Volume`` itself -- watertightness validation, CSG composition, and ``Lens``/``Doublet``/``Mirror`` built on top of it -- already exists in ``optiland.nonsequential.components.volume``; only the IR-level wiring (populating this dataclass from a live scene's volumes) remains. This dataclass is defined now so the IR's shape will not need to change again when that wiring is added. Attributes: id: Index into ``SceneIR.volumes``. name: Human-readable label. boundary_primitive_ids: Ids into ``SceneIR.primitives`` forming the closed boundary. interior_medium_id: Index into ``SceneIR.media`` for the volume's interior. """ id: int name: str boundary_primitive_ids: tuple[int, ...] interior_medium_id: int
[docs] @dataclass(frozen=True) class EmitterIR: """A ray source, as plain data. Attributes: id: Index into ``SceneIR.emitters``. kind: Source family; ``params`` is interpreted according to it. to_world: ``(4, 4)`` homogeneous local -> global transform. params: Kind-specific parameters, including ``total_flux`` and the ``spectrum`` dict (``{"wavelengths": [...], "weights": [...]}``). medium_id: Index into ``SceneIR.media`` for the medium the source is embedded in, or ``None`` for vacuum. name: Human-readable label. """ id: int kind: EmitterKind to_world: np.ndarray params: dict[str, Any] medium_id: int | None name: str = ""
[docs] @dataclass(frozen=True) class SensorIR: """A detector, as plain data. Attributes: id: Index into ``SceneIR.sensors``. kind: Detector family; ``params`` is interpreted according to it. to_world: ``(4, 4)`` homogeneous local -> global transform. params: Kind-specific parameters (extents, pixel counts, splat, ...). primitive_id: Index into ``SceneIR.primitives``, reserved for a future unification of detectors into the primitive list itself. Always ``None`` -- detectors are dispatched by :mod:`optiland.nonsequential.detectors.dispatch`, a single nearest-hit routine shared by both reference backends (PR10 deleted the two near-duplicate ``_intersect_detectors`` implementations that previously lived on ``ArrayBackend``/``TorchBackend`` and had diverged in their grad-attachment semantics; D-10). absorb: Whether a hit terminates the ray. ``False`` => transmissive, mid-system sampling: the hit is recorded and the ray continues unchanged. Implemented as of PR10; mirrors the live detector's ``BaseDetector.absorb``. name: Human-readable label. """ id: int kind: SensorKind to_world: np.ndarray params: dict[str, Any] primitive_id: int | None = None absorb: bool = True name: str = ""
[docs] @dataclass(frozen=True) class RngContract: """Which RNG algorithm the scene's random draws are contracted to. Not a trace seed -- ``trace(seed=...)`` stays a per-call argument. This documents the *algorithm* every conforming backend must implement: PCG32, keyed by ``(seed, ray_id, bounce, event_slot)``. See :mod:`optiland.nonsequential.rng`. Attributes: algorithm: RNG algorithm identifier. version: Key-layout version, bumped if the ``(seed, ray_id, bounce, event_slot)`` mixing scheme in :mod:`optiland.nonsequential.rng` ever changes incompatibly. """ algorithm: str = "pcg32" version: int = 1
[docs] @dataclass(frozen=True) class SamplingPolicy: """Rare-path sampling policy. Every default reproduces the engine's pre-PR11 forward behaviour exactly: ``reflect_prob="fresnel"`` is the unconditional Fresnel-probability branch that always existed, and ``split_depth=0`` means "never split," which was the only mode that existed before PR11. Set on a scene via ``NSQScene.sampling_policy``. Attributes: reflect_prob: Importance-sampling probability for the reflect branch (see :func:`optiland.nonsequential.sampling.resolve_reflect_prob`). ``"fresnel"`` uses the Fresnel reflectance itself (today's behaviour); ``"auto"`` clamps it into ``[0.25, 0.75]``; an explicit float fixes the probability. Works on both backends and under autograd -- the branch decision is always drawn from a detached probability with a compensating attached weight, so only the *variance* changes, never the expectation. split_depth: NumPy forward engine only; bounded bounce-splitting depth (see :mod:`optiland.nonsequential.backends.array_backend`). ``0`` = never split (the only mode the Torch backend supports -- it forces ``split_depth=0`` and warns if the scene sets a nonzero value, since fixed tensor shapes are required for the autograd graph). split_budget: Cap on live rays during splitting, as a multiple of ``batch_size``. Unused while ``split_depth=0``. Rays spawned beyond the cap are Russian-rouletted, not dropped. rr_start_flux: Russian-roulette threshold, as a fraction of per-ray initial flux (see :func:`optiland.nonsequential.sampling.russian_roulette`). Replaces the old biased hard kill below ``min_flux`` on both backends. """ reflect_prob: float | Literal["fresnel", "auto"] = "fresnel" split_depth: int = 0 split_budget: float = 4.0 rr_start_flux: float = 1e-3
[docs] @dataclass(frozen=True) class SceneIR: """The complete, backend-portable scene description. Attributes: primitives: All surfaces (from every compound component's flat ``.surfaces`` list), in ``scene.surfaces`` order. volumes: Always ``()`` (see :class:`VolumeIR`). media: Every distinct medium referenced by a primitive or emitter, deduplicated by catalog name (or the single shared vacuum entry). emitters: All sources, in ``scene.sources`` order. sensors: All detectors, in ``scene.detectors`` order. rng: RNG algorithm contract (see :class:`RngContract`). sampling: Rare-path sampling policy (see :class:`SamplingPolicy`). """ primitives: tuple[PrimitiveIR, ...] volumes: tuple[VolumeIR, ...] media: tuple[MediumIR, ...] emitters: tuple[EmitterIR, ...] sensors: tuple[SensorIR, ...] rng: RngContract = field(default_factory=RngContract) sampling: SamplingPolicy = field(default_factory=SamplingPolicy)
# --------------------------------------------------------------------------- # JSON round-trip (translatability checklist rule 5) # --------------------------------------------------------------------------- def _jsonable(value: Any) -> Any: """Recursively convert IR field values to plain JSON-safe Python. Detaches ``torch.Tensor`` and ``numpy.ndarray`` values to nested lists (matching the detach convention already used by :mod:`optiland.nonsequential.serialization`), and dataclasses to plain dicts tagged with their class name for :func:`_from_jsonable` to reverse. Args: value: Any IR field value. Returns: A value built only from ``dict``, ``list``, ``str``, ``float``, ``int``, ``bool``, and ``None``. """ if value is None or isinstance(value, str | bool | int | float): return value if is_torch_tensor(value): return value.detach().cpu().tolist() if isinstance(value, np.ndarray): return value.tolist() if isinstance(value, dict): return {str(k): _jsonable(v) for k, v in value.items()} if isinstance(value, list | tuple): return [_jsonable(v) for v in value] if is_dataclass(value): return { "__ir_type__": type(value).__name__, "fields": { f.name: _jsonable(getattr(value, f.name)) for f in fields(value) }, } raise TypeError( f"Cannot serialize {value!r} of type {type(value).__name__} to JSON: " "SceneIR values must be dataclasses, dicts, lists/tuples, arrays, or " "plain scalars (translatability checklist rule 2)." ) _IR_TYPES: dict[str, type] = { "SceneIR": SceneIR, "PrimitiveIR": PrimitiveIR, "VolumeIR": VolumeIR, "EmitterIR": EmitterIR, "SensorIR": SensorIR, "MediumIR": MediumIR, "BsdfIR": BsdfIR, "RngContract": RngContract, "SamplingPolicy": SamplingPolicy, } # Fields whose JSON-list value must be restored as a numpy array rather than # left as a plain Python list (everything else round-trips as list/dict/ # scalar unchanged). _ARRAY_FIELDS = {"to_world"} def _from_jsonable(value: Any) -> Any: """Inverse of :func:`_jsonable`. Args: value: A value previously produced by :func:`_jsonable`. Returns: The reconstructed dataclass / dict / list / scalar tree. """ if isinstance(value, dict) and "__ir_type__" in value: cls = _IR_TYPES[value["__ir_type__"]] kwargs = {} for name, raw in value["fields"].items(): restored = _from_jsonable(raw) if name in _ARRAY_FIELDS and isinstance(restored, list): restored = np.array(restored, dtype=np.float64) elif isinstance(restored, list) and name in ( "primitives", "volumes", "media", "emitters", "sensors", "boundary_primitive_ids", ): restored = tuple(restored) kwargs[name] = restored return cls(**kwargs) if isinstance(value, dict): return {k: _from_jsonable(v) for k, v in value.items()} if isinstance(value, list): return [_from_jsonable(v) for v in value] return value
[docs] def scene_ir_to_dict(scene_ir: SceneIR) -> dict: """Serialize a :class:`SceneIR` to a JSON-safe dict, losslessly. Args: scene_ir: The IR to serialize. Returns: A dict built only from JSON-safe primitives, restorable via :func:`scene_ir_from_dict`. """ return _jsonable(scene_ir)
[docs] def scene_ir_from_dict(d: dict) -> SceneIR: """Reconstruct a :class:`SceneIR` from a dict produced by :func:`scene_ir_to_dict`. Args: d: Dict previously produced by :func:`scene_ir_to_dict`. Returns: The reconstructed :class:`SceneIR`. """ return _from_jsonable(d)