Source code for optiland.nonsequential.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
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from optiland.nonsequential.scene import NSQScene

# Warn when flux killed by the hard max_depth cutoff exceeds this fraction
# of total launched flux: the result is truncated and max_depth should be
# raised, or the scene is leaking flux into paths this deep on purpose and
# the user should know that.
_DEPTH_TRUNCATION_WARN = 1e-3

# Warn when the flux ledger (in - detected - absorbed - bulk - escaped -
# lost) fails to close by more than this fraction of total_flux_in. Under
# reflect_prob="fresnel" (the default) this should be ~machine epsilon;
# under importance biasing or heavy roulette it is Monte Carlo noise that
# shrinks with ray count, so this threshold is deliberately loose enough
# to tolerate that while still catching a genuine energy leak.
_FLUX_CONSERVATION_WARN = 0.05

# Warn when Russian-roulette-killed flux exceeds this fraction of
# total launched flux. Roulette is unbiased in expectation, so a nonzero
# value is not itself wrong, but a large one means rr_start_flux is
# aggressive relative to this scene and the per-trace estimator is noisy.
_RR_KILLED_WARN = 1e-3

# Below this many mean hits per detector pixel, shot noise dominates the
# map (relative Poisson error 1/sqrt(hits) ~= 32% at 10 hits).
_UNDERSAMPLED_HITS_PER_PIXEL = 10.0

# Hits/pixel needed for ~5% relative Poisson error (1 / 0.05**2), used to
# estimate the ray count a target-quality map would need.
_TARGET_HITS_PER_PIXEL = 400.0


