Nonsequential Ray Tracing API#

Public API reference for the non-sequential (NSQ) engine. For the conceptual overview and tutorials see the NSQ gallery; for the capability envelope and known limitations see NSQ Limitations & Roadmap; for the architecture and differentiability contract see the developer guide.

Note

Pre-release. NSQ has never shipped in a tagged Optiland release, so the API may still change without a deprecation cycle. Scenes are built almost entirely through the *Config dataclasses — the Configuration reference below gives a parameter table for each.

Scene & tracer#

class NSQScene[source]#

Bases: object

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.

Variables:
  • component_registry – Registry of named compound components.

  • source_registry – Registry of named sources.

  • detector_registry – Registry of named detectors.

add_component(name: str, component: BaseComponent) None[source]#

Add a raw BaseComponent (advanced use).

Parameters:
  • name – Unique name for this component in the registry.

  • component – Pre-built BaseComponent to register.

add_detector(name: str, cs: CoordinateSystem, config) None[source]#

Add a detector to the scene.

Parameters:
  • name – Unique string name for the detector.

  • cs – Coordinate system for the detector.

  • config – Detector config dataclass (IrradianceDetectorConfig, SpectralDetectorConfig, FarFieldDetectorConfig, or RayDatabaseConfig).

add_doublet(name: str, cs: CoordinateSystem, config: DoubletConfig) None[source]#

Add a cemented achromatic doublet to the scene.

Parameters:
  • name – Unique name for the doublet in the registry.

  • cs – Front-vertex coordinate system.

  • config – DoubletConfig describing the doublet geometry.

add_lens(name: str, cs: CoordinateSystem, config: LensConfig) None[source]#

Add a single refractive lens to the scene.

Parameters:
  • name – Unique name for the lens in the registry.

  • cs – Front-vertex coordinate system.

  • config – LensConfig describing the lens geometry.

add_mirror(name: str, cs: CoordinateSystem, config: MirrorConfig) None[source]#

Add a reflective mirror to the scene.

Parameters:
  • name – Unique name for the mirror in the registry.

  • cs – Surface coordinate system.

  • config – MirrorConfig describing the mirror geometry.

add_source(name: str, cs: CoordinateSystem, config) None[source]#

Add a source to the scene.

Parameters:
  • name – Unique string name for the source.

  • cs – Coordinate system for the source.

  • config – Source config dataclass (PointSourceConfig, CollimatedSourceConfig, or ExtendedSourceConfig).

property component_names: list[str]#

Names of the registered components, in registration order.

property detector_names: list[str]#

Names of the registered detectors, in registration order.

These are the keys of SimulationResult.detectors.

property detectors#

Ordered list of all registered detectors.

classmethod from_json(path: str | os.PathLike) NSQScene[source]#

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.

Parameters:

path – Path to a JSON file previously written by to_json().

Returns:

Reconstructed 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")
remove_component(name: str) None[source]#

Remove a compound component by name.

Parameters:

name – Registry name of the component to remove.

remove_detector(name: str) None[source]#

Remove a detector by name.

Parameters:

name – Registry name of the detector to remove.

remove_source(name: str) None[source]#

Remove a source by name.

Parameters:

name – Registry name of the source to remove.

property source_names: list[str]#

Names of the registered sources, in registration order.

property sources#

Ordered list of all registered sources.

property surfaces: list[BaseComponent]#

Flat list of all component sub-surfaces in registration order.

to_json(path: str | os.PathLike) None[source]#

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.

Parameters:

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")
trace(num_rays: int, max_depth: int = 16, min_flux_fraction: float = 1e-06, batch_size: int = 16384, seed: int | None = None, backend: TracerBackend | None = None, record_paths: bool | int = False) SimulationResult[source]#

Run the Monte Carlo simulation and return results.

Parameters:
  • 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_pathsFalse (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.

validate() None[source]#

Validate the scene for common configuration errors.

Raises:

ValueError – If no sources or no detectors are registered.

view(result: SimulationResult | None = None, **kwargs) None[source]#

Render a 2D (matplotlib) cross-section of the scene.

Parameters:
  • result – Optional SimulationResult with ray paths to overlay.

  • **kwargs – Forwarded to NSQViewer2D.view().

view3d(result: SimulationResult | None = None, **kwargs) None[source]#

Render a 3D (VTK) scene visualization.

Parameters:
  • result – Optional SimulationResult with ray paths to overlay.

  • **kwargs – Forwarded to NSQViewer3D.view().

class NSQTracer(scene: NSQScene, backend: TracerBackend | None = None)[source]#

Bases: object

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)
Variables:
  • scene – The NSQScene to simulate.

  • backend – TracerBackend instance (defaults to NumpyBackend or TorchBackend based on the active optiland.backend).

trace(num_rays: int, max_depth: int = 16, min_flux_fraction: float = 1e-06, batch_size: int = 16384, seed: int | None = None, backend: TracerBackend | None = None, record_paths: bool | int = False) SimulationResult[source]#

Run the simulation and return results.

Parameters:
  • 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_pathsFalse 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 optiland.nonsequential.path_recording.

Returns:

SimulationResult.

class SimulationResult(detectors: dict[str, object] = <factory>, 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: ~optiland.nonsequential.diagnostics.Diagnostics = <factory>)[source]#

Bases: object

Top-level result returned by NSQTracer.trace() / NSQScene.trace().

Variables:
  • detectors (dict[str, object]) – Per-detector result objects, keyed by detector name.

  • num_rays_total (int) – Total number of rays launched.

  • num_rays_absorbed (int) – Rays terminated by absorbing components.

  • num_rays_escaped (int) – Rays that left the scene with no hit.

  • num_rays_flux_killed (int) – Rays killed for falling below flux threshold.

  • num_rays_depth_killed (int) – Rays killed for exceeding max_depth.

  • total_flux_in (float) – Total flux launched by all sources [W].

  • total_flux_detected (float) – Total flux recorded on all detectors [W].

  • total_flux_absorbed (float) – Flux absorbed by AbsorbingComponents [W].

  • total_flux_bulk_absorbed (float) – 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 (float) – Flux carried by escaped rays [W].

  • total_flux_lost (float) – Flux lost to flux/depth kill [W].

  • flux_conservation_error (float) – |flux_in - detected - absorbed - bulk_absorbed - escaped - lost| / flux_in.

  • trace_time_sec (float) – Wall-clock time for the trace [s].

  • ray_paths (dict | None) – Optional per-ray event log dict ({"events": structured_array}), populated when record_paths is truthy – see optiland.nonsequential.path_recording.

  • diagnostics (optiland.nonsequential.diagnostics.Diagnostics) – Self-diagnosing summary of this trace – depth truncation, roulette loss, unreached geometry, per -detector sampling quality, and a threshold-based warning list. See report() and optiland.nonsequential.diagnostics.

detectors: dict[str, object]#
diagnostics: Diagnostics#
flux_conservation_error: float = 0.0#
num_rays_absorbed: int = 0#
num_rays_depth_killed: int = 0#
num_rays_escaped: int = 0#
num_rays_flux_killed: int = 0#
num_rays_total: int = 0#
ray_paths: dict | None = None#
report() str[source]#

Full human-readable diagnostic report for this trace.

Returns:

A multi-line string – see Diagnostics.report().

total_flux_absorbed: float = 0.0#
total_flux_bulk_absorbed: float = 0.0#
total_flux_detected: float = 0.0#
total_flux_escaped: float = 0.0#
total_flux_in: float = 0.0#
total_flux_lost: float = 0.0#
trace_time_sec: float = 0.0#

Sources#

Sources subpackage for Non-Sequential Raytracing.

class BaseNSQSource(cs: CoordinateSystem, spectrum: Spectrum, total_flux: float = 1.0)[source]#

Bases: ABC

Abstract base class for non-sequential ray sources.

Variables:
  • cs – Coordinate system defining source position/orientation.

  • spectrum – Wavelength distribution for Monte Carlo sampling.

  • total_flux – Source total flux [W] (or photons/sec).

abstractmethod generate(ray_id: np.ndarray, rng: NSQRng) NSQRayBundle[source]#

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.

Parameters:
  • 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).

class CollimatedSource(cs: CoordinateSystem, spectrum: Spectrum, total_flux: float = 1.0, aperture_radius: float = 5.0, profile: Literal['tophat', 'gaussian'] = 'tophat', gaussian_sigma: float | None = None, medium=None)[source]#

Bases: BaseNSQSource

Parallel collimated beam with circular aperture.

All rays propagate along the local +z axis (after coordinate transformation to global frame). Intensity profile is either top-hat (uniform) or truncated Gaussian.

Variables:
  • cs – Coordinate system (local z = beam propagation axis).

  • spectrum – Wavelength distribution.

  • total_flux – Total beam flux [W].

  • aperture_radius – Beam aperture radius [mm].

  • profile – Intensity profile (‘tophat’ or ‘gaussian’).

  • gaussian_sigma – Gaussian sigma [mm] (used when profile=’gaussian’).

  • medium – Medium the source is embedded in.

generate(ray_id: np.ndarray, rng: NSQRng) NSQRayBundle[source]#

Generate collimated rays in global coordinates.

Positions are sampled within the circular aperture. All directions are along the local +z axis.

Parameters:
  • ray_id – Unique identifiers for the rays to generate, shape (N,).

  • rng – Keyed PCG32 RNG.

Returns:

NSQRayBundle with all rays alive and parallel directions.

class CollimatedSourceConfig(spectrum: Spectrum, total_flux: float = 1.0, total_flux_lumens: float | None = None, aperture_radius: float = 1.0, profile: str = 'tophat', gaussian_sigma: float | None = None, medium: NSQMaterial | None = None)[source]#

Bases: object

Configuration for a CollimatedSource.

Variables:
  • spectrum (Spectrum) – Wavelength distribution.

  • total_flux (float) – Total emitted flux [W]. Ignored (with a warning) when total_flux_lumens is also set.

  • total_flux_lumens (float | None) – Total emitted flux [lm], converted to watts via spectrum – see PointSourceConfig.total_flux_lumens.

  • aperture_radius (float) – Beam semi-diameter [mm].

  • profile (str) – Spatial profile – 'tophat' or 'gaussian'.

  • gaussian_sigma (float | None) – Gaussian sigma [mm]. Defaults to aperture_radius / 2.

  • medium (NSQMaterial | None) – Medium the source is embedded in (default: vacuum).

aperture_radius: float = 1.0#
gaussian_sigma: float | None = None#
medium: NSQMaterial | None = None#
profile: str = 'tophat'#
spectrum: Spectrum#
total_flux: float = 1.0#
total_flux_lumens: float | None = None#
class ExtendedSource(cs: CoordinateSystem, spectrum: Spectrum, total_flux: float = 1.0, width: float = 1.0, height: float = 1.0, aperture_radius: float | None = None, half_angle_deg: float = 90.0, medium=None)[source]#

Bases: BaseNSQSource

Uniform area emitter on a rectangular or circular surface.

The source surface lies in the local x-y plane (z=0). Emission direction is either Lambertian (cosine-weighted hemisphere) or confined to a cone around the local +z axis.

Variables:
  • cs – Coordinate system (local z = emission axis).

  • spectrum – Wavelength distribution.

  • total_flux – Total emitted flux [W].

  • width – Source width [mm] (used for rectangular aperture).

  • height – Source height [mm] (used for rectangular aperture).

  • aperture_radius – Circular aperture radius [mm]. If set, overrides width/height for a circular source.

  • half_angle_deg – Half-angle of emission cone [deg]. 90 = Lambertian hemisphere.

  • medium – Medium the source is embedded in.

generate(ray_id: np.ndarray, rng: NSQRng) NSQRayBundle[source]#

Generate rays from the extended source in global coordinates.

Parameters:
  • ray_id – Unique identifiers for the rays to generate, shape (N,).

  • rng – Keyed PCG32 RNG.

Returns:

NSQRayBundle with all rays alive.

class ExtendedSourceConfig(spectrum: Spectrum, total_flux: float = 1.0, total_flux_lumens: float | None = None, width: float = 1.0, height: float = 1.0, aperture_radius: float | None = None, half_angle_deg: float = 90.0, medium: NSQMaterial | None = None)[source]#

Bases: object

Configuration for an ExtendedSource.

Variables:
  • spectrum (Spectrum) – Wavelength distribution.

  • total_flux (float) – Total emitted flux [W]. Ignored (with a warning) when total_flux_lumens is also set.

  • total_flux_lumens (float | None) – Total emitted flux [lm], converted to watts via spectrum – see PointSourceConfig.total_flux_lumens.

  • width (float) – Source width [mm].

  • height (float) – Source height [mm].

  • aperture_radius (float | None) – Circular aperture radius [mm]. If set, overrides width/height for a circular source.

  • half_angle_deg (float) – Half-angle of the emission cone [deg]. Below 90 the rays are distributed uniformly within the cone; at 90 or above they are cosine-weighted over the full hemisphere (Lambertian), and values above 90 behave the same as 90.

  • medium (NSQMaterial | None) – Medium the source is embedded in (default: vacuum).

aperture_radius: float | None = None#
half_angle_deg: float = 90.0#
height: float = 1.0#
medium: NSQMaterial | None = None#
spectrum: Spectrum#
total_flux: float = 1.0#
total_flux_lumens: float | None = None#
width: float = 1.0#
class PointSource(cs: CoordinateSystem, spectrum: Spectrum, total_flux: float = 1.0, half_angle_deg: float = 90.0, medium=None)[source]#

Bases: BaseNSQSource

Point source emitting rays from a single position.

Emission can be isotropic (full sphere) or confined to a cone around the local +z axis.

Variables:
  • cs – Coordinate system (origin = source position).

  • spectrum – Wavelength distribution.

  • total_flux – Total emitted flux [W].

  • half_angle_deg – Half-angle of the emission cone [deg]. 180 = isotropic.

  • medium – Medium the source is embedded in.

generate(ray_id: np.ndarray, rng: NSQRng) NSQRayBundle[source]#

Generate rays from a point source in global coordinates.

Directions are sampled uniformly within the emission cone using spherical coordinates. Wavelengths are Monte Carlo sampled from the spectrum.

Parameters:
  • ray_id – Unique identifiers for the rays to generate, shape (N,).

  • rng – Keyed PCG32 RNG.

Returns:

NSQRayBundle with all rays alive.

class PointSourceConfig(spectrum: Spectrum, total_flux: float = 1.0, total_flux_lumens: float | None = None, half_angle_deg: float = 90.0, medium: NSQMaterial | None = None)[source]#

Bases: object

Configuration for a PointSource.

Variables:
  • spectrum (Spectrum) – Wavelength distribution.

  • total_flux (float) – Total emitted flux [W]. Ignored (with a warning) when total_flux_lumens is also set.

  • total_flux_lumens (float | None) – Total emitted flux [lm], converted to watts using spectrum via optiland.nonsequential.units.lumens_to_watts(). Takes precedence over total_flux when set. Raises if spectrum has negligible overlap with the visible band.

  • half_angle_deg (float) – Half-angle of the emission cone [deg]. 90 = hemisphere, 180 = full sphere (isotropic).

  • medium (NSQMaterial | None) – Medium the source is embedded in (default: vacuum).

half_angle_deg: float = 90.0#
medium: NSQMaterial | None = None#
spectrum: Spectrum#
total_flux: float = 1.0#
total_flux_lumens: float | None = None#
class SourceRegistry[source]#

Bases: object

Named registry of NSQ sources.

Variables:

_registry – Ordered dict mapping name -> BaseNSQSource.

add(name: str, source: BaseNSQSource) None[source]#

Add a source.

Parameters:
  • name – Unique identifier.

  • source – Source to register.

Raises:

KeyError – If a source with name already exists.

get(name: str) BaseNSQSource[source]#

Retrieve a source by name.

Parameters:

name – Name of the source.

Returns:

The registered source.

Raises:

KeyError – If no source with name exists.

remove(name: str) None[source]#

Remove a source by name.

Parameters:

name – Name of the source to remove.

Raises:

KeyError – If no source with name exists.

property sources: list[BaseNSQSource]#

Ordered list of all registered sources.

class Spectrum(wavelengths: numpy.ndarray, weights: numpy.ndarray)[source]#

Bases: object

Wavelength distribution for Monte Carlo sampling.

Variables:
  • wavelengths (numpy.ndarray) – Wavelength values [µm].

  • weights (numpy.ndarray) – Relative spectral power weights (unnormalized).

classmethod monochromatic(wavelength: float) Spectrum[source]#

Create a monochromatic spectrum at a single wavelength.

Parameters:

wavelength – Wavelength [µm].

Returns:

A Spectrum with a single wavelength.

sample(ray_id: np.ndarray, bounce: np.ndarray, rng: NSQRng) np.ndarray[source]#

Sample one wavelength per ray from the spectrum.

Uses inverse-CDF (quantile) sampling.

Parameters:
  • 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,).

wavelengths: numpy.ndarray#
weights: numpy.ndarray#

Components#

NSQ components subpackage.

class AbsorbingComponent(cs: CoordinateSystem, geometry: ComponentGeometry, material_front: NSQMaterial = NSQMaterial(optiland_material=None, bsdf=None), name: str = '')[source]#

Bases: BaseComponent

Absorbing surface that terminates all rays on contact.

Use for light traps, aperture stops, and baffles.

Variables:
  • cs – Coordinate system.

  • geometry – Surface geometry.

  • name – Optional label.

property bounding_box: AABB#

Axis-aligned bounding box in global coordinates.

Returns:

AABB for this component.

interact(rays: NSQRayBundle, t: np.ndarray, normals: np.ndarray, hit_mask: np.ndarray, rng: NSQRng, bsdf_ir: BsdfIR, n_geom: np.ndarray, sampling: SamplingPolicy | None = None, forced_branch: str | None = None) None[source]#

Kill all rays that hit this component (in-place).

Parameters:
  • rays – Ray bundle updated in-place.

  • t – Hit distances [mm], shape (N,).

  • normals – Surface normals in global frame, shape (N, 3).

  • hit_mask – True for rays hitting this component, shape (N,).

  • rng – Keyed PCG32 RNG (unused).

  • bsdf_ir – Unused – absorbing surfaces never scatter.

  • n_geom – Unused – absorbing surfaces never determine sidedness.

  • sampling – Unused – an absorber has no stochastic branch (D2, PR11).

  • forced_branch – Unused – bounded splitting only applies to RefractiveComponent.

intersect(rays: NSQRayBundle) tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]#

