Simulation Workflow
This guide explains how to set up and run ray tracing simulations with L-SURF.
Overview
A typical L-SURF simulation involves:
Define materials - Create or use built-in materials
Define surfaces - Create surfaces with roles (optical, detector, absorber)
Build geometry - Use GeometryBuilder to assemble the simulation
Configure simulation - Set propagation and termination parameters
Generate rays - Create initial ray batch from a source
Run simulation - Execute ray tracing
Analyze results - Process detected rays and statistics
Step 1: Define Materials
L-SURF provides built-in materials and supports custom materials:
import lsurf as sr
# Built-in homogeneous materials
air = sr.AIR_STP # n ≈ 1.000293
water = sr.WATER # n ≈ 1.33
glass = sr.BK7_GLASS # n ≈ 1.52
vacuum = sr.VACUUM # n = 1.0
# Gradient-index atmosphere (height-dependent n)
atmosphere = sr.ExponentialAtmosphere(
n_sea_level=1.000293,
scale_height=8500.0, # meters
)
# Custom Sellmeier material
custom_glass = sr.create_sellmeier_material(
B1=1.03961212, B2=0.23179234, B3=1.01046945,
C1=0.00600069867, C2=0.02001791, C3=103.560653,
)
Step 2: Define Surfaces
Surfaces define the geometry and have roles that determine behavior:
from lsurf.surfaces import (
PlaneSurface,
SphereSurface,
SurfaceRole,
EARTH_RADIUS,
)
# Optical surface: reflects and refracts rays
ocean = SphereSurface(
center=(0, 0, -EARTH_RADIUS),
radius=EARTH_RADIUS,
role=SurfaceRole.OPTICAL,
name="ocean",
)
# Detector surface: records and terminates rays
detector = PlaneSurface(
point=(0, 0, 35000),
normal=(0, 0, 1),
role=SurfaceRole.DETECTOR,
name="detector_35km",
)
# Absorber surface: terminates rays without recording
boundary = SphereSurface(
center=(0, 0, 0),
radius=500000,
role=SurfaceRole.ABSORBER,
name="outer_boundary",
)
Surface Roles
Role |
Behavior |
Use Case |
|---|---|---|
|
Fresnel reflection/refraction |
Physical interfaces (water, glass) |
|
Record and terminate rays |
Sensors, measurement planes |
|
Terminate without recording |
Boundaries, black surfaces |
Step 3: Build Geometry
Use GeometryBuilder to assemble surfaces with consistent materials:
from lsurf.geometry import GeometryBuilder
geometry = (
GeometryBuilder()
# Register named media
.register_medium("atmosphere", atmosphere)
.register_medium("ocean", water)
# Set background propagation medium
.set_background("atmosphere")
# Add optical surface with front/back materials
.add_surface(ocean, front="atmosphere", back="ocean")
# Add detector (no materials needed)
.add_detector(detector)
# Build immutable geometry
.build()
)
The builder ensures:
Material consistency across surfaces
Validation of surface configurations
Immutable geometry during simulation
Step 4: Configure Simulation
SimulationConfig controls simulation behavior:
from lsurf.simulation import SimulationConfig
config = SimulationConfig(
# Propagation
step_size=100.0, # Max step (meters)
min_step_size=3e-4, # Min step for precision (0.3mm)
adaptive_stepping=True, # Reduce steps near surfaces
max_steps_per_leg=10000, # Steps before forced surface check
# Termination
max_bounces=5, # Max surface interactions
min_intensity=1e-10, # Terminate weak rays
bounding_radius=500000.0, # Terminate outside this radius
# Physics
polarization="unpolarized", # 's', 'p', or 'unpolarized'
apply_absorption=True, # Beer-Lambert absorption
# Performance
use_gpu=True, # GPU acceleration if available
)
Key Parameters
- step_size and min_step_size
Control the balance between speed and precision. Larger steps are faster but may miss fine surface details. Adaptive stepping automatically reduces step size near surfaces.
- max_bounces
Limits simulation depth. Set high enough to capture all relevant reflections but low enough to avoid excessive computation.
- bounding_radius
Defines the simulation domain. Rays escaping this radius are terminated.
Step 5: Generate Rays
Create initial rays from a source:
import lsurf as sr
# Collimated beam (parallel rays)
source = sr.CollimatedBeam(
center=(0, 0, 1000), # Beam center position
direction=(0.17, 0, -0.98), # Propagation direction
radius=10.0, # Beam radius
num_rays=50000, # Number of rays
wavelength=532e-9, # Wavelength (meters)
)
rays = source.generate()
# Diverging beam (cone of rays)
source = sr.DivergingBeam(
origin=(0, 0, 1000),
direction=(0, 0, -1),
half_angle=0.017, # radians (~1°)
num_rays=10000,
wavelength=532e-9,
)
# Point source (spherical emission)
source = sr.PointSource(
position=(0, 0, 1000),
num_rays=10000,
wavelength=532e-9,
)
Step 6: Run Simulation
Execute the simulation:
from lsurf.simulation import Simulation
# Create and run simulation
sim = Simulation(geometry, config)
result = sim.run(rays)
Enable logging to monitor progress:
import lsurf as sr
sr.configure_logging("INFO") # Show simulation summaries
# sr.configure_logging("DEBUG") # Show per-bounce details
result = sim.run(rays)
Step 7: Analyze Results
The SimulationResult contains all simulation outputs:
# Detected rays
detected = result.detected
print(f"Detected {detected.num_rays} rays")
print(f"Positions shape: {detected.positions.shape}")
print(f"Times: {detected.times[:5]}")
print(f"Intensities: {detected.intensities[:5]}")
# Statistics
stats = result.statistics
print(f"Initial rays: {stats.total_rays_initial}")
print(f"Created (with splits): {stats.total_rays_created}")
print(f"Detected: {stats.rays_detected}")
print(f"Absorbed: {stats.rays_absorbed}")
print(f"Out of bounds: {stats.rays_terminated_bounds}")
print(f"Below threshold: {stats.rays_terminated_intensity}")
print(f"Bounces completed: {stats.bounces_completed}")
# Per-detector breakdown
for name, count in result.detections_per_surface.items():
print(f" {name}: {count} hits")
Detected Ray Data
The detected object (type RecordedRays) contains:
positions- (N, 3) array of hit positionsdirections- (N, 3) array of ray directions at hittimes- (N,) array of arrival times (seconds)intensities- (N,) array of ray intensitieswavelengths- (N,) array of wavelengthsgenerations- (N,) array of ray generation (bounce count)
Complete Example
import lsurf as sr
from lsurf.geometry import GeometryBuilder
from lsurf.simulation import Simulation, SimulationConfig
from lsurf.surfaces import SphereSurface, PlaneSurface, SurfaceRole, EARTH_RADIUS
# Enable logging
sr.configure_logging("INFO")
# Materials
atmosphere = sr.ExponentialAtmosphere(n_sea_level=1.000293)
# Surfaces
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",
)
# Build geometry
geometry = (
GeometryBuilder()
.register_medium("atmosphere", atmosphere)
.register_medium("ocean", sr.WATER)
.set_background("atmosphere")
.add_surface(ocean, front="atmosphere", back="ocean")
.add_detector(detector)
.build()
)
# Configure
config = SimulationConfig(
step_size=100.0,
max_bounces=3,
adaptive_stepping=True,
)
# Run
sim = Simulation(geometry, config)
source = sr.CollimatedBeam(
center=(0, 0, 2000),
direction=(0.17, 0, -0.98),
radius=10.0,
num_rays=10000,
wavelength=532e-9,
)
result = sim.run(source.generate())
# Results
print(f"Detected: {result.statistics.rays_detected}")
print(f"Detection times: {result.detected.times[:5]}")
Advanced Topics
GPU vs CPU Execution
L-SURF automatically uses GPU when available:
# Force CPU execution
config = SimulationConfig(use_gpu=False)
# Check if GPU is available
from numba import cuda
print(f"GPU available: {cuda.is_available()}")
Adaptive Stepping
Adaptive stepping provides high timing precision near surfaces while maintaining efficiency during long propagation paths:
config = SimulationConfig(
step_size=100.0, # Far from surfaces: 100m steps
min_step_size=3e-4, # Near surfaces: 0.3mm steps
adaptive_stepping=True,
surface_proximity_threshold=10.0, # Start adapting within 10m
surface_proximity_factor=0.5, # step = distance × 0.5
)
This provides approximately 1 picosecond time resolution at surfaces.
Polarization
Enable polarization tracking for accurate Fresnel calculations:
# Single polarization state
config = SimulationConfig(polarization="s") # or "p"
# Unpolarized (average of s and p)
config = SimulationConfig(polarization="unpolarized")
# Track 3D polarization vectors
config = SimulationConfig(
polarization="s",
track_polarization_vector=True,
)
Surface Hit Tracking
Track intermediate surface hits for visualization:
config = SimulationConfig(
track_surface_hits=True,
)
result = sim.run(rays)
# Access intermediate hits
if result.surface_hits:
for surface_name, hits in result.surface_hits.items():
print(f"{surface_name}: {len(hits)} hit records")