[docs] @dataclass(frozen=True) class DetectorDiagnostic: """Per-detector sampling-quality diagnostic. Attributes: name: Detector's registry name. num_rays_hit: Rays recorded on this detector. num_pixels: Pixel/bin count of the detector's map, or ``None`` for detector kinds with no grid (e.g. ``RayDatabaseDetector``). mean_hits_per_pixel: ``num_rays_hit / num_pixels``, or ``None`` when ``num_pixels`` is ``None``. undersampled: True when ``mean_hits_per_pixel`` is below :data:`_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: 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. """ name: str num_rays_hit: int num_pixels: int | None mean_hits_per_pixel: float | None undersampled: bool rays_needed_for_5pct: int | None
[docs] @dataclass(frozen=True) class Diagnostics: """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. Attributes: depth_truncated_flux_fraction: 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: 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: Copied from :attr:`~optiland.nonsequential.tracer.SimulationResult.flux_conservation_error` for convenience -- see that field's docstring. unreached_geometry: 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: Per-detector sampling-quality diagnostics, in ``scene.detectors`` order. medium_stack_underflows: 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: 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 rr_killed_flux_fraction: float = 0.0 flux_conservation_error: float = 0.0 unreached_geometry: tuple[str, ...] = () detectors: tuple[DetectorDiagnostic, ...] = field(default_factory=tuple) medium_stack_underflows: int = 0 split_budget_saturated: bool = False
[docs] def warnings(self) -> list[str]: """Human-readable warnings for every diagnostic past its threshold. Returns: A list of one-line warning strings; empty if nothing is flagged. """ msgs: list[str] = [] if self.depth_truncated_flux_fraction > _DEPTH_TRUNCATION_WARN: msgs.append( f"{self.depth_truncated_flux_fraction:.2%} of launched flux " "was truncated by max_depth -- results are incomplete for " "paths deeper than max_depth; raise it if those paths matter." ) if self.flux_conservation_error > _FLUX_CONSERVATION_WARN: msgs.append( f"flux_conservation_error is {self.flux_conservation_error:.2%} " "-- higher than expected Monte Carlo noise; check for an " "energy-accounting bug before trusting this trace." ) if self.rr_killed_flux_fraction > _RR_KILLED_WARN: msgs.append( f"Russian roulette killed {self.rr_killed_flux_fraction:.2%} " "of launched flux -- consider a lower rr_start_flux if this " "trace's variance looks high." ) if self.unreached_geometry: names = ", ".join(self.unreached_geometry) msgs.append( f"{len(self.unreached_geometry)} component(s) were never hit " f"by any ray: {names}. Check placement/orientation, or " "ignore if intentionally unused." ) for det in self.detectors: if det.undersampled: needed = ( f"; ~{det.rays_needed_for_5pct:,} rays would bring it to " "~5% relative error" if det.rays_needed_for_5pct is not None else "" ) msgs.append( f"Detector '{det.name}' is undersampled: " f"{det.mean_hits_per_pixel:.1f} mean hits/pixel " f"(< {_UNDERSAMPLED_HITS_PER_PIXEL:g}), shot noise " f"dominates the map{needed}." ) if self.medium_stack_underflows: msgs.append( f"{self.medium_stack_underflows} medium-stack underflow(s) " "detected -- a ray exited a volume it never entered; this " "indicates a geometry defect." ) if self.split_budget_saturated: msgs.append( "Bounded splitting hit its split_budget cap at least once " "-- some spawned ghost-path rays were roulette-terminated " "rather than kept; raise split_budget for lower variance." ) return msgs
[docs] def report(self) -> str: """Full human-readable diagnostic report. Returns: A multi-line string: every diagnostic value, followed by a "Warnings" section (or "No warnings." if none fired). """ lines = [ "NSQ trace diagnostics:", f" depth_truncated_flux_fraction: " f"{self.depth_truncated_flux_fraction:.4%}", f" rr_killed_flux_fraction: {self.rr_killed_flux_fraction:.4%}", f" flux_conservation_error: {self.flux_conservation_error:.4%}", f" unreached_geometry: " f"{list(self.unreached_geometry) if self.unreached_geometry else 'none'}", f" medium_stack_underflows: {self.medium_stack_underflows}", f" split_budget_saturated: {self.split_budget_saturated}", ] if self.detectors: lines.append(" detectors:") for det in self.detectors: mh = ( f"{det.mean_hits_per_pixel:.2f}" if det.mean_hits_per_pixel is not None else "n/a" ) lines.append( f" {det.name}: {det.num_rays_hit} hits, " f"{mh} mean hits/pixel" + (" [undersampled]" if det.undersampled else "") ) warns = self.warnings() if warns: lines.append("Warnings:") lines.extend(f" - {w}" for w in warns) else: lines.append("No warnings.") return "\n".join(lines)
def _detector_pixel_count(result: object) -> int | None: """Pixel/bin count of a detector result, or ``None`` if it has no grid. Args: result: A detector's ``get_result()`` output (``IrradianceMap``, ``SpectralResult``, ``FarFieldPattern``, or ``RayDatabase``). Returns: ``len(x) * len(y)`` for a spatial grid (``x_coords``/``y_coords``) or an angular grid (``theta``/``phi``); ``None`` for a result with neither (``RayDatabase``). """ x = getattr(result, "x_coords", None) y = getattr(result, "y_coords", None) if x is not None and y is not None: return len(x) * len(y) theta = getattr(result, "theta", None) phi = getattr(result, "phi", None) if theta is not None and phi is not None: return len(theta) * len(phi) return None def _detector_diagnostic( name: str, result: object, num_rays_total: int ) -> DetectorDiagnostic: """Build one :class:`DetectorDiagnostic` from a detector's result. Args: name: Detector's registry name. result: The detector's ``get_result()`` output. num_rays_total: Total rays launched this trace (for the ``rays_needed_for_5pct`` estimate). Returns: The diagnostic. """ num_rays_hit = int(getattr(result, "num_rays_hit", 0)) num_pixels = _detector_pixel_count(result) if not num_pixels: return DetectorDiagnostic( name=name, num_rays_hit=num_rays_hit, num_pixels=num_pixels, mean_hits_per_pixel=None, undersampled=False, rays_needed_for_5pct=None, ) mean_hits = num_rays_hit / num_pixels undersampled = mean_hits < _UNDERSAMPLED_HITS_PER_PIXEL rays_needed = ( int(num_rays_total * (_TARGET_HITS_PER_PIXEL / mean_hits)) if mean_hits > 0 else None ) return DetectorDiagnostic( name=name, num_rays_hit=num_rays_hit, num_pixels=num_pixels, mean_hits_per_pixel=mean_hits, undersampled=undersampled, rays_needed_for_5pct=rays_needed, )
[docs] def 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: """Assemble a :class:`Diagnostics` from one trace's bookkeeping. Shared by both reference backends so the diagnostic definitions cannot drift between them. Args: 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 :class:`Diagnostics`). Returns: The assembled diagnostics. """ unreached = tuple( (c.name or f"component_{i}") for i, c in enumerate(scene.surfaces) if i not in hit_component_ids ) detector_diags = tuple( _detector_diagnostic(name, result, num_rays_total) for name, result in detector_results.items() ) depth_frac = total_flux_depth_killed / total_flux_in if total_flux_in > 0 else 0.0 rr_frac = total_flux_rr_killed / total_flux_in if total_flux_in > 0 else 0.0 return Diagnostics( depth_truncated_flux_fraction=depth_frac, rr_killed_flux_fraction=rr_frac, flux_conservation_error=flux_conservation_error, unreached_geometry=unreached, detectors=detector_diags, medium_stack_underflows=medium_stack_underflows, split_budget_saturated=split_budget_saturated, )