Find the nearest intersection of alive rays with this component.

Transforms rays to local frame, delegates to geometry, then transforms normals back to global frame.

Parameters:

rays – The ray bundle in global coordinates.

Returns:

  • t: Per-ray distances [mm], shape (N,). inf if no hit.

  • normals: Surface normals in global frame, shape (N, 3).

  • hit_mask: Boolean hit mask, shape (N,).

  • n_geom: Geometric (unflipped, direction-independent)

    surface normal in global frame, shape (N, 3). See ComponentGeometry.ray_intersect().

Return type:

Tuple (t, normals, hit_mask, n_geom) in global frame

reset_stats() None[source]#

Reset per-simulation absorbed ray and flux counters.

class BaseComponent(cs: CoordinateSystem, geometry: ComponentGeometry, material_front: NSQMaterial, material_back: NSQMaterial, bsdf: BaseBSDF | None = None, name: str = '', scatter_fraction: float = 1.0)[source]#

Bases: ABC

Abstract base class for all non-sequential optical components.

Components define the geometry and optical interaction (reflection, refraction, absorption) for a surface in the NSQ scene.

Variables:
  • cs – Coordinate system defining position and orientation in global frame.

  • geometry – Shape of the component surface.

  • material_front – Medium on the front side (normal-facing side).

  • material_back – Medium on the back side.

  • bsdf – Optional scatter model. None means specular-only.

  • name – Optional human-readable label.

property bounding_box: AABB#

Axis-aligned bounding box in global coordinates.

Returns:

AABB for this component.

abstractmethod interact(rays: NSQRayBundle, t: np.ndarray, normals: np.ndarray, hit_mask: np.ndarray, rng: NSQRng, bsdf_ir: BsdfIR, n_geom: np.ndarray, sampling: SamplingPolicy | None = None, forced_branch: str | None = None) None[source]#

Apply optical interaction at hit points (in-place).

Updates ray positions, directions, flux, n_current, bounce, and alive status for rays that hit this component.

This is a private implementation detail of the reference NumPy/Torch interpreters (optiland.nonsequential.ir.interpreter .apply_primitive_interactions), not the engine’s public dispatch contract – a non-Python backend never calls it. bsdf_ir is what makes the dispatch IR-driven: whether to route a hit ray through self.bsdf is decided from bsdf_ir.kind (verified to match self.bsdf’s actual type by the caller), not from a bare self.bsdf is not None check.

Parameters:
  • rays – Ray bundle to update in-place.

  • t – Hit distances [mm], shape (N,).

  • normals – Surface normals in global frame, shape (N, 3).

  • hit_mask – True for rays that hit this component, shape (N,).

  • rng – Keyed PCG32 RNG for stochastic interactions.

  • bsdf_ir – This surface’s lowered BSDF descriptor (BsdfIR(kind= "none") when no scatter model is attached), matching self.bsdf.

  • n_geom – Geometric (unflipped) surface normal in global frame, shape (N, 3): points from material_front toward material_back. RefractiveComponent uses this, not index proximity, to determine which material a ray is entering.

  • sampling – The scene’s rare-path sampling policy. Only RefractiveComponent consults it, to resolve the Fresnel reflect/transmit branch probability; None is treated as the default (unbiased, reflect_prob="fresnel") policy.

  • forced_branch"reflect" or "transmit" to deterministically force the branch instead of drawing it, or None for the normal stochastic draw. Used only by the NumPy forward engine’s bounded-splitting orchestration (PR11; optiland.nonsequential.ir.interpreter) to build both children of a split ray; ignored by every component except RefractiveComponent.

intersect(rays: NSQRayBundle) tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray][source]#

Find the nearest intersection of alive rays with this component.

Transforms rays to local frame, delegates to geometry, then transforms normals back to global frame.

Parameters:

rays – The ray bundle in global coordinates.

Returns:

  • t: Per-ray distances [mm], shape (N,). inf if no hit.

  • normals: Surface normals in global frame, shape (N, 3).

  • hit_mask: Boolean hit mask, shape (N,).

  • n_geom: Geometric (unflipped, direction-independent)

    surface normal in global frame, shape (N, 3). See ComponentGeometry.ray_intersect().

Return type:

Tuple (t, normals, hit_mask, n_geom) in global frame

class ComponentRegistry[source]#

Bases: object

Named registry of compound components.

Stores CompoundComponent objects keyed by name and provides the tracer with a flat list of BaseComponent surfaces.

Variables:

_registry – Ordered dict mapping name -> CompoundComponent.

add(name: str, component: CompoundComponent) None[source]#

Add a compound component under the given name.

Parameters:
  • name – Unique identifier for the component.

  • component – The compound component to register.

Raises:

KeyError – If a component with name already exists.

property compounds: list[CompoundComponent]#

Ordered list of all registered compound components.

Returns:

List of compound components in registration order.

get(name: str) CompoundComponent[source]#

Retrieve a compound component by name.

Parameters:

name – Name of the component.

Returns:

The registered compound component.

Raises:

KeyError – If no component with name exists.

remove(name: str) None[source]#

Remove a component by name.

Parameters:

name – Name of the component to remove.

Raises:

KeyError – If no component with name exists.

property surfaces: list[BaseComponent]#

Flat list of all sub-surfaces in registration order.

Returns:

Concatenated list of sub-surfaces from all compound components.

class CompoundComponent[source]#

Bases: ABC

Abstract base class for multi-surface optical elements.

A CompoundComponent is a logical grouping of one or more BaseComponent surfaces that collectively form a single optical element. Compound components are stored in the ComponentRegistry; when the tracer needs a flat surface list it calls surfaces on each compound.

Subclasses must implement name, surfaces, and coordinate_system.

abstract property coordinate_system: CoordinateSystem#

Primary coordinate system (front vertex for lenses, surface for mirrors).

abstract property name: str#

Human-readable identifier for this compound component.

abstract property surfaces: list[BaseComponent]#

Ordered flat list of sub-surfaces for intersection testing.

class Doublet(name: str, cs: CoordinateSystem, config: DoubletConfig)[source]#

Bases: CompoundComponent

Cemented achromatic doublet.

Assembles five physical surfaces:

  1. Front face – refractive, conic (crown element).

  2. Cemented interface – refractive, conic (crown->flint).

  3. Back face – refractive, conic (flint element).

  4. Crown edge – cylindrical frustum, absorbing.

  5. Flint edge – cylindrical frustum, absorbing.

Unlike Lens, a doublet is two closed solids (the crown element and the flint element) sharing the cemented interface as a common boundary surface – so the edge is split into a crown segment and a flint segment at the cemented interface’s axial position, and each element is validated as its own Volume.

Variables:
  • _name – Registry name.

  • _cs – Front-vertex coordinate system.

  • _config – DoubletConfig.

  • _surfaces – Built list of sub-surfaces.

  • _volumes – The two validated Volume instances (crown, flint).

property coordinate_system: CoordinateSystem#

Front-vertex coordinate system.

property name: str#

Registry name.

property surfaces: list[BaseComponent]#

Ordered flat list of sub-surfaces.

class DoubletConfig(r1: float, r2: float, r3: float, thickness1: float, thickness2: float, material1: str | NSQMaterial, material2: str | NSQMaterial, aperture_radius: float, conic1: float = 0.0, conic2: float = 0.0, conic3: float = 0.0, front: SurfaceConfig | None = None, cemented: SurfaceConfig | None = None, back: SurfaceConfig | None = None, edge: SurfaceConfig | None = None)[source]#

Bases: object

Configuration for a cemented achromatic doublet.

Surfaces in order (front -> back): front face, cemented interface, back face, edge.

Variables:
  • r1 (float) – Front radius of curvature [mm].

  • r2 (float) – Cemented interface radius of curvature [mm].

  • r3 (float) – Back radius of curvature [mm].

  • thickness1 (float) – Thickness of the crown element [mm].

  • thickness2 (float) – Thickness of the flint element [mm].

  • material1 (str | NSQMaterial) – Crown element glass name or NSQMaterial.

  • material2 (str | NSQMaterial) – Flint element glass name or NSQMaterial.

  • aperture_radius (float) – Common semi-diameter for all surfaces [mm].

  • conic1 (float) – Conic constant of the front face.

  • conic2 (float) – Conic constant of the cemented interface.

  • conic3 (float) – Conic constant of the back face.

  • front (SurfaceConfig | None) – Per-surface overrides for the front face.

  • cemented (SurfaceConfig | None) – Per-surface overrides for the cemented interface.

  • back (SurfaceConfig | None) – Per-surface overrides for the back face.

  • edge (SurfaceConfig | None) – Per-surface overrides for the edge surface.

aperture_radius: float#
back: SurfaceConfig | None = None#
cemented: SurfaceConfig | None = None#
conic1: float = 0.0#
conic2: float = 0.0#
conic3: float = 0.0#
edge: SurfaceConfig | None = None#
front: SurfaceConfig | None = None#
material1: str | NSQMaterial#
material2: str | NSQMaterial#
r1: float#
r2: float#
r3: float#
thickness1: float#
thickness2: float#
class InteractionType(*values)[source]#

Bases: Enum

Optical interaction type for a single surface.

Variables:
  • REFRACTIVE – Surface refracts (and optionally reflects via Fresnel).

  • REFLECTIVE – Surface reflects only; no transmission.

  • ABSORBING – Surface absorbs all incident rays.

ABSORBING = 'absorbing'#
REFLECTIVE = 'reflective'#
REFRACTIVE = 'refractive'#
class Lens(name: str, cs: CoordinateSystem, config: LensConfig)[source]#

Bases: CompoundComponent

Single refractive lens element.

Assembles up to four physical surfaces:

  1. Front face – refractive, conic.

  2. Back face – refractive, conic.

  3. Edge – cylindrical frustum, absorbing by default.

  4. Rim – annular plane, absorbing; only when front_aperture_radius != back_aperture_radius.

The built surfaces are validated as a single closed Volume (watertight, consistently outward-oriented) at construction time – a lens whose faces and edge do not actually close up raises NonWatertightVolumeError immediately rather than producing silently wrong flux accounting later.

Variables:
  • _name – Registry name.

  • _cs – Front-vertex coordinate system.

  • _config – LensConfig describing the lens geometry.

  • _surfaces – Built list of sub-surfaces.

  • _volume – The validated Volume these surfaces form.

property coordinate_system: CoordinateSystem#

Front-vertex coordinate system.

property name: str#

Registry name of this lens.

property surfaces: list[BaseComponent]#

Ordered flat list of sub-surfaces.

class LensConfig(r1: float, r2: float, thickness: float, material: str | NSQMaterial, front_aperture_radius: float, back_aperture_radius: float | None = None, conic1: float = 0.0, conic2: float = 0.0, front: SurfaceConfig | None = None, back: SurfaceConfig | None = None, edge: SurfaceConfig | None = None, rim: SurfaceConfig | None = None)[source]#

Bases: object

Configuration for a single-element refractive lens.

The lens assembles up to four physical surfaces:

  1. Front face – refractive, conic.

  2. Back face – refractive, conic.

  3. Edge – cylindrical frustum, absorbing by default.

  4. Rim – annular plane, absorbing; only created when front_aperture_radius != back_aperture_radius.

Variables:
  • r1 (float) – Front vertex radius of curvature [mm]. Positive = centre of curvature on +z side.

  • r2 (float) – Back vertex radius of curvature [mm].

  • thickness (float) – Centre thickness of the lens [mm].

  • material (str | NSQMaterial) – Glass name (e.g. 'N-BK7') or a ready-made NSQMaterial instance.

  • front_aperture_radius (float) – Semi-diameter of the front face [mm].

  • back_aperture_radius (float | None) – Semi-diameter of the back face [mm]. Defaults to front_aperture_radius when None.

  • conic1 (float) – Conic constant of the front face (0 = sphere).

  • conic2 (float) – Conic constant of the back face (0 = sphere).

  • front (SurfaceConfig | None) – Per-surface overrides for the front face.

  • back (SurfaceConfig | None) – Per-surface overrides for the back face.

  • edge (SurfaceConfig | None) – Per-surface overrides for the edge (barrel) surface.

  • rim (SurfaceConfig | None) – Per-surface overrides for the rim annulus (only used when apertures differ).

back: SurfaceConfig | None = None#
back_aperture_radius: float | None = None#
conic1: float = 0.0#
conic2: float = 0.0#
edge: SurfaceConfig | None = None#
front: SurfaceConfig | None = None#
front_aperture_radius: float#
material: str | NSQMaterial#
r1: float#
r2: float#
rim: SurfaceConfig | None = None#
thickness: float#
class Mirror(name: str, cs: CoordinateSystem, config: MirrorConfig)[source]#

Bases: CompoundComponent

Single reflective mirror surface.

Wraps a ConicGeometry (or any overridden geometry) inside a ReflectiveComponent. The user can attach a custom BSDF via MirrorConfig.surface.bsdf.

Unlike Lens/Doublet, this is not wrapped in a Volume: a mirror is a single open reflective surface with vacuum on both sides, not a closed solid with an interior medium, so watertightness has nothing to check.

Variables:
  • _name – Registry name.

  • _cs – Surface coordinate system.

  • _config – MirrorConfig.

  • _surfaces – Single-element list containing the reflective surface.

property coordinate_system: CoordinateSystem#

Mirror surface coordinate system.

property name: str#

Registry name of this mirror.

property surfaces: list[BaseComponent]#

Single-element list containing the reflective surface.

class MirrorConfig(radius: float, reflectance: object, conic: float = 0.0, aperture_radius: float = 25.0, surface: SurfaceConfig | None = None)[source]#

Bases: object

Configuration for a single reflective mirror surface.

Variables:
  • radius (float) – Vertex radius of curvature [mm]. Negative = concave when oriented with the normal pointing toward +z.

  • reflectance (object) – Mirror reflectance: a constant in [0, 1], a wavelength-dependent callable(wavelength_um) -> reflectance, or an unpolarized optiland.coatings.BaseCoating (e.g. SimpleCoating). Required – there is no implicit perfect-mirror default: a mirror built without specifying how much light it reflects is a modelling bug, not a 100% reflector. Overridden per-surface by surface.reflectance.

  • conic (float) – Conic constant (0 = sphere, -1 = paraboloid, etc.).

  • aperture_radius (float) – Semi-diameter [mm].

  • surface (optiland.nonsequential.components.configs.SurfaceConfig | None) – Per-surface overrides (e.g. to attach a scatter BSDF).

aperture_radius: float = 25.0#
conic: float = 0.0#
radius: float#
reflectance: object#
surface: SurfaceConfig | None = None#
exception NonWatertightVolumeError[source]#

Bases: Exception

A Volume’s boundary surfaces do not form a closed, consistently outward-oriented solid.

Raised at Volume construction, never as a warning: a leak in the boundary lets rays enter or exit a solid without the medium stack (or, in this revamp, the per-surface geometric sidedness check) noticing, which is exactly the class of silent-wrong-answer failure this validation exists to prevent.

class ReflectiveComponent(cs: CoordinateSystem, geometry: ComponentGeometry, reflectance: float | Callable[[be.ndarray], be.ndarray] | BaseCoating, bsdf: BaseBSDF | None = None, material_front: NSQMaterial = NSQMaterial(optiland_material=None, bsdf=None), name: str = '', scatter_fraction: float = 1.0)[source]#

Bases: BaseComponent

Purely reflective optical element (mirror, baffle).

Reflects rays specularly (or via BSDF). Does not transmit.

Variables:
  • cs – Coordinate system.

  • geometry – Surface geometry.

  • reflectance – Constant, callable(wavelength_um), or unpolarized BaseCoating giving the fraction of flux reflected.

  • bsdf – Optional BSDF for scatter. None = specular mirror.

  • name – Optional label.

property bounding_box: AABB#

Axis-aligned bounding box in global coordinates.

Returns:

AABB for this component.

interact(rays: NSQRayBundle, t: np.ndarray, normals: np.ndarray, hit_mask: np.ndarray, rng: NSQRng, bsdf_ir: BsdfIR, n_geom: np.ndarray, sampling: SamplingPolicy | None = None, forced_branch: str | None = None) None[source]#

Apply specular (or BSDF) reflection at hit points (in-place).

Parameters:
  • rays – Ray bundle updated in-place.

  • t – Hit distances [mm], shape (N,).

  • normals – Surface normals in global frame, shape (N, 3).

  • hit_mask – True for rays hitting this component, shape (N,).

  • rng – Keyed PCG32 RNG. Draws are keyed by this ray’s own id and its bounce count as of this interaction.

  • bsdf_ir – This surface’s lowered BSDF descriptor. Whether the scatter branch below runs at all is decided from bsdf_ir.kind != "none" (verified by the caller to match self.bsdf), not from self.bsdf is not None.

  • n_geom – Unused – a mirror never transmits, so it never needs to determine which medium a ray is entering.

  • sampling – Unused – a mirror has no Fresnel reflect/transmit branch to importance-bias; its reflectance is applied as a deterministic flux weight, not a stochastic draw.

  • forced_branch – Unused – bounded splitting only applies to RefractiveComponent’s Fresnel branch.

intersect(rays: NSQRayBundle) tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]#

Find the nearest intersection of alive rays with this component.

Transforms rays to local frame, delegates to geometry, then transforms normals back to global frame.

Parameters:

rays – The ray bundle in global coordinates.

Returns:

  • t: Per-ray distances [mm], shape (N,). inf if no hit.

  • normals: Surface normals in global frame, shape (N, 3).

  • hit_mask: Boolean hit mask, shape (N,).

  • n_geom: Geometric (unflipped, direction-independent)

    surface normal in global frame, shape (N, 3). See ComponentGeometry.ray_intersect().

Return type:

Tuple (t, normals, hit_mask, n_geom) in global frame

class RefractiveComponent(cs: CoordinateSystem, geometry: ComponentGeometry, material_front: NSQMaterial, material_back: NSQMaterial, bsdf: BaseBSDF | None = None, name: str = '', scatter_fraction: float = 1.0, coating: BaseCoating | None = None)[source]#

Bases: BaseComponent

Refractive optical element (lens, prism, window).

At each interface, Fresnel splitting uses the detached-sample / attached-weight scheme: the branch decision (reflect vs transmit) is drawn from a detached probability, while the throughput weight carries the attached reflectance so gradients flow through material parameters.

