# The Clear BSD License
#
# Copyright (c) 2026 Tobias Heibges
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted (subject to the limitations in the disclaimer
# below) provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
#
# * Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
#
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
"""
Ray Tracing Simulation
The Simulation class uses a Geometry object built via GeometryBuilder
as its first step, ensuring:
- Material consistency across surfaces via named media
- Validation of surface configurations
- Immutable geometry during simulation
"""
from __future__ import annotations
import logging
import warnings
from typing import TYPE_CHECKING
from ..geometry import Geometry
from ..surfaces import SurfaceRole
from .config import SimulationConfig
from .orchestrator import SimulationOrchestrator
from .result import SimulationResult
if TYPE_CHECKING:
from ..utilities.ray_data import RayBatch
logger = logging.getLogger(__name__)
[docs]
class Simulation:
"""
Ray tracing simulation with geometry-based configuration.
The simulation takes a pre-built Geometry object which defines:
- All optical surfaces with their materials
- All detector surfaces
- The background propagation medium
- Named media for material consistency
Parameters
----------
geometry : Geometry
Pre-built geometry from GeometryBuilder containing all surfaces,
detectors, and materials.
config : SimulationConfig, optional
Simulation configuration. Uses defaults if not provided.
Examples
--------
>>> from lsurf.geometry import GeometryBuilder
>>> from lsurf.materials import LinsleyAtmosphere, WATER
>>> from lsurf.surfaces import SphereSurface, PlaneSurface, SurfaceRole
>>> from lsurf.simulation import Simulation, SimulationConfig
>>>
>>> # Build geometry
>>> EARTH_RADIUS = 6.371e6
>>> atmosphere = LinsleyAtmosphere()
>>>
>>> ocean = SphereSurface(
... center=(0, 0, -EARTH_RADIUS),
... radius=EARTH_RADIUS,
... role=SurfaceRole.OPTICAL,
... name="ocean",
... )
>>> detector = PlaneSurface(
... point=(0, 0, 35000),
... normal=(0, 0, 1),
... role=SurfaceRole.DETECTOR,
... name="detector_35km",
... )
>>>
>>> geometry = (
... GeometryBuilder()
... .register_medium("atmosphere", atmosphere)
... .register_medium("ocean", WATER)
... .set_background("atmosphere")
... .add_surface(ocean, front="atmosphere", back="ocean")
... .add_detector(detector)
... .build()
... )
>>>
>>> # Create simulation with geometry
>>> config = SimulationConfig(step_size=100.0, max_bounces=5)
>>> sim = Simulation(geometry, config)
>>> result = sim.run(rays)
>>> print(f"Detected: {result.statistics.rays_detected}")
"""
[docs]
def __init__(
self,
geometry: Geometry,
config: SimulationConfig | None = None,
):
self._geometry = geometry
self._config = config if config is not None else SimulationConfig()
# Extract surfaces list (surfaces + detectors)
self._all_surfaces = geometry.to_surface_list()
# Build surface indices by role for efficient processing
self._detector_indices: list[int] = []
self._optical_indices: list[int] = []
self._absorber_indices: list[int] = []
for i, surface in enumerate(self._all_surfaces):
if surface.role == SurfaceRole.DETECTOR:
self._detector_indices.append(i)
elif surface.role == SurfaceRole.OPTICAL:
self._optical_indices.append(i)
elif surface.role == SurfaceRole.ABSORBER:
self._absorber_indices.append(i)
# Create orchestrator lazily
self._orchestrator: SimulationOrchestrator | None = None
logger.info(
"Simulation initialized: %d optical, %d detector, %d absorber surfaces",
len(self._optical_indices),
len(self._detector_indices),
len(self._absorber_indices),
)
@property
def geometry(self) -> Geometry:
"""The simulation geometry."""
return self._geometry
@property
def config(self) -> SimulationConfig:
"""The simulation configuration."""
return self._config
@property
def num_surfaces(self) -> int:
"""Total number of surfaces (optical + absorber + detector)."""
return len(self._all_surfaces)
@property
def detector_surfaces(self) -> list:
"""List of detector surfaces."""
return [self._all_surfaces[i] for i in self._detector_indices]
@property
def optical_surfaces(self) -> list:
"""List of optical surfaces."""
return [self._all_surfaces[i] for i in self._optical_indices]
@property
def absorber_surfaces(self) -> list:
"""List of absorber surfaces."""
return [self._all_surfaces[i] for i in self._absorber_indices]
def _get_orchestrator(self) -> SimulationOrchestrator:
"""Get or create the simulation orchestrator."""
if self._orchestrator is None:
self._orchestrator = SimulationOrchestrator(
geometry=self._geometry,
config=self._config,
)
return self._orchestrator
[docs]
def run(self, rays: "RayBatch") -> SimulationResult:
"""
Run the ray tracing simulation.
Parameters
----------
rays : RayBatch
Initial rays to trace.
Returns
-------
SimulationResult
Complete simulation results including:
- detected: RecordedRays from detector surfaces
- remaining: RayBatch of rays still active
- statistics: SimulationStatistics with counts
- detections_per_surface: dict mapping detector names to hit counts
Examples
--------
>>> result = sim.run(rays)
>>> print(f"Detected {result.statistics.rays_detected} rays")
>>> print(f"Absorbed {result.statistics.rays_absorbed} rays")
>>> for name, count in result.detections_per_surface.items():
... print(f" {name}: {count} hits")
"""
orchestrator = self._get_orchestrator()
# Suppress Numba GPU under-utilization warnings (common with small batches)
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
message=".*Grid size.*GPU under-utilization.*",
category=UserWarning,
)
return orchestrator.run(rays)
[docs]
def run_single_bounce(
self,
rays: "RayBatch",
) -> tuple["RayBatch", SimulationResult]:
"""
Run a single propagation + interaction cycle.
Useful for step-by-step debugging or custom simulation loops.
Parameters
----------
rays : RayBatch
Rays to propagate.
Returns
-------
continuing_rays : RayBatch
Rays that should continue (reflected, refracted, no-hit).
result : SimulationResult
Results from this single bounce (detections, absorptions).
"""
orchestrator = self._get_orchestrator()
# Suppress Numba GPU under-utilization warnings (common with small batches)
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
message=".*Grid size.*GPU under-utilization.*",
category=UserWarning,
)
return orchestrator.run_single_bounce(rays)