The two materials name the media on either side of the surface, and the component works out which one a ray is leaving by comparing the ray direction against the surface’s geometric normal (n_geom, fixed per surface point, pointing from material_front toward material_back) – never by comparing refractive index values. Crossing direction therefore does not matter: the same surface refracts correctly for a ray on its way in, for a ghost or retro-reflection coming back through, and for the far side of a closed solid modelled as a single geometry, even when the two adjacent media have nearly identical indices (a cemented doublet, oil immersion).

Variables:
  • cs – Coordinate system.

  • geometry – Surface geometry.

  • material_front – Medium on the front (normal-facing) side.

  • material_back – Medium on the back side.

  • bsdf – Optional BSDF for scatter. None = specular.

  • name – Optional label.

property bounding_box: AABB#

Axis-aligned bounding box in global coordinates.

Returns:

AABB for this component.

interact(rays: NSQRayBundle, t: np.ndarray, normals: np.ndarray, hit_mask: np.ndarray, rng: NSQRng, bsdf_ir: BsdfIR, n_geom: np.ndarray, sampling: SamplingPolicy | None = None, forced_branch: str | None = None) None[source]#

Apply Fresnel refraction/reflection at hit points (in-place).

Uses detached-sample / attached-weight Fresnel: the reflect/transmit branch decision is drawn from a detached probability so stochastic choices do not block gradients; the throughput weight multiplier carries the attached reflectance so ∂flux/∂R is non-zero. When self.coating is set, its R/T replace the bare Fresnel values (still forced to R=1/T=0 under TIR, where no coating can restore a transmitted wave).

Parameters:
  • rays – Ray bundle updated in-place.

  • t – Hit distances [mm], shape (N,).

  • normals – Surface normals in global frame, shape (N, 3).

  • hit_mask – True for rays hitting this component, shape (N,).

  • rng – Keyed PCG32 RNG (used for detached sampling only). Draws are keyed by this ray’s own id and its bounce count as of this interaction, so they are independent of batch_size, compaction, and every other ray in the bundle.

  • bsdf_ir – This surface’s lowered BSDF descriptor. Whether the scatter branch below runs at all is decided from bsdf_ir.kind != "none" (verified by the caller to match self.bsdf), not from self.bsdf is not None.

  • n_geom – Geometric surface normal in global frame, shape (N, 3), fixed per surface point and pointing from material_front toward material_back. Used, not rays.n_current, to determine which material a ray is entering.

  • sampling – The scene’s rare-path sampling policy. Resolves the reflect-branch sampling probability – see optiland.nonsequential.sampling.resolve_reflect_prob(). None defaults to reflect_prob="fresnel" (today’s behaviour). Ignored when forced_branch is set.

  • forced_branch"reflect" or "transmit" to deterministically force the branch (weight = R or T exactly, no importance division) instead of drawing it stochastically. Used only by the NumPy forward engine’s bounded-splitting orchestration to build both children of a split ray.

intersect(rays: NSQRayBundle) tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]#

Find the nearest intersection of alive rays with this component.

Transforms rays to local frame, delegates to geometry, then transforms normals back to global frame.

Parameters:

rays – The ray bundle in global coordinates.

Returns:

  • t: Per-ray distances [mm], shape (N,). inf if no hit.

  • normals: Surface normals in global frame, shape (N, 3).

  • hit_mask: Boolean hit mask, shape (N,).

  • n_geom: Geometric (unflipped, direction-independent)

    surface normal in global frame, shape (N, 3). See ComponentGeometry.ray_intersect().

Return type:

Tuple (t, normals, hit_mask, n_geom) in global frame

class SurfaceConfig(bsdf: BaseBSDF | None = None, scatter_fraction: float = 1.0, coating: object | None = None, aperture_radius: float | None = None, interaction: InteractionType | None = None, reflectance: object | None = None)[source]#

Bases: object

Optional per-surface overrides within a compound component.

All fields default to None, meaning “use the compound-level default.” When a field is set, it overrides the compound’s default for that surface.

Variables:
  • bsdf (BaseBSDF | None) – Custom BSDF for this surface. Routes rays through the scatter model instead of the surface’s specular/refractive behaviour.

  • scatter_fraction (float) – Probability in [0, 1] that a ray striking this surface is routed through bsdf rather than following the specular or refractive path. The default of 1.0 sends every ray to the BSDF, turning the surface into a pure diffuser. Set it below 1 to model a partially scattering surface, e.g. 0.1 for a surface that scatters a tenth of the light and transmits the rest. Ignored when bsdf is None.

  • coating (object | None) – An optiland.coatings.BaseCoating for a refractive surface (e.g. an AR coating). When set, its reflectance/ transmittance replace the bare Fresnel calculation, so NSQ and the sequential engine agree on R. Must be an unpolarized coating (SimpleCoating); a BaseCoatingPolarized instance (Jones- matrix based – FresnelCoating, ThinFilmCoating, …) raises NotImplementedError rather than being silently degraded to its scalar average. Ignored on absorbing surfaces.

  • aperture_radius (float | None) – Semi-diameter override [mm]. Overrides the aperture computed from the compound config.

  • interaction (InteractionType | None) – Force a specific interaction type on this surface.

  • reflectance (object | None) – Required when interaction selects InteractionType.REFLECTIVE: a constant in [0, 1], a wavelength-dependent callable(wavelength_um) -> reflectance, or an unpolarized BaseCoating. See ReflectiveComponent – constructing a reflective surface without one raises rather than defaulting to a perfect mirror.

aperture_radius: float | None = None#
bsdf: BaseBSDF | None = None#
coating: object | None = None#
interaction: InteractionType | None = None#
reflectance: object | None = None#
scatter_fraction: float = 1.0#
class Volume(name: str, boundary: list[BaseComponent], interior: NSQMaterial, _skip_validation: bool = False)[source]#

Bases: object

A closed, outward-oriented solid built from boundary surfaces.

Validated at construction: every boundary surface’s rim must be met by a neighbour’s rim (watertightness), and the boundary must enclose its own estimated interior point consistently from every direction (orientation). Both checks raise NonWatertightVolumeError rather than warning – a leaky or misoriented boundary produces silently wrong flux accounting, exactly the failure class this validation exists to catch at construction time instead of at trace time.

Variables:
  • name (str) – Human-readable label.

  • boundary (list[BaseComponent]) – Closed, outward-oriented list of boundary surfaces.

  • interior (NSQMaterial) – The medium inside this volume.

boundary: list[BaseComponent]#
static difference(*parts: object) None[source]#

Not implemented: true CSG difference needs a boolean surface evaluator.

Raises:

NotImplementedError – Always. See intersection().

interior: NSQMaterial#
static intersection(*parts: object) None[source]#

Not implemented: true CSG intersection needs a boolean surface evaluator.

Raises:

NotImplementedError – Always. Construct the intersected geometry directly with analytic primitives, or use union() for concatenating already-disjoint boundary surfaces.

name: str#
static union(*parts: Volume | list[BaseComponent] | BaseComponent) list[BaseComponent][source]#

Concatenate already-disjoint boundary surfaces into one list.

This is the CSG operation this revamp implements: gluing separately constructed, non-overlapping boundary pieces together (the stated use cases – a lens with a flat, a light pipe with a chamfer) are boundary concatenation, not boolean surface evaluation. The result is not itself validated; pass it to Volume to check it.

Parameters:

*parts – Any mix of Volume instances, lists of components, or single components.

Returns:

The concatenated boundary list, in argument order.

Geometry#

Geometry subpackage for NSQ components.

class AABB(min_corner: numpy.ndarray, max_corner: numpy.ndarray)[source]#

Bases: object

Axis-aligned bounding box in global coordinates.

Variables:
  • min_corner (numpy.ndarray) – Minimum (x, y, z) corner [mm], shape (3,).

  • max_corner (numpy.ndarray) – Maximum (x, y, z) corner [mm], shape (3,).

intersects_ray(origins: numpy.ndarray, directions: numpy.ndarray) numpy.ndarray[source]#

Test ray-AABB intersection (slab method).

Parameters:
  • origins – Ray origins, shape (N, 3).

  • directions – Ray directions (unit vectors), shape (N, 3).

Returns:

Boolean mask of rays that intersect the AABB, shape (N,).

max_corner: numpy.ndarray#
min_corner: numpy.ndarray#
static union(boxes: list[AABB]) AABB[source]#

Return the AABB that contains all given boxes.

Parameters:

boxes – List of AABB instances.

Returns:

The enclosing AABB.

property xmax: float#

Maximum x-extent [mm].

property xmin: float#

Minimum x-extent [mm].

property ymax: float#

Maximum y-extent [mm].

property ymin: float#

Minimum y-extent [mm].

property zmax: float#

Maximum z-extent [mm].

property zmin: float#

Minimum z-extent [mm].

class AnalyticGeometry[source]#

Bases: ComponentGeometry, ABC

ABC for analytic geometry primitives.

Analytic geometries implement closed-form intersection formulas, enabling pure-GPU computation with no BVH traversal.

abstractmethod bounding_box(transform: tuple[numpy.ndarray, numpy.ndarray]) AABB#

Return axis-aligned bounding box in global coordinates.

Parameters:

transform – Tuple (translation, rotation_matrix) defining the local-to-global transformation. rotation_matrix is (3, 3), transforming column vectors local->global.

Returns:

AABB in global coordinates.

abstractmethod ray_intersect(origins: numpy.ndarray, directions: numpy.ndarray) tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray]#

Find ray intersections with this geometry in local coordinates.

Parameters:
  • origins – Ray origins in local frame, shape (N, 3) [mm].

  • directions – Ray directions in local frame, shape (N, 3), unit vectors.

Returns:

  • t: Distance to nearest hit, shape (N,). inf if no hit.

  • normals: Hit surface normals in local frame, shape (N, 3).

    Normals point toward the ray origin side (outward) – used for shading/reflection/refraction math.

  • hit_mask: True where ray actually hits, shape (N,) bool.

  • n_geom: The same surface normal before the “flip to face

    the incoming ray” step, shape (N, 3). Fixed per surface point, independent of which side the ray approached from (D-1, D11 4.7): every geometry orients this so it points from the material_front side toward the material_back side – the contract RefractiveComponent relies on to determine which material a ray is entering without comparing refractive index values. Components that never need sidedness (reflective, absorbing) ignore it.

Return type:

A tuple (t, normals, hit_mask, n_geom) where

class ComponentGeometry[source]#

Bases: ABC

Abstract base for component geometry.

Subclasses implement ray_intersect() for their specific shape. Geometry operates in the component’s LOCAL coordinate frame.

abstractmethod bounding_box(transform: tuple[numpy.ndarray, numpy.ndarray]) AABB[source]#

Return axis-aligned bounding box in global coordinates.

Parameters:

transform – Tuple (translation, rotation_matrix) defining the local-to-global transformation. rotation_matrix is (3, 3), transforming column vectors local->global.

Returns:

AABB in global coordinates.

abstractmethod ray_intersect(origins: numpy.ndarray, directions: numpy.ndarray) tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray][source]#

Find ray intersections with this geometry in local coordinates.

Parameters:
  • origins – Ray origins in local frame, shape (N, 3) [mm].

  • directions – Ray directions in local frame, shape (N, 3), unit vectors.

Returns:

  • t: Distance to nearest hit, shape (N,). inf if no hit.

  • normals: Hit surface normals in local frame, shape (N, 3).

    Normals point toward the ray origin side (outward) – used for shading/reflection/refraction math.

  • hit_mask: True where ray actually hits, shape (N,) bool.

  • n_geom: The same surface normal before the “flip to face

    the incoming ray” step, shape (N, 3). Fixed per surface point, independent of which side the ray approached from (D-1, D11 4.7): every geometry orients this so it points from the material_front side toward the material_back side – the contract RefractiveComponent relies on to determine which material a ray is entering without comparing refractive index values. Components that never need sidedness (reflective, absorbing) ignore it.

Return type:

A tuple (t, normals, hit_mask, n_geom) where

class ConicGeometry(radius: float, conic: float, aperture_radius: float)[source]#

Bases: AnalyticGeometry

Conic section surface (z = f(r)) centred on the local z-axis.

The surface vertex is at the local origin. The aperture is circular with radius aperture_radius.

Variables:
  • radius – Radius of curvature at the vertex [mm]. Positive = centre of curvature on +z side.

  • conic – Conic constant K.

  • aperture_radius – Semi-aperture radius [mm].

bounding_box(transform: tuple[numpy.ndarray, numpy.ndarray]) AABB[source]#

Return AABB in global coordinates.

Parameters:

transform – (translation, rotation_matrix).

Returns:

AABB in global frame.

ray_intersect(origins: numpy.ndarray, directions: numpy.ndarray) tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray][source]#

Intersect rays with the conic surface.

Solved in closed form: the sag form in the module docstring is algebraically the quadric

c * (x^2 + y^2) + (1 + K) * c * z^2 - 2 * z = 0,

so substituting p(t) = o + t*d gives a quadratic in t. The same equation covers the flat limit (c = 0), where it becomes linear.

The quadric is the whole conic, including points the sag function does not describe (the far side of an ellipsoid, the second branch of a hyperboloid), which _root_valid() rejects.

Parameters:
  • origins – Ray origins in local frame, shape (N, 3) [mm].

  • directions – Ray directions in local frame, shape (N, 3).

Returns:

(t, normals, hit_mask, n_geom). n_geom points toward local +z (the material_back side by contract; see ComponentGeometry.ray_intersect()).

class FinitePlaneGeometry(width: float = 10.0, height: float = 10.0, aperture_radius: float | None = None)[source]#

Bases: AnalyticGeometry

Finite planar surface at z=0 in local coordinates.

Supports rectangular (width x height) or circular (aperture_radius) apertures. If aperture_radius is set, it takes precedence.

Variables:
  • width – Rectangular half-width [mm] along local x. Used when aperture_radius is None.

  • height – Rectangular half-height [mm] along local y. Used when aperture_radius is None.

  • aperture_radius – Circular aperture radius [mm]. If set, the active region is a disk of this radius.

bounding_box(transform: tuple[numpy.ndarray, numpy.ndarray]) AABB[source]#

Return AABB for this finite plane in global coordinates.

Parameters:

transform – (translation, rotation_matrix).

Returns:

AABB in global frame.

ray_intersect(origins: numpy.ndarray, directions: numpy.ndarray) tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray][source]#

Intersect rays with the finite plane.

Parameters:
  • origins – Ray origins in local frame, shape (N, 3).

  • directions – Ray directions in local frame, shape (N, 3).

Returns:

(t, normals, hit_mask, n_geom). n_geom is the fixed local +z (the material_back side by contract; see ComponentGeometry.ray_intersect()).

class MeshGeometry(mesh: object)[source]#

Bases: AnalyticGeometry

Triangulated surface backed by a trimesh.Trimesh object.

Intersection uses trimesh’s ray caster (pyembree if installed, else the pure-Python fallback). GPU transfer is required on each intersection step for CuPy arrays; analytic geometry is preferred for GPU performance.

Variables:

mesh – The underlying trimesh.Trimesh object.

bounding_box(transform: tuple[numpy.ndarray, numpy.ndarray]) AABB[source]#

Return AABB for the mesh in global coordinates.

Parameters:

transform – (translation, rotation_matrix).

Returns:

AABB in global frame.

ray_intersect(origins: numpy.ndarray, directions: numpy.ndarray) tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray][source]#

Intersect rays with the mesh using trimesh BVH.

Arrays are converted to NumPy for trimesh, then results are converted back to the original array type (CuPy if needed).

Parameters:
  • origins – Ray origins in local frame, shape (N, 3) [mm].

  • directions – Ray directions in local frame, shape (N, 3).

Returns:

(t, normals, hit_mask, n_geom). n_geom is trimesh’s raw face_normals value, i.e. the material_back side by contract (see ComponentGeometry.ray_intersect()) is whichever side the mesh’s face winding points away from – consistently outward for a properly wound (CCW, right-hand rule) closed mesh.

class ParaboloidGeometry(radius: float, aperture_radius: float)[source]#

Bases: ConicGeometry

Convenience subclass for paraboloid (conic constant K = -1).

Variables:
  • radius – Radius of curvature [mm].

  • aperture_radius – Semi-aperture [mm].

bounding_box(transform: tuple[numpy.ndarray, numpy.ndarray]) AABB#

Return AABB in global coordinates.

Parameters:

transform – (translation, rotation_matrix).

Returns:

AABB in global frame.

ray_intersect(origins: numpy.ndarray, directions: numpy.ndarray) tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray]#

Intersect rays with the conic surface.

Solved in closed form: the sag form in the module docstring is algebraically the quadric

c * (x^2 + y^2) + (1 + K) * c * z^2 - 2 * z = 0,

so substituting p(t) = o + t*d gives a quadratic in t. The same equation covers the flat limit (c = 0), where it becomes linear.

The quadric is the whole conic, including points the sag function does not describe (the far side of an ellipsoid, the second branch of a hyperboloid), which _root_valid() rejects.

Parameters:
  • origins – Ray origins in local frame, shape (N, 3) [mm].

  • directions – Ray directions in local frame, shape (N, 3).

Returns:

(t, normals, hit_mask, n_geom). n_geom points toward local +z (the material_back side by contract; see ComponentGeometry.ray_intersect()).

class PlaneGeometry[source]#

Bases: AnalyticGeometry

Infinite flat plane at z=0 in local coordinates, normal along +z.

The plane equation in local frame is: z = 0.

bounding_box(transform: tuple[numpy.ndarray, numpy.ndarray]) AABB[source]#

Return infinite AABB (plane is unbounded).

Parameters:

transform – (translation, rotation_matrix).

Returns:

Infinite AABB.

ray_intersect(origins: numpy.ndarray, directions: numpy.ndarray) tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray][source]#

Intersect rays with the infinite plane z=0.

Parameters:
  • origins – Ray origins in local frame, shape (N, 3).

  • directions – Ray directions in local frame, shape (N, 3).

Returns:

(t, normals, hit_mask, n_geom). n_geom is the fixed local +z (the material_back side by contract; see ComponentGeometry.ray_intersect()).

class SphereGeometry(radius: float, aperture_radius: float | None = None)[source]#

Bases: AnalyticGeometry

Full sphere centred at the local origin.

The sphere equation: x^2 + y^2 + z^2 = radius^2.

Variables:
  • radius – Sphere radius [mm].

  • aperture_radius – Optional aperture limit [mm]. Only the part of the sphere within this transverse radius is considered.

bounding_box(transform: tuple[numpy.ndarray, numpy.ndarray]) AABB[source]#

Return AABB for the sphere in global coordinates.

Parameters:

transform – (translation, rotation_matrix).

Returns:

AABB in global frame.

ray_intersect(origins: numpy.ndarray, directions: numpy.ndarray) tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray][source]#

Intersect rays with the sphere.

Uses the analytic quadratic solution. Returns the nearest positive hit.

Parameters:
  • origins – Ray origins in local frame, shape (N, 3) [mm].

  • directions – Ray directions in local frame, shape (N, 3), unit.

Returns:

(t, normals, hit_mask, n_geom). n_geom points inward, toward the sphere centre – the material_back side by contract (see ComponentGeometry.ray_intersect()): a standalone sphere used as a refractive surface (e.g. a ball lens) should be built with the exterior medium as material_front and the interior as material_back, matching the convention every compound builder (Lens, Doublet) already uses.

class AnnularPlaneGeometry(inner_radius: float, outer_radius: float, z_offset: float = 0.0)[source]#

Bases: AnalyticGeometry

Flat annular ring at z = z_offset in the local frame.

The annulus extends from inner_radius to outer_radius in the radial direction. Rays that hit the z = z_offset plane inside the annular band register a hit; rays outside or on the inner hole do not.

Variables:
  • inner_radius – Inner radius of the annulus [mm].

  • outer_radius – Outer radius of the annulus [mm].

  • z_offset – Axial position of the plane in local frame [mm].

bounding_box(transform: tuple[numpy.ndarray, numpy.ndarray]) AABB[source]#

Return AABB of the annulus in global coordinates.

Parameters:

transform – (translation, rotation_matrix).

Returns:

AABB in global frame.

ray_intersect(origins: numpy.ndarray, directions: numpy.ndarray) tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray][source]#

Intersect rays with the annular plane.

Uses the standard plane intersection: t = (z_offset - oz) / dz, then checks that the hit radius is within [inner_radius, outer_radius].

Parameters:
  • origins – Ray origins in local frame, shape (N, 3) [mm].

  • directions – Ray directions in local frame, shape (N, 3).

Returns:

(t, normals, hit_mask, n_geom) all in local frame. n_geom is the fixed local +z (the material_back side by contract; see ComponentGeometry.ray_intersect()).

class CylindricalFrustumGeometry(r_front: float, r_back: float, z_front: float, z_back: float)[source]#

Bases: AnalyticGeometry

Lateral surface of a truncated cone (frustum) along the local z-axis.

The frustum connects a circle of radius r_front at z = z_front to a circle of radius r_back at z = z_back. Only the lateral (barrel) surface is modelled – the two end-caps are handled by separate geometry objects (e.g. ConicGeometry for lens faces).

Variables:
  • r_front – Radius at the front rim [mm].

  • r_back – Radius at the back rim [mm].

  • z_front – Axial position of the front rim in local frame [mm].

  • z_back – Axial position of the back rim in local frame [mm].

bounding_box(transform: tuple[numpy.ndarray, numpy.ndarray]) AABB[source]#

Return AABB of the frustum in global coordinates.

Parameters:

transform – (translation, rotation_matrix).

Returns:

AABB in global frame.

ray_intersect(origins: numpy.ndarray, directions: numpy.ndarray) tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray][source]#

Intersect rays with the frustum lateral surface.

The frustum surface satisfies:

x^2 + y^2 = r(z)^2

where r(z) = r_front + slope*(z - z_front) and slope = (r_back - r_front)/(z_back - z_front).

Substituting the ray parametric equations yields a quadratic in t. Both roots are tested; the smallest positive root within the axial range [z_front, z_back] is returned.

Parameters:
  • origins – Ray origins in local frame, shape (N, 3) [mm].

  • directions – Ray directions in local frame, shape (N, 3).

Returns:

(t, normals, hit_mask, n_geom) all in local frame. n_geom points radially outward from the frustum axis – this geometry is only ever used for edge/barrel surfaces with the same material on both sides, so it never needs to satisfy the material_back sidedness contract in ComponentGeometry.ray_intersect().

BSDF and scattering#

BSDF subpackage for non-sequential raytracing.

class BaseBSDF[source]#

Bases: ABC

Abstract base class for bidirectional scattering distribution functions.

All BSDF implementations must support vectorized operation over N rays. Array operations must be compatible with both NumPy and CuPy arrays.

Lobes are explicitly REFLECT or TRANSMIT: sample() returns, alongside each scattered direction, whether that particular ray’s draw landed in the reflective hemisphere (same side as the incident ray) or the transmissive one (far side). A surface’s own optical topology decides what that means physically – a mirror has no far side to transmit into, so ReflectiveComponent ignores the flag, while RefractiveComponent uses it to pick which of its two adjacent media (material_front/material_back) a scattered ray is now in: the medium a scattered ray ends up in is decided by its own lobe choice, never by the independent Fresnel branch draw that only applies to unscattered rays.

abstractmethod reflectance(incident_dirs: np.ndarray, normals: np.ndarray, wavelengths: np.ndarray) np.ndarray[source]#

Total hemispherical reflectance for Russian-roulette decisions.

Parameters:
  • incident_dirs – Incident ray directions, shape (N, 3), unit vectors.

  • normals – Surface normals at hit points, shape (N, 3), unit vectors.

  • wavelengths – Per-ray wavelengths [nm], shape (N,).

Returns:

Total reflectance values in [0, 1], shape (N,).

abstractmethod sample(num_rays: int, incident_dirs: np.ndarray, normals: np.ndarray, wavelengths: np.ndarray, rng: NSQRng, ray_id: np.ndarray, bounce: np.ndarray) tuple[np.ndarray, np.ndarray, np.ndarray][source]#

Sample scattered ray directions, flux weights, and lobe side.

Parameters:
  • num_rays – Number of rays to scatter.

  • incident_dirs – Incident ray directions, shape (N, 3), unit vectors.

  • normals – Surface normals at hit points, shape (N, 3), unit vectors pointing toward the incoming ray side.

  • wavelengths – Per-ray wavelengths [nm], shape (N,).

  • rng – Keyed PCG32 RNG.

  • ray_id – Per-ray identifiers, shape (N,), for keying the draw.

  • bounce – Per-ray bounce/step index, shape (N,), for keying the draw.

Returns:

  • scattered_dirs: Scattered unit direction vectors, shape (N, 3).

  • flux_weights: Relative flux weights in [0, 1], shape (N,).

  • transmitted: Boolean mask, shape (N,). True where the returned direction is on the transmissive (far) side of the surface; False for the reflective (incident) side. A purely reflective BSDF (e.g. SpecularBRDF) returns all-False.

Return type:

A tuple (scattered_dirs, flux_weights, transmitted) where

class HarveyShackBSDF(b0: float, l0: float, s: float, transmissive_fraction: float = 0.0)[source]#

Bases: BaseBSDF

Harvey-Shack / ABg scatter model for surface micro-roughness.

The ABg model is a simplified form of the Harvey-Shack theory:

BSDF(beta - beta0) = b0 / (1 + |beta - beta0| / l0)^s

where beta and beta0 are direction cosines of the scattered and specular directions, b0 is the scatter level at beta=beta0, l0 is the break frequency, and s is the roll-off slope.

Variables:
  • b0 – Scatter amplitude at zero angle [sr^-1].

  • l0 – Break-point spatial frequency (dimensionless direction cosine).

  • s – Power-law roll-off slope (positive).

  • transmissive_fraction – Probability in [0, 1] that a given scatter event blurs the undeviated straight-through ray (the transmissive lobe, e.g. a diffuser sheet) instead of the specular reflection. Defaults to 0.0: a purely reflective blur, identical to this class’s behaviour before D-5.

reflectance(incident_dirs: numpy.ndarray, normals: numpy.ndarray, wavelengths: numpy.ndarray) numpy.ndarray[source]#

Return the fraction of incident power redistributed by the lobe.

The sampler conserves energy (rays keep full flux and are only redirected), so this is 1.0. The ABg scatter level itself is total_integrated_scatter, which is what a scatter_fraction should be set to for a physically scaled halo.

Parameters:
  • incident_dirs – Incident directions, shape (N, 3).

  • normals – Surface normals, shape (N, 3).

  • wavelengths – Wavelengths [µm], shape (N,).

Returns:

Approximate reflectance values, shape (N,).

sample(num_rays: int, incident_dirs: np.ndarray, normals: np.ndarray, wavelengths: np.ndarray, rng: NSQRng, ray_id: np.ndarray, bounce: np.ndarray) tuple[np.ndarray, np.ndarray, np.ndarray][source]#

Sample scattered directions from the ABg lobe about a reference ray.

The scatter offset is drawn directly from the ABg distribution in direction-cosine space: the radial magnitude |beta - beta0| comes from a tabulated inverse CDF of BSDF(beta) * 2 * pi * beta and the azimuth is uniform. Rays keep their full flux, so the surface acts as a mirror (or diffuser sheet) whose reflection (or straight-through transmission) is blurred by the ABg lobe: a polished surface (small l0) stays near-specular/near-collimated, a rough one spreads.

A per-ray draw against transmissive_fraction picks the reference ray the lobe is centred on: the specular reflection for a reflective draw, or the undeviated straight-through ray (the incident direction itself, unrefracted) for a transmissive one. Both references are expressed in the same tangent frame about normals, so the existing spec_normal_sign (the reference ray’s own sign against normals) places the reconstructed sample on the correct side automatically – no separate branch is needed downstream.

To model the physically scaled picture instead, a bright specular beam plus a faint scatter halo, set the surface’s scatter_fraction to total_integrated_scatter. Then a TIS fraction of the rays enter the halo and the rest reflect specularly.

Sampling the lobe directly matters: drawing from a cosine-weighted hemisphere and correcting with a clipped BSDF / cos weight (the previous approach) puts essentially every sample where the ABg lobe is negligible, which drove surface throughput to ~1e-7 of the incident flux and made the model behave as a black absorber.

Sampling is detached (numpy); weights are plain scalars.

Parameters:
  • num_rays – Number of rays.

  • incident_dirs – Incident directions, shape (N, 3).

  • normals – Surface normals, shape (N, 3).

  • wavelengths – Wavelengths [µm], shape (N,).

  • rng – Keyed PCG32 RNG.

  • ray_id – Per-ray identifiers, shape (N,).

  • bounce – Per-ray bounce/step index, shape (N,).

Returns:

(scattered_dirs, flux_weights, transmitted).

property total_integrated_scatter: float#

Fraction of incident power scattered by this surface, in [0, 1].

Returns:

TIS, clipped to 1.0.

class LambertianBSDF(reflectance_value: float = 1.0, transmissive_fraction: float = 0.0)[source]#

Bases: BaseBSDF

Cosine-weighted Lambertian diffuse scatter.

Variables:
  • reflectance_value – Hemispherical diffuse reflectance in [0, 1].

  • transmissive_fraction – Probability in [0, 1] that a given scatter event samples the transmissive hemisphere (the far side of the surface, e.g. a ground-glass diffuser) instead of the reflective one. Defaults to 0.0: a pure diffuse reflector, identical to this class’s behaviour before D-5.

reflectance(incident_dirs: numpy.ndarray, normals: numpy.ndarray, wavelengths: numpy.ndarray) numpy.ndarray[source]#

Return total hemispherical reflectance.

Parameters:
  • incident_dirs – Incident directions, shape (N, 3).

  • normals – Surface normals, shape (N, 3).

  • wavelengths – Wavelengths [µm], shape (N,).

Returns:

Array of reflectance_value, shape (N,).

sample(num_rays: int, incident_dirs: np.ndarray, normals: np.ndarray, wavelengths: np.ndarray, rng: NSQRng, ray_id: np.ndarray, bounce: np.ndarray) tuple[np.ndarray, np.ndarray, np.ndarray][source]#

Sample cosine-weighted hemisphere directions around +/- normals.

Uses Malley’s method: sample uniform disk, project to hemisphere. A per-ray draw against transmissive_fraction picks whether that hemisphere is centred on normals (reflective) or -normals (transmissive). Sampling is detached (keyed PCG32); weights are plain scalars.

Parameters:
  • num_rays – Number of rays.

  • incident_dirs – Incident directions, shape (N, 3).

  • normals – Surface normals, shape (N, 3), pointing toward ray side.

  • wavelengths – Wavelengths [µm], shape (N,).

  • rng – Keyed PCG32 RNG.

  • ray_id – Per-ray identifiers, shape (N,).

  • bounce – Per-ray bounce/step index, shape (N,).

Returns:

(scattered_dirs, flux_weights, transmitted); flux_weights = reflectance_value for every ray (the lobe redistributes energy within whichever hemisphere it lands in, it does not remove it).

class SpecularBRDF[source]#

Bases: BaseBSDF

Perfect specular reflector (mirror).

The scattered direction is the specular reflection of the incident ray. Flux weight is always 1.0 (no energy loss at the surface itself).

reflectance(incident_dirs: np.ndarray, normals: np.ndarray, wavelengths: np.ndarray) np.ndarray[source]#

Return reflectance = 1.0 (perfect mirror).

Parameters:
  • incident_dirs – Incident directions, shape (N, 3).

  • normals – Surface normals, shape (N, 3).

  • wavelengths – Wavelengths [µm], shape (N,).

Returns:

Array of ones, shape (N,).

sample(num_rays: int, incident_dirs: np.ndarray, normals: np.ndarray, wavelengths: np.ndarray, rng: NSQRng | None = None, ray_id: np.ndarray | None = None, bounce: np.ndarray | None = None) tuple[np.ndarray, np.ndarray, np.ndarray][source]#

Sample specular reflection directions.

Parameters:
  • num_rays – Number of rays.

  • incident_dirs – Incident directions, shape (N, 3).

  • normals – Surface normals, shape (N, 3).

  • wavelengths – Wavelengths [µm], shape (N,).

  • rng – Unused for specular BRDF.

  • ray_id – Unused for specular BRDF.

  • bounce – Unused for specular BRDF.

Returns:

(reflected_dirs, ones, all_false) – reflected unit vectors, unit weights, and an all-False transmitted mask (a mirror lobe has no far side).

class TabulatedBSDF(path: str | Path, transmissive_fraction: float = 0.0)[source]#

Bases: BaseBSDF

BSDF loaded from tabulated data (CSV or Zemax scatter file).

The file must contain columns: theta_i [deg], theta_s [deg], bsdf_value. The BSDF is assumed azimuthally symmetric (phi-independent).

Variables:
  • path – Path to the scatter data file.

  • transmissive_fraction – Probability in [0, 1] that a given scatter event samples the transmissive hemisphere (the far side of the surface) instead of the reflective one. Defaults to 0.0: a purely reflective scatter, identical to this class’s behaviour before D-5. The tabulated data itself is treated as hemisphere-relative (theta_s measured from whichever normal the draw lands on), not as a combined BRDF+BTDF table.

reflectance(incident_dirs: numpy.ndarray, normals: numpy.ndarray, wavelengths: numpy.ndarray) numpy.ndarray[source]#

Approximate total hemispherical reflectance from tabulated data.

Parameters:
  • incident_dirs – Incident directions, shape (N, 3).

  • normals – Surface normals, shape (N, 3).

  • wavelengths – Wavelengths [µm], shape (N,).

Returns:

Reflectance values, shape (N,).

sample(num_rays: int, incident_dirs: np.ndarray, normals: np.ndarray, wavelengths: np.ndarray, rng: NSQRng, ray_id: np.ndarray, bounce: np.ndarray) tuple[np.ndarray, np.ndarray, np.ndarray][source]#

Sample scattered directions from the tabulated BSDF.

Uses importance sampling via Lambertian hemisphere + BSDF weighting. A per-ray draw against transmissive_fraction picks whether that hemisphere is centred on normals (reflective) or -normals (transmissive); theta_i/theta_s are measured from whichever normal the ray’s draw landed on. Sampling is detached (keyed PCG32).

Parameters:
  • num_rays – Number of rays.

  • incident_dirs – Incident directions, shape (N, 3).

  • normals – Surface normals, shape (N, 3).

  • wavelengths – Wavelengths [µm], shape (N,).

  • rng – Keyed PCG32 RNG.

  • ray_id – Per-ray identifiers, shape (N,).

  • bounce – Per-ray bounce/step index, shape (N,).

Returns:

(scattered_dirs, flux_weights, transmitted).

Detectors#

Detectors subpackage for Non-Sequential Raytracing.

class BaseDetector(cs: CoordinateSystem, geometry: ComponentGeometry, name: str = '', absorb: bool = True)[source]#

Bases: ABC

Abstract base class for detectors in the NSQ scene.

Detectors record ray data at a surface. They intersect rays (via their geometry) and accumulate hit data across simulation batches.

Detectors are absorbing by default: a ray that reaches a detector is recorded and then terminated. Setting absorb=False records the ray but lets it continue unchanged, so a detector can be tilted into a converging beam to sample it mid-system without terminating it. Several detectors may share one scene; among the detectors a ray would still reach, only the nearest one sees it, so stacking absorbing detectors down a beam records the beam at the nearest plane and nothing beyond it – use absorb=False (or trace one scene per plane) to profile a beam at several planes.

Variables:
  • cs – Coordinate system defining detector position and orientation.

  • geometry – Surface geometry that defines the detector area.

  • name – Optional human-readable label.

  • absorb – Whether a hit terminates the ray. False => the ray is recorded and passes through unaffected.

property bounding_box: AABB#

AABB of this detector in global coordinates.

abstractmethod get_result()[source]#

Return the accumulated result object.

Returns:

A result object (IrradianceMap, FarFieldPattern, etc.).

intersect(rays: NSQRayBundle) tuple[np.ndarray, np.ndarray, np.ndarray][source]#

Find ray intersections with this detector surface.

Parameters:

rays – Ray bundle in global coordinates.

Returns:

Tuple (t, normals, hit_mask) in global frame.

abstractmethod record(rays: NSQRayBundle, t: np.ndarray, hit_mask: np.ndarray) None[source]#

Accumulate ray data for rays that hit this detector.

Parameters:
  • rays – Current ray bundle. Positions have NOT yet been advanced to the hit point; use t to compute hit positions.

  • t – Hit distances [mm], shape (N,).

  • hit_mask – Boolean mask of rays hitting this detector, shape (N,).

abstractmethod reset() None[source]#

Clear accumulated data for reuse in a new simulation.

class DetectorRegistry[source]#

Bases: object

Named registry of NSQ detectors.

Variables:

_registry – Ordered dict mapping name -> BaseDetector.

add(name: str, detector: BaseDetector) None[source]#

Add a detector.

Parameters:
  • name – Unique identifier.

  • detector – Detector to register.

Raises:

KeyError – If a detector with name already exists.

property detectors: list[BaseDetector]#

Ordered list of all registered detectors.

get(name: str) BaseDetector[source]#

Retrieve a detector by name.

Parameters:

name – Name of the detector.

Returns:

The registered detector.

Raises:

KeyError – If no detector with name exists.

remove(name: str) None[source]#

Remove a detector by name.

Parameters:

name – Name of the detector to remove.

Raises:

KeyError – If no detector with name exists.

class FarFieldDetector(cs: CoordinateSystem, theta_max_deg: float, num_bins_theta: int, num_bins_phi: int, aperture_radius: float = 1000000.0, name: str = '', absorb: bool = True)[source]#

Bases: BaseDetector

Accumulates angular flux distribution in the far field.

Records ray directions at the detector surface and bins them into a polar (theta, phi) histogram.

Variables:
  • cs – Coordinate system.

  • theta_max_deg – Maximum polar angle to record [deg].

  • num_bins_theta – Number of polar angle bins.

  • num_bins_phi – Number of azimuthal angle bins.

property bounding_box: AABB#

AABB of this detector in global coordinates.

get_result() FarFieldPattern[source]#

Return the accumulated far-field pattern.

Returns:

FarFieldPattern with intensity [W/sr].

intersect(rays: NSQRayBundle) tuple[np.ndarray, np.ndarray, np.ndarray]#

Find ray intersections with this detector surface.

Parameters:

rays – Ray bundle in global coordinates.

Returns:

Tuple (t, normals, hit_mask) in global frame.

record(rays: NSQRayBundle, t: np.ndarray, hit_mask: np.ndarray) None[source]#

Accumulate angular flux from hit rays.

Converts ray directions to (theta, phi) in local detector frame and bins.

Parameters:
  • rays – Current ray bundle.

  • t – Hit distances [mm], shape (N,).

  • hit_mask – Boolean mask of hitting rays, shape (N,).

reset() None[source]#

Clear accumulated data.

class FarFieldDetectorConfig(num_theta: int = 90, num_phi: int = 360, absorb: bool = True)[source]#

Bases: object

Configuration for a FarFieldDetector.

Variables:
  • num_theta (int) – Number of polar angle bins.

  • num_phi (int) – Number of azimuthal angle bins.

  • absorb (bool) – Whether a hit terminates the ray.

absorb: bool = True#
num_phi: int = 360#
num_theta: int = 90#
class IrradianceDetector(cs: CoordinateSystem, width: float, height: float, num_pixels_x: int, num_pixels_y: int, splat: Literal['bilinear', 'gaussian', 'hard'] = 'bilinear', splat_sigma: float = 0.5, name: str = '', absorb: bool = True)[source]#

Bases: BaseDetector

2D irradiance map detector on a planar rectangular surface.

Records flux in a pixel grid with differentiable splatting.

Variables:
  • cs – Coordinate system.

  • width – Detector width [mm].

  • height – Detector height [mm].

  • num_pixels_x – Number of pixels along x.

  • num_pixels_y – Number of pixels along y.

  • splat – Splatting mode — ‘bilinear’, ‘gaussian’, or ‘hard’.

  • splat_sigma – Gaussian splat sigma in pixels (used when splat=’gaussian’).

property bounding_box: AABB#

AABB of this detector in global coordinates.

get_result() IrradianceMap[source]#

Return the accumulated irradiance map.

Returns:

IrradianceMap with irradiance [W/mm^2] computed from stored flux. The data attribute of the returned map is the attached flat flux buffer that supports gradient computation.

intersect(rays: NSQRayBundle) tuple[np.ndarray, np.ndarray, np.ndarray]#

Find ray intersections with this detector surface.

Parameters:

rays – Ray bundle in global coordinates.

Returns:

Tuple (t, normals, hit_mask) in global frame.

record(rays: NSQRayBundle, t: np.ndarray, hit_mask: np.ndarray) None[source]#

Accumulate flux from hit rays into the pixel grid.

Computes hit positions in local detector frame and accumulates flux using the configured splatting mode.

Parameters:
  • rays – Current ray bundle (positions not yet advanced to hit point).

  • t – Hit distances [mm], shape (N,).

  • hit_mask – Boolean mask of hitting rays, shape (N,).

reset() None[source]#

Clear accumulated data.

Re-initialises the internal buffer to a fresh be.zeros array, disconnecting it from the previous trace’s computation graph.

class IrradianceDetectorConfig(width: float, height: float, num_pixels_x: int = 256, num_pixels_y: int = 256, splat: Literal['bilinear', 'gaussian', 'hard'] = 'bilinear', splat_sigma: float = 0.5, absorb: bool = True)[source]#

Bases: object

Configuration for an IrradianceDetector.

Variables:
  • width (float) – Detector width [mm].

  • height (float) – Detector height [mm].

  • num_pixels_x (int) – Number of pixels along x.

  • num_pixels_y (int) – Number of pixels along y.

  • splat (Literal['bilinear', 'gaussian', 'hard']) – Splatting mode — ‘bilinear’, ‘gaussian’, or ‘hard’.

  • splat_sigma (float) – Gaussian splat sigma in pixels (used when splat=’gaussian’).

  • absorb (bool) – Whether a hit terminates the ray. False makes the detector transmissive: the hit is recorded and the ray continues on its unchanged direction, enabling mid-system beam sampling.

absorb: bool = True#
height: float#
num_pixels_x: int = 256#
num_pixels_y: int = 256#
splat: Literal['bilinear', 'gaussian', 'hard'] = 'bilinear'#
splat_sigma: float = 0.5#
width: float#
class RayDatabaseConfig(width: float, height: float, max_rays: int = 0, absorb: bool = True)[source]#

Bases: object

Configuration for a RayDatabaseDetector.

Variables:
  • width (float) – Detector width [mm].

  • height (float) – Detector height [mm].

  • max_rays (int) – Maximum number of rays to store (0 = unlimited).

  • absorb (bool) – Whether a hit terminates the ray.

absorb: bool = True#
height: float#
max_rays: int = 0#
width: float#
class RayDatabaseDetector(cs: CoordinateSystem, geometry: ComponentGeometry, store_rays: bool = True, max_rays: int | None = None, name: str = '', absorb: bool = True)[source]#

Bases: 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.

Variables:
  • 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.

property bounding_box: AABB#

AABB of this detector in global coordinates.

get_result() RayDatabase[source]#

Return accumulated ray database.

Returns:

RayDatabase with stored ray phase-space data.

intersect(rays: NSQRayBundle) tuple[np.ndarray, np.ndarray, np.ndarray]#

Find ray intersections with this detector surface.

Parameters:

rays – Ray bundle in global coordinates.

Returns:

Tuple (t, normals, hit_mask) in global frame.

record(rays: NSQRayBundle, t: np.ndarray, hit_mask: np.ndarray) None[source]#

Record hit ray data.

Parameters:
  • rays – Current ray bundle.

  • t – Hit distances [mm], shape (N,).

  • hit_mask – Boolean mask of hitting rays, shape (N,).

reset() None[source]#

Clear accumulated data.

class SpectralDetector(cs: CoordinateSystem, width: float, height: float, num_pixels_x: int, num_pixels_y: int, wavelength_bins: np.ndarray, splat: Literal['bilinear', 'gaussian', 'hard'] = 'bilinear', splat_sigma: float = 0.5, name: str = '', absorb: bool = True)[source]#

Bases: BaseDetector

Per-wavelength irradiance detector on a planar rectangular surface.

Records flux in a 3D (x, y, wl) grid.

Variables:
  • cs – Coordinate system.

  • width – Detector width [mm].

  • height – Detector height [mm].

  • num_pixels_x – Number of pixels along x.

  • num_pixels_y – Number of pixels along y.

  • wavelength_bins – Wavelength bin edges [µm].

  • splat – Spatial splatting mode – ‘bilinear’, ‘gaussian’, or ‘hard’. Splatting is spatial (x, y) only; the wavelength bin is always hard-assigned.

  • splat_sigma – Gaussian splat sigma in pixels (used when splat='gaussian').

property bounding_box: AABB#

AABB of this detector in global coordinates.

get_result() SpectralResult[source]#

Return accumulated spectral result.

Returns:

SpectralResult with irradiance [W/mm^2] per pixel per wavelength bin.

intersect(rays: NSQRayBundle) tuple[np.ndarray, np.ndarray, np.ndarray]#

Find ray intersections with this detector surface.

Parameters:

rays – Ray bundle in global coordinates.

Returns:

Tuple (t, normals, hit_mask) in global frame.

record(rays: NSQRayBundle, t: np.ndarray, hit_mask: np.ndarray) None[source]#

Accumulate per-wavelength flux from hit rays.

Parameters:
  • rays – Current ray bundle.

  • t – Hit distances [mm], shape (N,).

  • hit_mask – Boolean mask of hitting rays, shape (N,).

reset() None[source]#

Clear accumulated data.

class SpectralDetectorConfig(width: float, height: float, num_pixels_x: int = 256, num_pixels_y: int = 256, wl_min: float = 0.4, wl_max: float = 0.7, num_bins: int = 100, splat: Literal['bilinear', 'gaussian', 'hard'] = 'bilinear', splat_sigma: float = 0.5, absorb: bool = True)[source]#

Bases: object

Configuration for a SpectralDetector.

Variables:
  • width (float) – Detector width [mm].

  • height (float) – Detector height [mm].

  • num_pixels_x (int) – Number of pixels along x.

  • num_pixels_y (int) – Number of pixels along y.

  • wl_min (float) – Minimum wavelength for spectral binning [µm].

  • wl_max (float) – Maximum wavelength for spectral binning [µm].

  • num_bins (int) – Number of wavelength bins.

  • splat (Literal['bilinear', 'gaussian', 'hard']) – Spatial (x, y) splatting mode — ‘bilinear’, ‘gaussian’, or ‘hard’. The wavelength bin is always hard-assigned.

  • splat_sigma (float) – Gaussian splat sigma in pixels (used when splat='gaussian').

  • absorb (bool) – Whether a hit terminates the ray.

Note

Wavelengths are in micrometres, matching Spectrum and every other wavelength in Optiland. Visible light spans 0.4-0.7 µm, so a detector spanning the visible is wl_min=0.4, wl_max=0.7.

absorb: bool = True#
height: float#
num_bins: int = 100#
num_pixels_x: int = 256#
num_pixels_y: int = 256#
splat: Literal['bilinear', 'gaussian', 'hard'] = 'bilinear'#
splat_sigma: float = 0.5#
width: float#
wl_max: float = 0.7#
wl_min: float = 0.4#

Results#

Results subpackage for Non-Sequential Raytracing.

class FarFieldPattern(intensity: numpy.ndarray, theta: numpy.ndarray, phi: numpy.ndarray, total_flux: float, num_rays_hit: int)[source]#

Bases: object

Angular flux distribution in the far field.

Variables:
  • intensity – Intensity [W/sr], shape (n_theta, n_phi).

  • theta – Polar angle bin centres [deg], shape (n_theta,).

  • phi – Azimuthal angle bin centres [deg], shape (n_phi,).

  • total_flux – Total flux recorded [W].

  • num_rays_hit – Number of rays recorded.

plot(ax=None, projection: str = 'polar', **kwargs)[source]#

Plot the far-field pattern.

Parameters:
  • ax – Optional Matplotlib Axes. If None, a new figure is created.

  • projection – Plot projection type (‘polar’ or ‘cartesian’).

  • **kwargs – Additional arguments passed to the plot function.

Returns:

The Matplotlib Figure object.

save(path: str | Path) None[source]#

Save far-field pattern to a .npz file.

Parameters:

path – Output file path.

class IrradianceMap(irradiance: numpy.ndarray, x_coords: numpy.ndarray, y_coords: numpy.ndarray, total_flux, num_rays_hit: int, data=None)[source]#

Bases: object

2D irradiance distribution on a planar detector.

Variables:
  • data – Flat accumulated flux buffer (be-array, shape ny*nx). This is the differentiable handle; call .data.backward() to propagate gradients through the detector image. None when constructed from legacy hard-splatted data.

  • irradiance – Irradiance [W/mm^2], shape (ny, nx), as a NumPy array.

  • x_coords – Bin centre x-coordinates [mm], shape (nx,).

  • y_coords – Bin centre y-coordinates [mm], shape (ny,).

  • total_flux – Total flux recorded [W]. Attached to the active backend’s autograd graph – a torch.Tensor when the underlying data is, so result.total_flux.backward() propagates a gradient. Use total_flux_float for printing or any consumer that expects a plain Python float.

  • num_rays_hit – Number of rays recorded on this detector.

plot(ax=None, **kwargs)[source]#

Plot the irradiance map.

Parameters:
  • ax – Optional Matplotlib Axes. If None, a new figure is created.

  • **kwargs – Additional arguments passed to imshow.

Returns:

The Matplotlib Figure object.

save(path: str | Path) None[source]#

Save irradiance map to a .npz file.

Parameters:

path – Output file path.

to_numpy() numpy.ndarray[source]#

Return the irradiance array as a NumPy array.

Returns:

The irradiance array, shape (ny, nx).

property total_flux_float: float#

Total detected flux [W] as a plain, detached Python float.

Use this for printing, formatting, or any code path that cannot accept a gradient-carrying tensor; total_flux stays attached for differentiation.

class RayDatabase(x: numpy.ndarray, y: numpy.ndarray, z: numpy.ndarray, L: numpy.ndarray, M: numpy.ndarray, N: numpy.ndarray, flux: numpy.ndarray, wavelength: numpy.ndarray)[source]#

Bases: object

Phase-space record of individual rays at a detector surface.

Variables:
  • x – Ray positions x [mm].

  • y – Ray positions y [mm].

  • z – Ray positions z [mm].

  • L – Ray direction cosine x (unit vector).

  • M – Ray direction cosine y.

  • N – Ray direction cosine z.

  • flux – Per-ray flux [W].

  • wavelength – Per-ray wavelength [µm].

property num_rays: int#

Number of stored rays.

save(path: str | Path) None[source]#

Save ray database to a .npz file.

Parameters:

path – Output file path.

to_dataframe()[source]#

Return ray data as a pandas DataFrame.

Returns:

DataFrame with columns x, y, z, L, M, N, flux, wavelength.

Raises:

ImportError – If pandas is not installed.

property total_flux: float#

Total flux recorded at this detector [W].

Exposed so a ray-database detector contributes to the tracer’s flux ledger like every other detector; without it the detected flux is undercounted and flux_conservation_error misreports.

class SpectralResult(irradiance: numpy.ndarray, x_coords: numpy.ndarray, y_coords: numpy.ndarray, wavelengths: numpy.ndarray, total_flux: float, num_rays_hit: int)[source]#

Bases: object

Per-wavelength irradiance on a planar detector.

Variables:
  • irradiance – Irradiance per wavelength bin [W/mm^2], shape (ny, nx, n_lambda). Flux is binned, not divided by bin width, so summing over the last axis gives the broadband irradiance.

  • x_coords – Bin centre x-coordinates [mm].

  • y_coords – Bin centre y-coordinates [mm].

  • wavelengths – Wavelength bin centres [µm].

  • total_flux – Total flux recorded [W].

  • num_rays_hit – Number of rays recorded.

plot_at_wavelength(wl: float, ax=None, **kwargs)[source]#

Plot the irradiance map at the wavelength closest to wl.

Parameters:
  • wl – Target wavelength [µm].

  • ax – Optional Matplotlib Axes.

  • **kwargs – Additional arguments passed to imshow.

Returns:

The Matplotlib Figure object.

save(path: str | Path) None[source]#

Save spectral result to a .npz file.

Parameters:

path – Output file path.

Diagnostics#

Self-diagnosing simulation results.

SimulationResult.diagnostics turns several silent failure modes the NSQ engine could previously produce – a scene that depth-truncates most of its flux, a detector whose map is pure shot noise, a surface no ray ever touches, roulette eating an unexpected fraction of the beam – into an explicit, inspectable object with a threshold-based warning list, rather than numbers a user has to know to go looking for.

Kramer Harrison, 2026

class DetectorDiagnostic(name: str, num_rays_hit: int, num_pixels: int | None, mean_hits_per_pixel: float | None, undersampled: bool, rays_needed_for_5pct: int | None)[source]#

Bases: object

Per-detector sampling-quality diagnostic.

Variables:
  • name (str) – Detector’s registry name.

  • num_rays_hit (int) – Rays recorded on this detector.

  • num_pixels (int | None) – Pixel/bin count of the detector’s map, or None for detector kinds with no grid (e.g. RayDatabaseDetector).

  • mean_hits_per_pixel (float | None) – num_rays_hit / num_pixels, or None when num_pixels is None.

  • undersampled (bool) – True when mean_hits_per_pixel is below _UNDERSAMPLED_HITS_PER_PIXEL – shot noise dominates the map. Always False when num_pixels is None (nothing to under-sample against).

  • rays_needed_for_5pct (int | None) – Estimated total ray count (scaled from this trace’s num_rays_total) that would bring this detector to ~5% relative Poisson error, assuming ray count scales linearly with hits (true when flux/geometry are unchanged). None when num_pixels is None or no rays hit at all.

mean_hits_per_pixel: float | None#
name: str#
num_pixels: int | None#
num_rays_hit: int#
rays_needed_for_5pct: int | None#
undersampled: bool#
class Diagnostics(depth_truncated_flux_fraction: float = 0.0, rr_killed_flux_fraction: float = 0.0, flux_conservation_error: float = 0.0, unreached_geometry: tuple[str, ...] = (), detectors: tuple[~optiland.nonsequential.diagnostics.DetectorDiagnostic, ...] = <factory>, medium_stack_underflows: int = 0, split_budget_saturated: bool = False)[source]#

Bases: object

Self-diagnosing summary of one trace.

Every field is computed during the trace at negligible extra cost (a few running counters and one pass over scene.detectors at the end) – this is diagnosis, not a second simulation.

Variables:
  • depth_truncated_flux_fraction (float) – Fraction of total_flux_in killed by the hard max_depth cutoff. This is the one loss mechanism that is an inherent, reported bias – not something roulette or importance sampling can fix – so a nonzero value is a direct signal to raise max_depth if the deep paths matter.

  • rr_killed_flux_fraction (float) – Fraction of total_flux_in killed by Russian roulette, including bounded-splitting budget culling. Unbiased in expectation; large values mean the per-trace estimator is noisy, not necessarily wrong.

  • flux_conservation_error (float) – Copied from flux_conservation_error for convenience – see that field’s docstring.

  • unreached_geometry (tuple[str, ...]) – Names of scene components no ray ever hit (nearest-hit or otherwise) over the whole trace. Usually a misplaced or mis-oriented surface, but can be a deliberately unused spare aperture – reported, not assumed to be a bug.

  • detectors (tuple[optiland.nonsequential.diagnostics.DetectorDiagnostic, ...]) – Per-detector sampling-quality diagnostics, in scene.detectors order.

  • medium_stack_underflows (int) – Total pop-on-empty-stack events across all rays this trace (see NSQRayBundle.medium_stack/ RefractiveComponent.interact). This is a diagnostic cross-check layered on top of the geometric n_geom sidedness resolution – it never affects the physics (n1/n2 are always resolved geometrically, never from the stack) – so a nonzero value flags a likely geometry defect (a volume boundary surface reused inconsistently, or two separately constructed NSQMaterial instances standing in for what should be one physical medium) without itself changing any traced result.

  • split_budget_saturated (bool) – True if bounded splitting ever hit its split_budget cap during this trace, meaning some spawned ghost-path rays were roulette-terminated rather than all being kept. NumPy backend only; always False otherwise.

depth_truncated_flux_fraction: float = 0.0#
detectors: tuple[DetectorDiagnostic, ...]#
flux_conservation_error: float = 0.0#
medium_stack_underflows: int = 0#
report() str[source]#

Full human-readable diagnostic report.

Returns:

every diagnostic value, followed by a “Warnings” section (or “No warnings.” if none fired).

Return type:

A multi-line string

rr_killed_flux_fraction: float = 0.0#
split_budget_saturated: bool = False#
unreached_geometry: tuple[str, ...] = ()#
warnings() list[str][source]#

Human-readable warnings for every diagnostic past its threshold.

Returns:

A list of one-line warning strings; empty if nothing is flagged.

build_diagnostics(scene: NSQScene, hit_component_ids: set[int], num_rays_total: int, total_flux_in: float, total_flux_depth_killed: float, total_flux_rr_killed: float, flux_conservation_error: float, split_budget_saturated: bool, detector_results: dict[str, object], medium_stack_underflows: int = 0) Diagnostics[source]#

Assemble a Diagnostics from one trace’s bookkeeping.

Shared by both reference backends so the diagnostic definitions cannot drift between them.

Parameters:
  • scene – The traced scene.

  • hit_component_ids – Indices into scene.surfaces that were the nearest hit for at least one ray at least once.

  • num_rays_total – Total rays launched this trace.

  • total_flux_in – Total launched flux [W].

  • total_flux_depth_killed – Flux killed by the hard max_depth cutoff.

  • total_flux_rr_killed – Flux killed by Russian roulette (including split-budget culling).

  • flux_conservation_error – The trace’s flux-ledger closure error.

  • split_budget_saturated – Whether bounded splitting ever hit its cap.

  • detector_results{name: get_result()} for every detector, in scene.detectors order.

  • medium_stack_underflows – Total medium-stack pop-on-empty events across the trace (see Diagnostics).

Returns:

The assembled diagnostics.

Photometric units#

Photometric conversion layer.

The NSQ radiometric core is unchanged: everything inside the trace stays in watts, W/mm^2, and micrometres. This module is a read-only conversion layer on top of finished results (and, for sources, an input-side lumens-to-watts helper) – it never touches the trace loop.

Guardrail : converting a monochromatic result outside the visible band, or a spectrum with negligible V(lambda) overlap, raises rather than returning a near-zero photometric value that looks like a valid (if dim) answer. Silently returning ~0 is the same defect class as D-2 (an accepted configuration that quietly does nothing).

Kramer Harrison, 2026

class PhotometricMap(data: numpy.ndarray, x_coords: numpy.ndarray, y_coords: numpy.ndarray, total: float, quantity: str, weighting: Literal['photopic', 'scotopic'])[source]#

Bases: object

A 2D photometric map – illuminance [lux] or luminance-equivalent.

Mirrors IrradianceMap’s shape so it can be plotted the same way, but the values and units are photometric, not radiometric.

Variables:
  • data – Photometric map, shape (ny, nx). Lux for quantity= "illuminance".

  • x_coords – Bin centre x-coordinates [mm], shape (nx,).

  • y_coords – Bin centre y-coordinates [mm], shape (ny,).

  • total – Total luminous flux [lm] over the whole map.

  • quantity – The photometric quantity computed ("illuminance").

  • weighting"photopic" or "scotopic".

plot(ax=None, **kwargs)[source]#

Plot the photometric map.

Parameters:
  • ax – Optional Matplotlib Axes. If None, a new figure is created.

  • **kwargs – Additional arguments passed to imshow.

Returns:

The Matplotlib Figure object.

class PhotometricScalar(value: float, quantity: str, weighting: Literal['photopic', 'scotopic'])[source]#

Bases: object

A single photometric quantity (e.g. total luminous flux).

Variables:
  • value – The photometric value.

  • quantity"luminous_flux" (lumens).

  • weighting"photopic" or "scotopic".

lumens_to_watts(total_flux_lm: float, spectrum: Spectrum, weighting: Weighting = 'photopic') float[source]#

Convert a source’s lumens to the radiometric watts NSQ traces in.

Parameters:
  • total_flux_lm – Source output in lumens.

  • spectrum – The source’s Spectrum – its shape (not absolute scale) determines the conversion.

  • weighting"photopic" (default) or "scotopic".

Returns:

Radiant flux [W] such that a source emitting this many watts, with this spectral shape, emits total_flux_lm lumens.

Raises:

ValueError – If the spectrum has negligible overlap with the chosen V(lambda) curve (guardrail) – e.g. a monochromatic 1.5 um source has no photopic lumen equivalent worth reporting.

luminous_efficacy_of_spectrum(wavelengths_um: numpy.ndarray, weights: numpy.ndarray, weighting: Literal['photopic', 'scotopic'] = 'photopic') float[source]#

Luminous efficacy [lm/W] of a normalised spectral power distribution.

Parameters:
  • wavelengths_um – Wavelength samples [um].

  • weights – Relative spectral power at each wavelength (need not be normalised; only the shape matters).

  • weighting"photopic" or "scotopic".

Returns:

Km * sum(weights * V(wavelengths)) / sum(weights) – the spectrum-averaged luminous efficacy, in [0, Km].

to_photometric(result: IrradianceMap | SpectralResult | FarFieldPattern, quantity: Literal['illuminance', 'luminous_flux'] = 'illuminance', weighting: Weighting = 'photopic', wavelength_um: float | None = None) PhotometricMap | PhotometricScalar[source]#

Convert a detector result to a photometric quantity.

The radiometric result is unaffected; this returns a new object.

Parameters:
  • result – An IrradianceMap, SpectralResult, or FarFieldPattern (from SimulationResult.detectors[name]).

  • quantity"illuminance" (lux, per-pixel map) or "luminous_flux" (total lumens, scalar).

  • weighting"photopic" (default, CIE 1931) or "scotopic" (CIE 1951).

  • wavelength_um – Required when result carries no per-wavelength breakdown of its own (i.e. anything but SpectralResult) – the monochromatic wavelength to weight by.

Returns:

A PhotometricMap for quantity="illuminance", or a PhotometricScalar for quantity="luminous_flux".

Raises:
  • ValueError – If the result’s spectral content – explicit wavelength_um or the result’s own wavelength bins – has negligible overlap with the chosen V(lambda) curve (guardrail: this would otherwise silently return ~0).

  • TypeError – If result has neither a pixel grid nor per -wavelength data (e.g. a RayDatabase), or quantity is not recognised.

v_lambda(wavelength_um: float | np.ndarray, weighting: Weighting = 'photopic')[source]#

Evaluate V(lambda) (or V’(lambda)) at one or more wavelengths.

Parameters:
  • wavelength_um – Wavelength(s) [um].

  • weighting"photopic" (default) or "scotopic".

Returns:

The luminous efficiency function value(s), 0 outside the tabulated visible band – correct physics (the eye has no response there), not a missing-data placeholder. Callers that must distinguish “genuinely zero” from “out of band” should check VISIBLE_BAND_UM themselves; to_photometric() and lumens_to_watts() do this via the negligible-overlap guardrail.

Materials#

NSQ Material subpackage.

class NSQMaterial(optiland_material: BaseMaterial | None = None, bsdf: BaseBSDF | None = None)[source]#

Bases: object

Thin differentiable adapter over optiland.materials.BaseMaterial.

Evaluates n(wavelength_um) without any grad-severing casts (no float(), no np.asarray()) so the result stays in the autograd graph when using the Torch backend.

Variables:
  • optiland_material (BaseMaterial | None) – Underlying material model. None means vacuum (n=1).

  • bsdf (BaseBSDF | None) – Optional surface scatter model.

bsdf: BaseBSDF | None = None#
classmethod from_glass(name: str) NSQMaterial[source]#

Resolve a glass catalog name to an NSQMaterial.

Parameters:

name – Glass catalog name (e.g. 'N-BK7', 'SF11').

Returns:

NSQMaterial wrapping the resolved BaseMaterial.

Raises:

ValueError – If the glass name is not found in the catalog.

k(wavelength_um: WavelengthInput) WavelengthInput[source]#

Extinction coefficient at the given wavelength(s).

Feeds Beer-Lambert bulk absorption: alpha = 4*pi*k/wavelength_um [1/um], matching optiland.propagation.homogeneous .HomogeneousPropagation so NSQ and the sequential engine attenuate a glass path by the same amount.

Parameters:

wavelength_um – Wavelength(s) in micrometres [µm].

Returns:

Extinction coefficient (dimensionless). Returns 0.0 (scalar) for vacuum when input is a scalar, or a zeros-like array/tensor matching the input shape for array inputs.

n(wavelength_um: WavelengthInput) WavelengthInput[source]#

Refractive index at the given wavelength(s).

Differentiable: when wavelength_um is a torch Tensor with requires_grad=True, the returned value carries attached gradients. No float() or np.asarray() casts are applied to the result.

Parameters:

wavelength_um – Wavelength(s) in micrometres [µm]. Accepts Python float, NumPy ndarray, or torch Tensor.

Returns:

Refractive index with the same array type as the input. Returns 1.0 (scalar) for vacuum when input is a scalar, or a ones-like array/tensor matching the input shape for array inputs.

optiland_material: BaseMaterial | None = None#

Backends#

Backends subpackage for Non-Sequential Raytracing.

class NumpyBackend(seed: int | None = None)[source]#

Bases: ArrayBackend

CPU backend using NumPy for all array operations.

This is the default fallback backend. All ray data remains in host (CPU) memory throughout the simulation. The full Monte Carlo trace loop lives in ArrayBackend; this class provides the NumPy-specific intersect_scene() and RNG.

Variables:
intersect_scene(rays: NSQRayBundle, components: list[BaseComponent]) tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray][source]#

Find nearest intersection of each ray with all scene components.

Parameters:
  • rays – Current ray bundle (NumPy arrays).

  • components – List of scene components.

Returns:

(t_min, hit_normals, component_indices, hit_n_geom).

trace(scene: NSQScene, num_rays: int, max_depth: int = 16, min_flux_fraction: float = 1e-06, batch_size: int = 16384, seed: int | None = None, record_paths: bool | int = False) SimulationResult#

Run the full Monte Carlo simulation.

Parameters:
  • scene – The NSQScene to simulate.

  • 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 for reproducibility.

  • record_pathsFalse (default) records nothing. True records every ray’s full path – fine for small traces, but O(rays x bounces) memory for large ones. A positive int records an approximately that-many-ray subset, selected by a PCG32 hash of ray_id so the trace stays full-size and cheap while a bounded, deterministic sample is available for visualization/diagnosis – e.g. scene.trace(num_rays=10_000_000, record_paths=1_000).

Returns:

SimulationResult.

class TorchBackend(seed: int | None = None, gradient_mode: Literal['autograd'] = 'autograd')[source]#

Bases: TracerBackend

Differentiable PyTorch backend for NSQ raytracing.

Uses optiland.backend (configured to torch) for all computation. The fixed-depth wavefront loop lets PyTorch build an autograd graph through the entire trace so that result.detectors[name].data.backward() propagates gradients to scene parameters.

Compaction is disabled: dead rays (alive=False) carry zero throughput and participate in all operations as no-ops; the tensor shape stays fixed across bounces so the graph remains clean.

Gradient strategy is “autograd” (naive attached graph) in v1. A pluggable gradient_mode seam is provided for future Path Replay Backpropagation.

Variables:
  • seed – RNG seed.

  • gradient_mode – Gradient strategy (currently only “autograd”).

  • rng – Keyed PCG32 RNG for detached sampling decisions (see optiland.nonsequential.rng).

intersect_scene(rays: NSQRayBundle, components: list[BaseComponent]) tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray][source]#

Find nearest intersection of each ray with all scene components.

t_min and hit_normals stay in the torch graph (attached to geometry parameters). comp_indices are numpy ints (no grad needed). n_geom is purely geometric (never a function of a differentiable material parameter), but is still built via be.where to stay consistent with the rest of this method and to support a differentiable geometry (radius, conic, …) tilting n_geom itself.

Parameters:
  • rays – Current ray bundle.

  • components – List of scene components.

Returns:

(t_min, hit_normals, component_indices, hit_n_geom).

trace(scene: NSQScene, num_rays: int, max_depth: int = 16, min_flux_fraction: float = 1e-06, batch_size: int = 16384, seed: int | None = None, record_paths: bool | int = False) SimulationResult[source]#

Run the differentiable fixed-depth trace.

Parameters:
  • scene – NSQScene to simulate.

  • num_rays – Total rays to launch.

  • max_depth – Fixed number of bounces. Rays exceeding this are depth-killed. Memory scales O(num_rays × max_depth).

  • 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 (forward pass only). Does not change the result, only the speed; see DEFAULT_BATCH_SIZE.

  • seed – RNG seed override (overrides constructor seed if provided).

  • record_pathsFalse records nothing, True records every ray’s path (numpy, detached), and a positive int records an approximately that-many-ray subset selected deterministically by ray_id hash – see optiland.nonsequential.path_recording.

Returns:

SimulationResult with differentiable detector data tensors.

class TracerBackend[source]#

Bases: ABC

Abstract backend for the NSQ Monte Carlo trace loop.

The full simulation loop – ray generation, intersection, interaction, detection – is delegated to the backend implementation. NumpyBackend provides the default CPU implementation. A future OptiXBackend from optiland-rt would replace the entire loop with NVIDIA OptiX kernel dispatch.

New backends implement trace() and are passed to NSQScene.trace(backend=...) or NSQTracer.trace(backend=...).

abstractmethod trace(scene: NSQScene, num_rays: int, max_depth: int = 16, min_flux_fraction: float = 1e-06, batch_size: int = 16384, seed: int | None = None, record_paths: bool | int = False) SimulationResult[source]#

Run the full simulation and return results.

Parameters:
  • scene – The NSQScene to trace (provides flat surface/source/detector lists via surfaces, sources, detectors).

  • num_rays – Total number of rays to launch.

  • max_depth – Maximum surface interactions per ray before termination.

  • min_flux_fraction – Rays whose flux drops below min_flux_fraction * (total_flux / num_rays) are killed.

  • batch_size – Number of rays per processing batch. Does not change the result, only the speed; see DEFAULT_BATCH_SIZE.

  • seed – RNG seed for reproducibility.

  • record_pathsFalse records nothing, True records every ray’s full phase-space path bounce-by-bounce, and a positive int records an approximately that-many-ray subset selected deterministically by ray_id hash – see optiland.nonsequential.path_recording.

Returns:

SimulationResult with per-detector results and global statistics.

Scene IR, sampling policy, and RNG#

The declarative, backend-portable scene description (see the developer guide), the rare-path sampling policy, and the counter-based PCG32 RNG.

Scene IR – the backend-portable, data-only description of an NSQ scene.

optiland.nonsequential.ir is the single change that is hardest to retrofit later: a scene becomes describable as plain data (SceneIR) rather than as live Python objects with methods. lower() converts a live NSQScene into that data form.

This is what makes a third-party backend (Mitsuba 3, OptiX) possible without reworking the NumPy/Torch reference engines again: any backend that can interpret a SceneIR can trace the scene. optiland.nonsequential.backends builds a fresh SceneIR at the start of every trace() call and drives its per-bounce interaction dispatch from it (see interpreter) rather than branching on the Python class of each scene.surfaces entry – the reference interpreter of this data, not the scene’s source of truth.

Translatability checklist#

Every physics feature added to NSQ from here on must satisfy these rules:

  1. No Python-side per-hit state. All interaction behaviour is expressible as (BsdfIR, MediumIR) data, not as a callback.

  2. Every parameter is a backend array or a plain scalar – never a closure, never an object with behaviour.

  3. No unbounded loops in the interaction path; iteration counts are compile-time constants or scene-level parameters.

  4. Transforms are (4, 4) matrices, not CoordinateSystem objects (which carry parent-chain behaviour).

  5. Everything in the IR is serializable to JSON without loss.

Kramer Harrison, 2026

class BsdfIR(kind: ~typing.Literal['none', 'specular', 'lambertian', 'harvey_shack', 'tabulated'], params: dict[str, ~typing.Any] = <factory>)[source]#

Bases: object

A surface scatter model, as plain data.

params is kind-specific and mirrors the corresponding BaseBSDF subclass’s constructor arguments exactly, so lowering is a direct field copy with no interpretation:

  • "none": {}

  • "specular": {} (always reflective; no transmissive lobe)

  • "lambertian": {"reflectance_value": <float>, "transmissive_fraction": <float>}

  • "harvey_shack": {"b0": <float>, "l0": <float>, "s": <float>, "transmissive_fraction": <float>}

  • "tabulated": {"path": <str>, "transmissive_fraction": <float>}

transmissive_fraction is the probability that a given scatter event samples the transmissive (far-side) hemisphere instead of the reflective one; it defaults to 0.0 on every kind that has it, so an un-set BSDF scatters exactly as it did before D-5.

Variables:
  • kind (Literal['none', 'specular', 'lambertian', 'harvey_shack', 'tabulated']) – Which scatter model this is.

  • params (dict[str, Any]) – Kind-specific parameters (see above).

kind: Literal['none', 'specular', 'lambertian', 'harvey_shack', 'tabulated']#
params: dict[str, Any]#
class EmitterIR(id: int, kind: Literal['point', 'collimated', 'extended'], to_world: numpy.ndarray, params: dict[str, Any], medium_id: int | None, name: str = '')[source]#

Bases: object

A ray source, as plain data.

Variables:
  • id (int) – Index into SceneIR.emitters.

  • kind (Literal['point', 'collimated', 'extended']) – Source family; params is interpreted according to it.

  • to_world (numpy.ndarray) – (4, 4) homogeneous local -> global transform.

  • params (dict[str, Any]) – Kind-specific parameters, including total_flux and the spectrum dict ({"wavelengths": [...], "weights": [...]}).

  • medium_id (int | None) – Index into SceneIR.media for the medium the source is embedded in, or None for vacuum.

  • name (str) – Human-readable label.

id: int#
kind: Literal['point', 'collimated', 'extended']#
medium_id: int | None#
name: str = ''#
params: dict[str, Any]#
to_world: numpy.ndarray#
class MediumIR(id: int, name: str, n_model: dict[str, Any], k_model: dict[str, Any] | None = None)[source]#

Bases: object

A single optical medium, as plain data.

n_model/k_model describe how to evaluate dispersion/absorption without embedding a live evaluator: a dict tagged by "kind" rather than a bound method, so the description survives a JSON round-trip and a non-Python backend can interpret it. Both use the same three kinds and are always populated together by lower():

  • {"kind": "constant", "n": <float>} / {"kind": "constant", "k": <float>} – wavelength-independent (vacuum is n=1.0, k=0.0).

  • {"kind": "catalog", "name": <str>} – a glass catalog name (e.g. "N-BK7"), resolved via NSQMaterial.from_glass() the same way optiland.nonsequential.serialization already round-trips materials.

  • {"kind": "opaque"} – a non-catalog material kept only for dispatch (lower(scene, strict=False)); not losslessly serializable.

A custom dispersion model with no catalog name cannot be lowered losslessly and raises at lowering time (see lower()) rather than silently dropping to a constant approximation.

Note that k_model here is descriptive only, matching the existing n_model precedent: the interpreter’s Beer-Lambert absorption (D-13, see optiland.nonsequential.backends.array_backend.ArrayBackend.trace) reads k(wavelength) from the live NSQMaterial on rays.k_current, never from this IR.

Variables:
  • id (int) – Index into SceneIR.media.

  • name (str) – Human-readable label (glass name, or "vacuum").

  • n_model (dict[str, Any]) – Refractive-index model descriptor, as above.

  • k_model (dict[str, Any] | None) – Extinction-coefficient model descriptor, as above.

id: int#
k_model: dict[str, Any] | None = None#
n_model: dict[str, Any]#
name: str#
class PrimitiveIR(id: int, kind: Literal['conic', 'plane', 'annulus', 'frustum', 'sphere', 'mesh'], to_world: numpy.ndarray, params: dict[str, Any], bsdf: BsdfIR, interior_medium_id: int, exterior_medium_id: int, volume_id: int | None, component_kind: Literal['refractive', 'reflective', 'absorbing'], scatter_fraction: float, name: str = '')[source]#

Bases: object

One surface, as plain data.

Variables:
  • id (int) – 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 (Literal['conic', 'plane', 'annulus', 'frustum', 'sphere', 'mesh']) – Geometry family; params is interpreted according to it.

  • to_world (numpy.ndarray) – (4, 4) homogeneous local -> global transform.

  • params (dict[str, Any]) – 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 (optiland.nonsequential.ir.bsdf_ir.BsdfIR) – Attached scatter model, if any (BsdfIR(kind="none") when the surface is bare specular/refractive/absorbing).

  • interior_medium_id (int) – 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 (int) – As above, for the front side.

  • volume_id (int | None) – 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 (Literal['refractive', 'reflective', 'absorbing']) – Which physical interaction this primitive’s hit dispatches to (see ComponentKind).

  • scatter_fraction (float) – Probability that a hit ray is routed through bsdf rather than the specular/refractive path.

  • name (str) – Human-readable label, for diagnostics.

bsdf: BsdfIR#
component_kind: Literal['refractive', 'reflective', 'absorbing']#
exterior_medium_id: int#
id: int#
interior_medium_id: int#
kind: Literal['conic', 'plane', 'annulus', 'frustum', 'sphere', 'mesh']#
name: str = ''#
params: dict[str, Any]#
scatter_fraction: float#
to_world: numpy.ndarray#
volume_id: int | None#
class RngContract(algorithm: str = 'pcg32', version: int = 1)[source]#

Bases: object

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 optiland.nonsequential.rng.

Variables:
  • algorithm (str) – RNG algorithm identifier.

  • version (int) – Key-layout version, bumped if the (seed, ray_id, bounce, event_slot) mixing scheme in optiland.nonsequential.rng ever changes incompatibly.

algorithm: str = 'pcg32'#
version: int = 1#
class SamplingPolicy(reflect_prob: float | Literal['fresnel', 'auto'] = 'fresnel', split_depth: int = 0, split_budget: float = 4.0, rr_start_flux: float = 0.001)[source]#

Bases: object

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.

Variables:
  • reflect_prob (float | Literal['fresnel', 'auto']) – Importance-sampling probability for the reflect branch (see 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 (int) – NumPy forward engine only; bounded bounce-splitting depth (see 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 (float) – 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 (float) – Russian-roulette threshold, as a fraction of per-ray initial flux (see optiland.nonsequential.sampling.russian_roulette()). Replaces the old biased hard kill below min_flux on both backends.

reflect_prob: float | Literal['fresnel', 'auto'] = 'fresnel'#
rr_start_flux: float = 0.001#
split_budget: float = 4.0#
split_depth: int = 0#
class SceneIR(primitives: tuple[~optiland.nonsequential.ir.scene_ir.PrimitiveIR, ...], volumes: tuple[~optiland.nonsequential.ir.scene_ir.VolumeIR, ...], media: tuple[~optiland.nonsequential.ir.medium_ir.MediumIR, ...], emitters: tuple[~optiland.nonsequential.ir.scene_ir.EmitterIR, ...], sensors: tuple[~optiland.nonsequential.ir.scene_ir.SensorIR, ...], rng: ~optiland.nonsequential.ir.scene_ir.RngContract = <factory>, sampling: ~optiland.nonsequential.ir.scene_ir.SamplingPolicy = <factory>)[source]#

Bases: object

The complete, backend-portable scene description.

Variables:
emitters: tuple[EmitterIR, ...]#
media: tuple[MediumIR, ...]#
primitives: tuple[PrimitiveIR, ...]#
rng: RngContract#
sampling: SamplingPolicy#
sensors: tuple[SensorIR, ...]#
volumes: tuple[VolumeIR, ...]#
class SensorIR(id: int, kind: Literal['irradiance', 'spectral', 'far_field', 'ray_database'], to_world: numpy.ndarray, params: dict[str, Any], primitive_id: int | None = None, absorb: bool = True, name: str = '')[source]#

Bases: object

A detector, as plain data.

Variables:
  • id (int) – Index into SceneIR.sensors.

  • kind (Literal['irradiance', 'spectral', 'far_field', 'ray_database']) – Detector family; params is interpreted according to it.

  • to_world (numpy.ndarray) – (4, 4) homogeneous local -> global transform.

  • params (dict[str, Any]) – Kind-specific parameters (extents, pixel counts, splat, …).

  • primitive_id (int | None) – Index into SceneIR.primitives, reserved for a future unification of detectors into the primitive list itself. Always None – detectors are dispatched by 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 (bool) – 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 (str) – Human-readable label.

absorb: bool = True#
id: int#
kind: Literal['irradiance', 'spectral', 'far_field', 'ray_database']#
name: str = ''#
params: dict[str, Any]#
primitive_id: int | None = None#
to_world: numpy.ndarray#
class VolumeIR(id: int, name: str, boundary_primitive_ids: tuple[int, ...], interior_medium_id: int)[source]#

Bases: object

A closed, outward-oriented set of boundary primitives (reserved).

Not populated by 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.

Variables:
  • id (int) – Index into SceneIR.volumes.

  • name (str) – Human-readable label.

  • boundary_primitive_ids (tuple[int, ...]) – Ids into SceneIR.primitives forming the closed boundary.

  • interior_medium_id (int) – Index into SceneIR.media for the volume’s interior.

boundary_primitive_ids: tuple[int, ...]#
id: int#
interior_medium_id: int#
name: str#
apply_primitive_interactions(rays: NSQRayBundle, ir: SceneIR, components: list[BaseComponent], t_min: object, hit_normals: object, hit_n_geom: object, comp_idx: np.ndarray, comp_first_np: np.ndarray, rng: NSQRng, log_hit_fn: LogHitFn | None = None, ray_id_allocator: RayIdAllocator | None = None) NSQRayBundle | None[source]#

Apply each hit primitive’s interaction to rays, in-place.

This is the shared per-bounce “which surface did each ray hit, and what happens” step – previously duplicated almost verbatim between ArrayBackend.trace() and TorchBackend.trace() (backend-specific only in whether t_min/hit_normals are eager NumPy arrays or attached Torch tensors, which optiland.backend already abstracts).

Dispatch is IR-driven: primitives are visited in ir.primitives order (not by iterating components and asking “is this the hit one”), and each hit is checked against its recorded ComponentKind/ BsdfIR before the live component’s interact() executes the physics (see the module docstring for why the physics itself still lives on the component).

Parameters:
  • rays – Ray bundle to update in-place.

  • ir – The scene’s lowered IR (built once per trace() call).

  • componentsscene.surfaces, in the same order ir.primitives was built from – components[i] is the live object ir.primitives[i] was lowered from.

  • t_min – Per-ray nearest-primitive hit distance, shape (N,).

  • hit_normals – Per-ray nearest-primitive hit normal, shape (N, 3).

  • hit_n_geom – Per-ray nearest-primitive geometric (unflipped) normal, shape (N, 3); see ComponentGeometry.ray_intersect.

  • comp_idx – Per-ray index into ir.primitives/components of the nearest-hit primitive, or -1. NumPy int array.

  • comp_first_np – Per-ray mask: True where a primitive (not a detector) is this ray’s nearest hit and should be processed this bounce. NumPy bool array.

  • rng – Keyed PCG32 RNG.

  • log_hit_fn – Optional (rays, mask, primitive_name, t_offset) callback for path recording, matching each backend’s _log_hits closure.

  • ray_id_allocator(n) -> int64 ndarray of n fresh, previously -unused ray ids. Required to enable bounded splitting (D2, PR11, ir.sampling.split_depth > 0) – omit (the default) on the Torch backend, which forces split_depth=0 and never spawns rays (fixed tensor shapes are required for the autograd graph).

Returns:

A new NSQRayBundle of transmit-branch children spawned by bounded splitting this bounce, or None if none were spawned (splitting disabled, no eligible hits, or ray_id_allocator was not given). The caller is responsible for merging this into the live bundle – see optiland.nonsequential.backends.array_backend.ArrayBackend.trace().

assert_bsdf_matches(bsdf: object | None, bsdf_ir: BsdfIR) None[source]#

Raise if a live BSDF’s type disagrees with its lowered BsdfIR.

Parameters:
  • bsdf – The live BSDF bsdf_ir was lowered from (or None).

  • bsdf_ir – The corresponding BsdfIR.

Raises:

RuntimeError – If the live BSDF’s type no longer matches what lower() recorded.

assert_component_kind_matches(component: BaseComponent, primitive: PrimitiveIR) None[source]#

Raise if a live component’s type disagrees with its lowered IR kind.

Cheap (no per-ray cost): only compares two short strings. Called once per hit primitive per bounce, never per ray.

Parameters:
  • component – The live component primitive was lowered from.

  • primitive – The corresponding PrimitiveIR.

Raises:

RuntimeError – If the live component’s interaction type no longer matches what lower() recorded – a lowering/interpreter drift bug, not a user configuration error.

lower(scene: NSQScene, *, strict: bool = True) SceneIR[source]#

Lower a live NSQScene to a data-only SceneIR.

Parameters:
  • scene – The scene to lower. Not mutated.

  • strict – Forwarded to _MediumRegistry.get_id() for every material referenced by a surface or a source. Defaults to True (every MediumIR must be losslessly identifiable – vacuum or catalog-backed), which is what JSON export and the translatability checklist require. The backends pass strict=False for the lower() call they make before every trace(): that IR only needs to drive dispatch (D-1 sidedness is resolved from geometry, not from a medium id – see optiland.nonsequential.components.refractive), so a custom, non-catalog material must not block tracing the way it would block serialization.

Returns:

The scene, described as plain data.

Raises:
  • TypeError – If the scene contains a component, BSDF, source, or detector type this revamp does not yet know how to lower.

  • ValueError – If strict and a material cannot be losslessly identified (mirrors NSQScene.to_json()’s existing limitation).

scene_ir_from_dict(d: dict) SceneIR[source]#

Reconstruct a SceneIR from a dict produced by scene_ir_to_dict().

Parameters:

d – Dict previously produced by scene_ir_to_dict().

Returns:

The reconstructed SceneIR.

scene_ir_to_dict(scene_ir: SceneIR) dict[source]#

Serialize a SceneIR to a JSON-safe dict, losslessly.

Parameters:

scene_ir – The IR to serialize.

Returns:

A dict built only from JSON-safe primitives, restorable via scene_ir_from_dict().

Counter-based PCG32 RNG for Non-Sequential Raytracing.

Every stochastic decision in the NSQ engine is a pure function of a key (seed, ray_id, bounce, event_slot[, offset]) – there is no shared mutable stream. This is what makes results bit-identical across batch_size, across NumPy compaction vs. Torch’s fixed-shape bundles, and across any two conforming backends: a ray’s random numbers depend only on its own identity, never on which other rays happen to be alive in the same batch or in what order components were visited.

Algorithm#

This is the standard O’Neill PCG32 (XSH-RR 64/32), the same generator used by Mitsuba 3 (pcg32.h) and satisfied by the per-launch-index counter-based PRNGs conventional in OptiX kernels:

state_{k+1} = state_k * MULT + inc (mod 2**64) output_k = xsh_rr(state_k) (32-bit)

inc (the odd-valued stream selector) is derived from (seed, ray_id, event_slot) via SplitMix64, so every ray gets its own independent stream per event slot. bounce (plus an optional offset for multi-draw slots such as rejection sampling) selects which output in that stream via PCG32’s jump-ahead identity – a closed-form function of the LCG step count, computed by the standard doubling algorithm in O(64) fixed iterations. This is what “counter derived arithmetically rather than by stateful advance” means: computing output number k never requires having computed outputs 0..k-1 first, and no RNG object needs to persist state between calls.

Honest scope of the guarantee: the random-number stream per (ray_id, bounce, event_slot) is bit-identical everywhere this module is used. Final float results are not guaranteed bit-identical across NumPy/Torch/CPU/GPU, because floating-point summation order and transcendental implementations differ – only the random decisions and the code path they select are guaranteed identical.

Kramer Harrison, 2026

class EventSlot(*values)[source]#

Bases: IntEnum

Discriminates independent PCG32 streams within one (ray, bounce).

Every stochastic decision draws from its own slot, so adding, removing, or reordering an unrelated decision can never perturb another decision’s stream (defect D-8: a shared, position-dependent stream).

BSDF_LOBE_BRANCH = 10#
BSDF_U1 = 7#
BSDF_U2 = 8#
FRESNEL_BRANCH = 5#
PATH_SAMPLE = 11#
RR = 9#
SCATTER_BRANCH = 6#
SOURCE_U1 = 0#
SOURCE_U2 = 1#
SOURCE_U3 = 2#
SOURCE_U4 = 3#
SOURCE_WAVELENGTH = 4#
class NSQRng(seed: int | None = None)[source]#

Bases: object

Keyed PCG32 RNG for one trace.

Unlike numpy.random.Generator, this carries no advancing internal state: every draw is a pure function of (seed, ray_id, bounce, event_slot), so the result never depends on batch_size, on whether the NumPy backend has compacted dead rays out of the bundle, or on the order in which scene components were visited.

Variables:

seed – Trace-level RNG seed (defaults to 0 if none was given, so a trace is always reproducible even when the user does not pass one explicitly).

uniform(ray_id: numpy.ndarray, bounce: numpy.ndarray, event_slot: int, offset: int = 0) numpy.ndarray[source]#

Draw one uniform float per ray, in [0, 1).

Parameters:
  • ray_id – Per-ray identifiers, shape (N,).

  • bounce – Per-ray bounce/step index, shape (N,) or scalar.

  • event_slotEventSlot value or plain int.

  • offset – See pcg32_uint32().

Returns:

float64 array in [0, 1), shape (N,).

pcg32_uint32(seed: int, ray_id: numpy.ndarray, bounce: numpy.ndarray, event_slot: int, offset: int = 0) numpy.ndarray[source]#

Draw one PCG32 32-bit output per key, as a pure function of the key.

Parameters:
  • seed – Trace-level RNG seed.

  • ray_id – Per-ray identifiers, shape (N,). Must be non-negative.

  • bounce – Per-ray bounce/step index, shape (N,) or a scalar broadcastable to (N,). Must be non-negative.

  • event_slot – Which independent stream within (ray_id, bounce) to draw from – an EventSlot value or plain int.

  • offset – Extra step count added to bounce for multi-draw slots (e.g. successive attempts in a rejection sampler) that need a fresh, deterministic value without consuming a new event slot.

Returns:

uint32 array, shape (N,).

pcg32_uniform(seed: int, ray_id: numpy.ndarray, bounce: numpy.ndarray, event_slot: int, offset: int = 0) numpy.ndarray[source]#

Draw one PCG32-derived uniform float per key, in [0, 1).

Parameters:
  • seed – Trace-level RNG seed.

  • ray_id – Per-ray identifiers, shape (N,).

  • bounce – Per-ray bounce/step index, shape (N,) or scalar.

  • event_slotEventSlot value or plain int.

  • offset – See pcg32_uint32().

Returns:

float64 array in [0, 1), shape (N,).

Serialization#

Versioned JSON serialization for NSQ scenes.

Provides scene_to_dict() and scene_from_dict() which convert an NSQScene to and from a plain JSON-serializable dict.

Schema version#

The top-level key "nsq_schema_version" is 1. NSQ has never been officially released, so there is exactly one schema and no compatibility or migration machinery for an earlier one: a file whose nsq_schema_version does not match the current loader is refused with a generic mismatch error naming both versions. Since NSQ is still pre-release, the physics and the schema can both change without notice; a scene built against an older checkout should be rebuilt from its original construction code (or converted again via sequential_to_nonsequential()) against the current API.

Tensor handling#

All PyTorch tensors are detached and serialized as plain Python floats or lists before writing. requires_grad is not persisted. A scene loaded from JSON is plain-valued; users must re-wrap parameters in torch.tensor(..., requires_grad=True) to enable differentiation after loading.

Coordinate systems#

Only the local (x, y, z, rx, ry, rz) components are serialized. Nested reference_cs chains are serialized recursively.

Materials#

String catalog names (e.g. 'N-BK7') round-trip as strings. NSQMaterial instances with an underlying optiland material round-trip via the catalog name stored on the material object. NSQMaterial vacuum (optiland_material=None) is serialized as null.

Not serialized#

  • SimulationResult / detector data

  • Ray databases

  • Mesh geometry file content (only the file path is stored)

Kramer Harrison, 2026

scene_from_dict(d: dict) NSQScene[source]#

Reconstruct an NSQScene from a serialized dict.

Validates "nsq_schema_version" before loading. A loaded scene is plain-valued; all parameters are plain Python floats, not tensors.

Parameters:

d – Dict previously produced by scene_to_dict() or read from a JSON file written by NSQScene.to_json().

Returns:

Reconstructed NSQScene.

Raises:
  • ValueError – If "nsq_schema_version" is missing or does not match NSQ_SCHEMA_VERSION. NSQ has never been officially released, so there is no compatibility mode or auto-migration for an older schema – a mismatched file must be rebuilt against the current API.

  • ValueError – If any component/source/detector type is unknown.

scene_from_json(path: str | os.PathLike) NSQScene[source]#

Load an NSQScene from a versioned JSON file.

This is the low-level implementation called by NSQScene.from_json().

Parameters:

path – Path to the JSON file previously written by scene_to_json() or NSQScene.to_json().

Returns:

Reconstructed NSQScene.

Raises:
scene_to_dict(scene: NSQScene) dict[source]#

Convert an NSQScene to a JSON-serializable dict.

The returned dict includes a top-level "nsq_schema_version" key. Simulation results and detector data are not included.

Parameters:

scene – The scene to serialize.

Returns:

JSON-serializable dict representing the scene structure.

Raises:
  • TypeError – If any component, source, or detector type is not supported.

  • ValueError – If any material cannot be round-tripped.

scene_to_json(scene: NSQScene, path: str | os.PathLike) None[source]#

Serialize an NSQScene to a versioned JSON file.

This is the low-level implementation called by NSQScene.to_json().

Parameters:
  • scene – The scene to serialize.

  • path – Destination file path (created or overwritten).

Visualization#

class NSQViewer2D(scene: NSQScene)[source]#

Bases: BaseViewer2D

2D cross-section viewer for non-sequential scenes.

Renders compound components, sources (as markers), and detectors onto a Matplotlib figure. Optionally overlays a random sample of ray paths when a SimulationResult is provided.

The renderer registry maps compound-component types to ComponentRenderer2D instances. Default renderers are registered for Lens, Mirror, and detectors.

Variables:
  • scene – The NSQScene to visualize.

  • _renderer_registry – Mapping from component class to renderer.

register_renderer(component_type: type, renderer) None[source]#

Register a custom 2D renderer for a compound-component type.

Parameters:
  • component_type – The class to bind the renderer to.

  • renderer – ComponentRenderer2D instance.

view(result: SimulationResult | None = None, *, theme=None, projection: str = 'YZ', num_rays: int = 100, figsize: tuple[int, int] | None = None, title: str | None = None, xlim: tuple | None = None, ylim: tuple | None = None, ax: Axes | None = None, color_by: str = 'source') tuple[Figure, Axes][source]#

Render the scene cross-section to a Matplotlib figure.

When result is provided and contains ray_paths, those paths are used for the ray overlay without running a new trace. If result is None (or its ray_paths is None) and num_rays > 0, a fresh trace is run internally.

Parameters:
  • result – Optional SimulationResult. If its ray_paths dict is populated it is used for the ray overlay directly.

  • theme – Optional theme object for colours and styles. If None, the active theme is used.

  • projection – Projection plane – 'YZ' (default), 'XZ', or 'XY'.

  • num_rays – Number of ray segments to sample and overlay. Defaults to 100. Pass 0 to skip ray drawing entirely.

  • figsize – Figure size override (width, height). None -> theme default.

  • title – Optional axes title. Defaults to "NSQ Scene -- 2D Cross-Section".

  • xlim – Optional (xmin, xmax) axis limits.

  • ylim – Optional (ymin, ymax) axis limits.

  • ax – Optional existing axes to plot into.

  • color_by – Ray colouring strategy – 'source' (default), 'bounce', or 'segment'.

Returns:

(fig, ax) tuple.

class NSQViewer3D(scene: NSQScene)[source]#

Bases: BaseViewer3D

3D VTK viewer for non-sequential scenes.

Renders compound components and detectors as VTK actors. Optionally overlays ray-path polylines when a SimulationResult with a RayDatabase is provided.

The renderer registry maps compound-component types to ComponentRenderer3D instances. Default renderers are registered for Lens, Mirror, and detectors.

Variables:
  • scene – The NSQScene to visualize.

  • _renderer_registry – Mapping from component class to renderer.

register_renderer(component_type: type, renderer) None[source]#

Register a custom 3D renderer for a compound-component type.

Parameters:
  • component_type – The class to bind the renderer to.

  • renderer – ComponentRenderer3D instance.

view(result: SimulationResult | None = None, *, num_rays: int = 100, dark_mode: bool = False, figsize: tuple[int, int] = (1200, 800), color_by: str = 'source') None[source]#

Render the scene in a VTK interactive window.

When result is provided and contains ray_paths, those paths are used for the ray overlay without running a new trace. If result is None (or its ray_paths is None) and num_rays > 0, a fresh trace is run internally.

Parameters:
  • result – Optional SimulationResult. If its ray_paths dict is populated it is used for the ray overlay directly.

  • num_rays – Number of ray segments to overlay. Pass 0 to skip ray drawing entirely.

  • dark_mode – Use a dark background if True.

  • figsize – (width, height) of the VTK window in pixels.

  • color_by – Ray colouring strategy – 'source' (default), 'bounce', or 'segment'.

Raises:

ImportError – If VTK is not installed.

Converter#

Sequential -> Non-Sequential Converter.

Provides sequential_to_nonsequential() to convert an Optic instance into a fully-populated NSQScene.

Differentiable-ready output#

The returned scene stores all geometric parameters as plain Python float values extracted from the sequential surfaces. This is intentional: the converter does not know which parameters the user wants to differentiate. To optimize a parameter with PyTorch, re-assign it as a tensor after conversion:

scene = sequential_to_nonsequential(optic)
# Access the config on the compound component:
cfg = scene.component_registry.get("L1")._config
cfg.r1 = torch.tensor(cfg.r1, requires_grad=True)

Kramer Harrison, 2026

exception ConversionError[source]#

Bases: Exception

Raised when a sequential surface or element cannot be converted to NSQ.

class ConversionReport(coated_surfaces: list[str] = <factory>, uncoated_surfaces: list[str] = <factory>, mirror_reflectance_defaulted: list[str] = <factory>, estimated_apertures: list[str] = <factory>, polarization_dropped: bool = False)[source]#

Bases: object

Structured record of what a conversion dropped or approximated.

Attached to the returned scene as scene.conversion_report rather than requiring the caller to parse warning text – the whole point of this class is that “what did the converter have to guess or drop” is inspectable data, not something a user has to have watched the log for.

Variables:
  • coated_surfaces (list[str]) – Names of refractive surfaces whose sequential unpolarized coating was carried over to SurfaceConfig.coating (so NSQ and the sequential engine agree on R).

  • uncoated_surfaces (list[str]) – Names of refractive surfaces with no usable coating in the sequential system; these get bare Fresnel reflection/refraction in NSQ, which the sequential engine never applies (transmission is always 100% there).

  • mirror_reflectance_defaulted (list[str]) – Names of mirrors with no usable scalar reflectance in the sequential system, defaulted to a perfect reflector (R=1.0) – NSQ has no implicit-mirror default, so the converter must supply something, and a perfect reflector is the most visible (least silently-wrong) choice.

  • estimated_apertures (list[str]) – Names of surfaces whose aperture radius was not explicitly set in the sequential system and had to be estimated from paraxial ray heights (or, failing that, a fixed 10 mm fallback) rather than read directly.

  • polarization_dropped (bool) – True if any sequential surface had a polarization-sensitive (Jones-matrix) coating – NSQ rays carry no polarization state, so these are dropped entirely, not approximated.

coated_surfaces: list[str]#
estimated_apertures: list[str]#
mirror_reflectance_defaulted: list[str]#
polarization_dropped: bool = False#
summary() str[source]#

Human-readable multi-line summary of everything dropped/approximated.

Returns:

A multi-line string, or a single “nothing dropped” line if the conversion was fully faithful.

uncoated_surfaces: list[str]#
sequential_to_nonsequential(optic: Optic, *, num_rays: int = 1000, detector_width: float | None = None, detector_height: float | None = None, detector_pixels: tuple[int, int] = (512, 512), beam_diameter: float | None = None, half_angle_deg: float | None = None) NSQScene[source]#

Convert a sequential Optic to a non-sequential NSQScene.

The image surface is converted to an IrradianceDetector. Each sequential field is mapped to one NSQ source (CollimatedSource for angle fields, PointSource for object-height fields). Consecutive refractive surfaces are grouped into Lens or Doublet compound components.

Parameters:
  • optic – Sequential optical system to convert.

  • num_rays – Default ray count for visualization sources. Does not affect scene.trace() – pass num_rays there directly.

  • detector_width – Semi-width of the image detector [mm]. Defaults to 2x the paraxial image height.

  • detector_height – Semi-height of the image detector [mm]. Defaults to detector_width.

  • detector_pixels – (num_pixels_x, num_pixels_y) for the irradiance detector.

  • beam_diameter – Override entrance pupil diameter for collimated sources [mm]. Defaults to paraxial EPD.

  • half_angle_deg – Override cone half-angle for point sources [degrees]. Defaults to paraxial marginal-ray angle at the object plane.

Returns:

NSQScene populated with lens/mirror components, sources, and a detector. scene.conversion_report is a ConversionReport listing everything the converter dropped or had to approximate – coatings, apertures, mirror reflectance, polarization – as structured data rather than only warning text.

Raises:

ConversionError – If any surface has an unsupported geometry type (coordinate breaks, diffraction gratings, NURBS, Zernike freeforms, or lens elements with more than 3 surfaces).

Warns:

UserWarning – NSQ surfaces with no carried-over coating apply bare Fresnel reflection/refraction; sequential surfaces always transmit. See scene.conversion_report.uncoated_surfaces for exactly which surfaces this applies to.

Configuration reference#

Scenes are built by passing a *Config dataclass to the corresponding scene.add_* builder. The tables below list every field, its type, units, and default. Differentiable fields are typed float | Tensor: under the PyTorch backend they may be passed as torch.Tensor leaves to receive gradients via loss.backward().

Conventions: wavelengths in micrometres (µm), lengths/positions in millimetres (mm), angles in degrees where a field name ends in _deg, otherwise radians.

Sources#

PointSourceConfig — infinitesimal point emitting into a cone.

Field

Type

Default

Meaning

spectrum

Spectrum

(required)

Wavelength distribution (µm).

total_flux

float | Tensor

1.0

Total emitted flux [W].

half_angle_deg

float

90.0

Emission cone half-angle [deg]; 90 = hemisphere, 180 = isotropic.

medium

NSQMaterial | None

None

Embedding medium (default vacuum).

CollimatedSourceConfig — parallel beam.

Field

Type

Default

Meaning

spectrum

Spectrum

(required)

Wavelength distribution (µm).

total_flux

float | Tensor

1.0

Total emitted flux [W].

aperture_radius

float | Tensor

1.0

Beam semi-diameter [mm].

profile

str

"tophat"

Spatial profile: "tophat" or "gaussian".

gaussian_sigma

float | None

None

Gaussian sigma [mm]; defaults to aperture_radius / 2.

medium

NSQMaterial | None

None

Embedding medium (default vacuum).

ExtendedSourceConfig — spatially + angularly extended emitter.

Field

Type

Default

Meaning

spectrum

Spectrum

(required)

Wavelength distribution (µm).

total_flux

float | Tensor

1.0

Total emitted flux [W].

width

float | Tensor

1.0

Source width [mm].

height

float | Tensor

1.0

Source height [mm].

aperture_radius

float | None

None

Circular aperture radius [mm]; overrides width/height when set.

half_angle_deg

float

90.0

Emission cone half-angle [deg]. Rays are uniform within the cone below 90; at 90 and above they are cosine-weighted over the full hemisphere (Lambertian), and values above 90 behave as 90.

medium

NSQMaterial | None

None

Embedding medium (default vacuum).

Components#

LensConfig — single refractive element (up to four surfaces).

Field

Type

Default

Meaning

r1

float | Tensor

(required)

Front vertex radius of curvature [mm]; + = centre of curvature on +z.

r2

float | Tensor

(required)

Back vertex radius of curvature [mm].

thickness

float | Tensor

(required)

Centre thickness [mm].

material

str | NSQMaterial

(required)

Glass name (e.g. "N-BK7") or an NSQMaterial instance.

front_aperture_radius

float | Tensor

(required)

Front-face semi-diameter [mm].

back_aperture_radius

float | None

None

Back-face semi-diameter [mm]; defaults to front_aperture_radius.

conic1

float | Tensor

0.0

Front-face conic constant (0 = sphere).

conic2

float | Tensor

0.0

Back-face conic constant.

front / back / edge / rim

SurfaceConfig | None

None

Per-surface overrides (see SurfaceConfig).

DoubletConfig — cemented achromatic doublet.

Field

Type

Default

Meaning

r1

float | Tensor

(required)

Front radius of curvature [mm].

r2

float | Tensor

(required)

Cemented-interface radius of curvature [mm].

r3

float | Tensor

(required)

Back radius of curvature [mm].

thickness1

float | Tensor

(required)

Crown element thickness [mm].

thickness2

float | Tensor

(required)

Flint element thickness [mm].

material1 / material2

str | NSQMaterial

(required)

Crown / flint glass name or NSQMaterial.

aperture_radius

float | Tensor

(required)

Common semi-diameter for all surfaces [mm].

conic1 / conic2 / conic3

float | Tensor

0.0

Conic constants of front / cemented / back faces.

front / cemented / back / edge

SurfaceConfig | None

None

Per-surface overrides.

MirrorConfig — single reflective surface.

Field

Type

Default

Meaning

radius

float | Tensor

(required)

Vertex radius of curvature [mm]; − = concave (normal toward +z).

reflectance

float | Callable | BaseCoating

(required)

Mirror reflectance: a constant in [0, 1], a callable(wavelength_um) -> reflectance, or an unpolarized optiland.coatings coating. No implicit default — a mirror built without one raises rather than reflecting 100%.

conic

float | Tensor

0.0

Conic constant (0 = sphere, −1 = paraboloid).

aperture_radius

float | Tensor

25.0

Semi-diameter [mm].

surface

SurfaceConfig | None

None

Per-surface override (e.g. attach a scatter BSDF or override reflectance).

SurfaceConfig — optional per-surface overrides within a compound component. All fields default to None (use the compound-level default).

Field

Type

Default

Meaning

bsdf

BaseBSDF | None

None

Scatter model. Rays routed to it are scattered instead of being refracted or specularly reflected.

scatter_fraction

float

1.0

Probability that a ray striking this surface is routed through bsdf rather than following the specular/refractive path. The default of 1.0 makes the surface a pure diffuser; use a smaller value for a partially scattering surface. Ignored when bsdf is None.

coating

BaseCoating | None

None

An optiland.coatings coating (e.g. an AR coating) for a refractive surface; its reflectance/transmittance replace bare Fresnel, so NSQ and the sequential engine agree on R. Must be unpolarized (SimpleCoating) — a polarization-sensitive coating (BaseCoatingPolarized) raises rather than being silently degraded to its scalar average. Ignored on absorbing surfaces.

aperture_radius

float | None

None

Semi-diameter override [mm].

interaction

InteractionType | None

None

Force a specific interaction (REFRACTIVE / REFLECTIVE / ABSORBING).

reflectance

float | Callable | BaseCoating | None

None

Required when interaction selects InteractionType.REFLECTIVE: overrides the compound’s MirrorConfig.reflectance for this surface only.

Detectors#

IrradianceDetectorConfig — 2-D spatial flux map.

Field

Type

Default

Meaning

width / height

float

(required)

Detector extent [mm].

num_pixels_x / num_pixels_y

int

256

Pixel grid resolution.

splat

"bilinear" | "gaussian" | "hard"

"bilinear"

Splatting mode. bilinear is differentiable in landing position; hard is nearest-pixel (not differentiable in landing position); gaussian is a true, differentiable Gaussian splat of width splat_sigma.

splat_sigma

float

0.5

Gaussian splat sigma in pixels (used when splat="gaussian").

absorb

bool

True

Whether a hit terminates the ray. False makes the detector transmissive: the hit is recorded and the ray continues unchanged, enabling mid-system beam sampling.

SpectralDetectorConfig — per-wavelength irradiance.

Field

Type

Default

Meaning

width / height

float

(required)

Detector extent [mm].

num_pixels_x / num_pixels_y

int

256

Pixel grid resolution.

wl_min / wl_max

float

0.4 / 0.7

Spectral binning range [µm] (same convention as Spectrum — visible light is 0.4-0.7).

num_bins

int

100

Number of wavelength bins.

splat

"bilinear" | "gaussian" | "hard"

"bilinear"

Only hard binning is currently implemented for this detector.

splat_sigma

float

0.5

Reserved for future use.

absorb

bool

True

Whether a hit terminates the ray.

FarFieldDetectorConfig — angular intensity pattern.

Field

Type

Default

Meaning

num_theta

int

90

Number of polar-angle bins.

num_phi

int

360

Number of azimuthal-angle bins.

absorb

bool

True

Whether a hit terminates the ray.

RayDatabaseConfig — full phase-space record.

Field

Type

Default

Meaning

width / height

float

(required)

Detector extent [mm].

max_rays

int

0

Maximum rays to store (0 = unlimited).

absorb

bool

True

Whether a hit terminates the ray.