lsurf 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lsurf/__init__.py +471 -0
- lsurf/analysis/__init__.py +107 -0
- lsurf/analysis/healpix_utils.py +418 -0
- lsurf/analysis/sphere_viz.py +1280 -0
- lsurf/cli/__init__.py +48 -0
- lsurf/cli/build.py +398 -0
- lsurf/cli/config_schema.py +318 -0
- lsurf/cli/gui_cmd.py +76 -0
- lsurf/cli/interactive.py +850 -0
- lsurf/cli/main.py +81 -0
- lsurf/cli/run.py +806 -0
- lsurf/detectors/__init__.py +266 -0
- lsurf/detectors/analysis.py +289 -0
- lsurf/detectors/base.py +284 -0
- lsurf/detectors/constant_size_rings.py +485 -0
- lsurf/detectors/directional.py +45 -0
- lsurf/detectors/extended/__init__.py +73 -0
- lsurf/detectors/extended/local_sphere.py +353 -0
- lsurf/detectors/extended/recording_sphere.py +368 -0
- lsurf/detectors/planar.py +45 -0
- lsurf/detectors/protocol.py +187 -0
- lsurf/detectors/recording_spheres.py +63 -0
- lsurf/detectors/results.py +1140 -0
- lsurf/detectors/small/__init__.py +79 -0
- lsurf/detectors/small/directional.py +330 -0
- lsurf/detectors/small/planar.py +401 -0
- lsurf/detectors/small/spherical.py +450 -0
- lsurf/detectors/spherical.py +45 -0
- lsurf/geometry/__init__.py +199 -0
- lsurf/geometry/builder.py +478 -0
- lsurf/geometry/cell.py +228 -0
- lsurf/geometry/cell_geometry.py +247 -0
- lsurf/geometry/detector_arrays.py +1785 -0
- lsurf/geometry/geometry.py +222 -0
- lsurf/geometry/surface_analysis.py +375 -0
- lsurf/geometry/validation.py +91 -0
- lsurf/gui/__init__.py +51 -0
- lsurf/gui/app.py +903 -0
- lsurf/gui/core/__init__.py +39 -0
- lsurf/gui/core/scene.py +343 -0
- lsurf/gui/core/simulation.py +264 -0
- lsurf/gui/renderers/__init__.py +40 -0
- lsurf/gui/renderers/ray_renderer.py +353 -0
- lsurf/gui/renderers/source_renderer.py +505 -0
- lsurf/gui/renderers/surface_renderer.py +477 -0
- lsurf/gui/views/__init__.py +48 -0
- lsurf/gui/views/config_editor.py +3199 -0
- lsurf/gui/views/properties.py +257 -0
- lsurf/gui/views/results.py +291 -0
- lsurf/gui/views/scene_tree.py +180 -0
- lsurf/gui/views/viewport_3d.py +555 -0
- lsurf/gui/views/visualizations.py +712 -0
- lsurf/materials/__init__.py +169 -0
- lsurf/materials/base/__init__.py +64 -0
- lsurf/materials/base/full_inhomogeneous.py +208 -0
- lsurf/materials/base/grid_inhomogeneous.py +319 -0
- lsurf/materials/base/homogeneous.py +342 -0
- lsurf/materials/base/material_field.py +527 -0
- lsurf/materials/base/simple_inhomogeneous.py +418 -0
- lsurf/materials/base/spectral_inhomogeneous.py +497 -0
- lsurf/materials/implementations/__init__.py +120 -0
- lsurf/materials/implementations/data/alpha_values_typical_atmosphere_updated.txt +24 -0
- lsurf/materials/implementations/duct_atmosphere.py +390 -0
- lsurf/materials/implementations/exponential_atmosphere.py +435 -0
- lsurf/materials/implementations/gaussian_lens.py +120 -0
- lsurf/materials/implementations/interpolated_data.py +123 -0
- lsurf/materials/implementations/layered_atmosphere.py +134 -0
- lsurf/materials/implementations/linear_gradient.py +109 -0
- lsurf/materials/implementations/linsley_atmosphere.py +764 -0
- lsurf/materials/implementations/standard_materials.py +126 -0
- lsurf/materials/implementations/turbulent_atmosphere.py +135 -0
- lsurf/materials/implementations/us_standard_atmosphere.py +149 -0
- lsurf/materials/utils/__init__.py +77 -0
- lsurf/materials/utils/constants.py +45 -0
- lsurf/materials/utils/device_functions.py +117 -0
- lsurf/materials/utils/dispersion.py +160 -0
- lsurf/materials/utils/factories.py +142 -0
- lsurf/propagation/__init__.py +91 -0
- lsurf/propagation/detector_gpu.py +67 -0
- lsurf/propagation/gpu_device_rays.py +294 -0
- lsurf/propagation/kernels/__init__.py +175 -0
- lsurf/propagation/kernels/absorption/__init__.py +61 -0
- lsurf/propagation/kernels/absorption/grid.py +240 -0
- lsurf/propagation/kernels/absorption/simple.py +232 -0
- lsurf/propagation/kernels/absorption/spectral.py +410 -0
- lsurf/propagation/kernels/detection/__init__.py +64 -0
- lsurf/propagation/kernels/detection/protocol.py +102 -0
- lsurf/propagation/kernels/detection/spherical.py +255 -0
- lsurf/propagation/kernels/device_functions.py +790 -0
- lsurf/propagation/kernels/fresnel/__init__.py +64 -0
- lsurf/propagation/kernels/fresnel/protocol.py +97 -0
- lsurf/propagation/kernels/fresnel/standard.py +258 -0
- lsurf/propagation/kernels/intersection/__init__.py +79 -0
- lsurf/propagation/kernels/intersection/annular_plane.py +207 -0
- lsurf/propagation/kernels/intersection/bounded_plane.py +205 -0
- lsurf/propagation/kernels/intersection/plane.py +166 -0
- lsurf/propagation/kernels/intersection/protocol.py +95 -0
- lsurf/propagation/kernels/intersection/signed_distance.py +742 -0
- lsurf/propagation/kernels/intersection/sphere.py +190 -0
- lsurf/propagation/kernels/propagation/__init__.py +85 -0
- lsurf/propagation/kernels/propagation/grid.py +527 -0
- lsurf/propagation/kernels/propagation/protocol.py +105 -0
- lsurf/propagation/kernels/propagation/simple.py +460 -0
- lsurf/propagation/kernels/propagation/spectral.py +875 -0
- lsurf/propagation/kernels/registry.py +331 -0
- lsurf/propagation/kernels/surface/__init__.py +72 -0
- lsurf/propagation/kernels/surface/bisection.py +232 -0
- lsurf/propagation/kernels/surface/detection.py +402 -0
- lsurf/propagation/kernels/surface/reduction.py +166 -0
- lsurf/propagation/propagator_protocol.py +222 -0
- lsurf/propagation/propagators/__init__.py +101 -0
- lsurf/propagation/propagators/detector_handler.py +354 -0
- lsurf/propagation/propagators/factory.py +200 -0
- lsurf/propagation/propagators/fresnel_handler.py +305 -0
- lsurf/propagation/propagators/gpu_gradient.py +566 -0
- lsurf/propagation/propagators/gpu_surface_propagator.py +707 -0
- lsurf/propagation/propagators/gradient.py +429 -0
- lsurf/propagation/propagators/intersection_handler.py +327 -0
- lsurf/propagation/propagators/material_propagator.py +398 -0
- lsurf/propagation/propagators/signed_distance_handler.py +522 -0
- lsurf/propagation/propagators/spectral_gpu_gradient.py +553 -0
- lsurf/propagation/propagators/surface_interaction.py +616 -0
- lsurf/propagation/propagators/surface_propagator.py +719 -0
- lsurf/py.typed +1 -0
- lsurf/simulation/__init__.py +70 -0
- lsurf/simulation/config.py +164 -0
- lsurf/simulation/orchestrator.py +462 -0
- lsurf/simulation/result.py +299 -0
- lsurf/simulation/simulation.py +262 -0
- lsurf/sources/__init__.py +128 -0
- lsurf/sources/base.py +264 -0
- lsurf/sources/collimated.py +252 -0
- lsurf/sources/custom.py +409 -0
- lsurf/sources/diverging.py +228 -0
- lsurf/sources/gaussian.py +272 -0
- lsurf/sources/parallel_from_positions.py +197 -0
- lsurf/sources/point.py +172 -0
- lsurf/sources/uniform_diverging.py +258 -0
- lsurf/surfaces/__init__.py +184 -0
- lsurf/surfaces/cpu/__init__.py +50 -0
- lsurf/surfaces/cpu/curved_wave.py +463 -0
- lsurf/surfaces/cpu/gerstner_wave.py +381 -0
- lsurf/surfaces/cpu/wave_params.py +118 -0
- lsurf/surfaces/gpu/__init__.py +72 -0
- lsurf/surfaces/gpu/annular_plane.py +453 -0
- lsurf/surfaces/gpu/bounded_plane.py +390 -0
- lsurf/surfaces/gpu/curved_wave.py +483 -0
- lsurf/surfaces/gpu/gerstner_wave.py +377 -0
- lsurf/surfaces/gpu/multi_curved_wave.py +520 -0
- lsurf/surfaces/gpu/plane.py +299 -0
- lsurf/surfaces/gpu/recording_sphere.py +587 -0
- lsurf/surfaces/gpu/sphere.py +311 -0
- lsurf/surfaces/protocol.py +336 -0
- lsurf/surfaces/registry.py +373 -0
- lsurf/utilities/__init__.py +175 -0
- lsurf/utilities/detector_analysis.py +814 -0
- lsurf/utilities/fresnel.py +628 -0
- lsurf/utilities/interactions.py +1215 -0
- lsurf/utilities/propagation.py +602 -0
- lsurf/utilities/ray_data.py +532 -0
- lsurf/utilities/recording_sphere.py +745 -0
- lsurf/utilities/time_spread.py +463 -0
- lsurf/visualization/__init__.py +329 -0
- lsurf/visualization/absorption_plots.py +334 -0
- lsurf/visualization/atmospheric_plots.py +754 -0
- lsurf/visualization/common.py +348 -0
- lsurf/visualization/detector_plots.py +1350 -0
- lsurf/visualization/detector_sphere_plots.py +1173 -0
- lsurf/visualization/fresnel_plots.py +1061 -0
- lsurf/visualization/ocean_simulation_plots.py +999 -0
- lsurf/visualization/polarization_plots.py +916 -0
- lsurf/visualization/raytracing_plots.py +1521 -0
- lsurf/visualization/ring_detector_plots.py +1867 -0
- lsurf/visualization/time_spread_plots.py +531 -0
- lsurf-1.0.0.dist-info/METADATA +381 -0
- lsurf-1.0.0.dist-info/RECORD +180 -0
- lsurf-1.0.0.dist-info/WHEEL +5 -0
- lsurf-1.0.0.dist-info/entry_points.txt +2 -0
- lsurf-1.0.0.dist-info/licenses/LICENSE +32 -0
- lsurf-1.0.0.dist-info/top_level.txt +1 -0
lsurf/cli/run.py
ADDED
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
# The Clear BSD License
|
|
2
|
+
#
|
|
3
|
+
# Copyright (c) 2026 Tobias Heibges
|
|
4
|
+
# All rights reserved.
|
|
5
|
+
#
|
|
6
|
+
# Redistribution and use in source and binary forms, with or without
|
|
7
|
+
# modification, are permitted (subject to the limitations in the disclaimer
|
|
8
|
+
# below) provided that the following conditions are met:
|
|
9
|
+
#
|
|
10
|
+
# * Redistributions of source code must retain the above copyright notice,
|
|
11
|
+
# this list of conditions and the following disclaimer.
|
|
12
|
+
#
|
|
13
|
+
# * Redistributions in binary form must reproduce the above copyright
|
|
14
|
+
# notice, this list of conditions and the following disclaimer in the
|
|
15
|
+
# documentation and/or other materials provided with the distribution.
|
|
16
|
+
#
|
|
17
|
+
# * Neither the name of the copyright holder nor the names of its
|
|
18
|
+
# contributors may be used to endorse or promote products derived from this
|
|
19
|
+
# software without specific prior written permission.
|
|
20
|
+
#
|
|
21
|
+
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
|
|
22
|
+
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
|
23
|
+
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
|
24
|
+
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
|
|
25
|
+
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
|
|
26
|
+
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
|
27
|
+
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
|
28
|
+
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
|
|
29
|
+
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
|
|
30
|
+
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
31
|
+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
32
|
+
# POSSIBILITY OF SUCH DAMAGE.
|
|
33
|
+
|
|
34
|
+
"""L-SURF CLI run command.
|
|
35
|
+
|
|
36
|
+
This module implements the `lsurf run` command for executing simulations
|
|
37
|
+
from configuration files.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Any
|
|
42
|
+
|
|
43
|
+
import click
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
import tomli
|
|
47
|
+
except ImportError:
|
|
48
|
+
tomli = None
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
import yaml
|
|
52
|
+
except ImportError:
|
|
53
|
+
yaml = None
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
from rich.console import Console
|
|
57
|
+
from rich.progress import (
|
|
58
|
+
Progress,
|
|
59
|
+
SpinnerColumn,
|
|
60
|
+
TextColumn,
|
|
61
|
+
BarColumn,
|
|
62
|
+
TaskProgressColumn,
|
|
63
|
+
)
|
|
64
|
+
from rich.table import Table
|
|
65
|
+
|
|
66
|
+
RICH_AVAILABLE = True
|
|
67
|
+
except ImportError:
|
|
68
|
+
RICH_AVAILABLE = False
|
|
69
|
+
|
|
70
|
+
from .config_schema import LSURFConfig
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def read_config(path: Path) -> dict[str, Any]:
|
|
74
|
+
"""Read a configuration file (YAML or TOML)."""
|
|
75
|
+
suffix = path.suffix.lower()
|
|
76
|
+
|
|
77
|
+
if suffix in (".yaml", ".yml"):
|
|
78
|
+
if yaml is None:
|
|
79
|
+
raise click.ClickException(
|
|
80
|
+
"PyYAML is required for YAML files. Install with: pip install pyyaml"
|
|
81
|
+
)
|
|
82
|
+
with open(path) as f:
|
|
83
|
+
return yaml.safe_load(f)
|
|
84
|
+
|
|
85
|
+
elif suffix == ".toml":
|
|
86
|
+
if tomli is None:
|
|
87
|
+
raise click.ClickException(
|
|
88
|
+
"tomli is required for TOML files. Install with: pip install tomli"
|
|
89
|
+
)
|
|
90
|
+
with open(path, "rb") as f:
|
|
91
|
+
return tomli.load(f)
|
|
92
|
+
|
|
93
|
+
else:
|
|
94
|
+
raise click.ClickException(
|
|
95
|
+
f"Unsupported config file format: {suffix}. Use .yaml, .yml, or .toml"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def validate_config(config_dict: dict[str, Any]) -> LSURFConfig:
|
|
100
|
+
"""Validate and parse a configuration dictionary."""
|
|
101
|
+
try:
|
|
102
|
+
return LSURFConfig(**config_dict)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
raise click.ClickException(f"Configuration validation failed: {e}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def build_simulation(config: LSURFConfig, verbose: bool = False):
|
|
108
|
+
"""Build simulation objects from configuration.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Tuple of (geometry, rays, sim_config)
|
|
112
|
+
"""
|
|
113
|
+
import lsurf as sr
|
|
114
|
+
from ..geometry import GeometryBuilder
|
|
115
|
+
|
|
116
|
+
console = Console() if RICH_AVAILABLE else None
|
|
117
|
+
|
|
118
|
+
if verbose and console:
|
|
119
|
+
console.print("[cyan]Building simulation from configuration...[/cyan]")
|
|
120
|
+
|
|
121
|
+
# Build media
|
|
122
|
+
media_objects: dict[str, Any] = {}
|
|
123
|
+
for name, media_cfg in config.media.items():
|
|
124
|
+
media_objects[name] = _create_medium(media_cfg.type, media_cfg.params)
|
|
125
|
+
if verbose and console:
|
|
126
|
+
console.print(f" Created medium: [green]{name}[/green] ({media_cfg.type})")
|
|
127
|
+
|
|
128
|
+
# Build geometry
|
|
129
|
+
builder = GeometryBuilder()
|
|
130
|
+
|
|
131
|
+
# Register media
|
|
132
|
+
for name, medium in media_objects.items():
|
|
133
|
+
builder.register_medium(name, medium)
|
|
134
|
+
|
|
135
|
+
# Set background - default to vacuum if not specified
|
|
136
|
+
if config.background:
|
|
137
|
+
builder.set_background(config.background)
|
|
138
|
+
else:
|
|
139
|
+
# No background specified - use vacuum
|
|
140
|
+
import lsurf as sr
|
|
141
|
+
|
|
142
|
+
builder.register_medium("_vacuum", sr.VACUUM)
|
|
143
|
+
builder.set_background("_vacuum")
|
|
144
|
+
if verbose and console:
|
|
145
|
+
console.print(" [yellow]No background specified, using vacuum[/yellow]")
|
|
146
|
+
|
|
147
|
+
# Add surfaces
|
|
148
|
+
for surface_cfg in config.surfaces:
|
|
149
|
+
surface = _create_surface(surface_cfg, media_objects)
|
|
150
|
+
if surface_cfg.front_medium and surface_cfg.back_medium:
|
|
151
|
+
builder.add_surface(
|
|
152
|
+
surface,
|
|
153
|
+
front=surface_cfg.front_medium,
|
|
154
|
+
back=surface_cfg.back_medium,
|
|
155
|
+
)
|
|
156
|
+
else:
|
|
157
|
+
builder.add_detector(surface)
|
|
158
|
+
if verbose and console:
|
|
159
|
+
console.print(
|
|
160
|
+
f" Added surface: [green]{surface_cfg.name}[/green] ({surface_cfg.type})"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Add detectors
|
|
164
|
+
for detector_cfg in config.detectors:
|
|
165
|
+
detector_surface = _create_detector_surface(detector_cfg)
|
|
166
|
+
builder.add_detector(detector_surface)
|
|
167
|
+
if verbose and console:
|
|
168
|
+
console.print(
|
|
169
|
+
f" Added detector: [green]{detector_cfg.name}[/green] ({detector_cfg.type})"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
geometry = builder.build()
|
|
173
|
+
|
|
174
|
+
# Build source and generate rays
|
|
175
|
+
source = _create_source(config.source.type, config.source.params)
|
|
176
|
+
rays = source.generate()
|
|
177
|
+
if verbose and console:
|
|
178
|
+
console.print(
|
|
179
|
+
f" Generated [green]{rays.num_rays}[/green] rays from {config.source.type}"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# config.simulation is already a SimulationConfig instance
|
|
183
|
+
return geometry, rays, config.simulation
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _create_medium(media_type: str, params: dict[str, Any]):
|
|
187
|
+
"""Create a medium object from type and parameters."""
|
|
188
|
+
import lsurf as sr
|
|
189
|
+
from ..materials.implementations.duct_atmosphere import DuctAtmosphere
|
|
190
|
+
|
|
191
|
+
if media_type == "vacuum":
|
|
192
|
+
return sr.VACUUM
|
|
193
|
+
elif media_type == "air":
|
|
194
|
+
return sr.AIR_STP
|
|
195
|
+
elif media_type == "water":
|
|
196
|
+
return sr.WATER
|
|
197
|
+
elif media_type == "glass":
|
|
198
|
+
n = params.get("refractive_index", 1.5168)
|
|
199
|
+
return sr.HomogeneousMaterial(name="glass", refractive_index=n)
|
|
200
|
+
elif media_type == "homogeneous":
|
|
201
|
+
return sr.HomogeneousMaterial(
|
|
202
|
+
name=params.get("name", "custom"),
|
|
203
|
+
refractive_index=params.get("refractive_index", 1.5),
|
|
204
|
+
absorption_coef=params.get("absorption_coef", 0.0),
|
|
205
|
+
scattering_coef=params.get("scattering_coef", 0.0),
|
|
206
|
+
anisotropy=params.get("anisotropy", 0.0),
|
|
207
|
+
)
|
|
208
|
+
elif media_type == "exponential_atmosphere":
|
|
209
|
+
return sr.ExponentialAtmosphere(
|
|
210
|
+
name=params.get("name", "Exponential Atmosphere"),
|
|
211
|
+
n_sea_level=params.get("n_sea_level", 1.000293),
|
|
212
|
+
scale_height=params.get("scale_height", 8500.0),
|
|
213
|
+
earth_radius=params.get("earth_radius", 6.371e6),
|
|
214
|
+
earth_center=tuple(params.get("earth_center", (0.0, 0.0, -6.371e6))),
|
|
215
|
+
absorption_coef=params.get("absorption_coef", 0.0),
|
|
216
|
+
)
|
|
217
|
+
elif media_type == "duct_atmosphere":
|
|
218
|
+
return DuctAtmosphere(**params)
|
|
219
|
+
else:
|
|
220
|
+
raise ValueError(f"Unknown medium type: {media_type}")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _create_source(source_type: str, params: dict[str, Any]):
|
|
224
|
+
"""Create a source object from type and parameters."""
|
|
225
|
+
import lsurf as sr
|
|
226
|
+
|
|
227
|
+
# Helper to convert wavelength (may be string like "532e-9" from YAML)
|
|
228
|
+
def get_wavelength(p):
|
|
229
|
+
wl = p["wavelength"]
|
|
230
|
+
return float(wl) if isinstance(wl, str) else wl
|
|
231
|
+
|
|
232
|
+
if source_type == "point":
|
|
233
|
+
return sr.PointSource(
|
|
234
|
+
position=tuple(params["position"]),
|
|
235
|
+
num_rays=int(params["num_rays"]),
|
|
236
|
+
wavelength=get_wavelength(params),
|
|
237
|
+
power=float(params.get("power", 1.0)),
|
|
238
|
+
)
|
|
239
|
+
elif source_type == "collimated_beam":
|
|
240
|
+
return sr.CollimatedBeam(
|
|
241
|
+
center=tuple(params["center"]),
|
|
242
|
+
direction=tuple(params["direction"]),
|
|
243
|
+
radius=float(params.get("beam_radius", params.get("radius", 0.01))),
|
|
244
|
+
num_rays=int(params["num_rays"]),
|
|
245
|
+
wavelength=get_wavelength(params),
|
|
246
|
+
power=float(params.get("power", 1.0)),
|
|
247
|
+
profile=params.get("profile", "uniform"),
|
|
248
|
+
)
|
|
249
|
+
elif source_type == "diverging_beam":
|
|
250
|
+
return sr.DivergingBeam(
|
|
251
|
+
origin=tuple(params["origin"]),
|
|
252
|
+
mean_direction=tuple(params["mean_direction"]),
|
|
253
|
+
divergence_angle=float(params["divergence_angle"]),
|
|
254
|
+
num_rays=int(params["num_rays"]),
|
|
255
|
+
wavelength=get_wavelength(params),
|
|
256
|
+
power=float(params.get("power", 1.0)),
|
|
257
|
+
)
|
|
258
|
+
elif source_type == "gaussian_beam":
|
|
259
|
+
return sr.GaussianBeam(
|
|
260
|
+
waist_position=tuple(params["waist_position"]),
|
|
261
|
+
direction=tuple(params["direction"]),
|
|
262
|
+
waist_radius=float(params["waist_radius"]),
|
|
263
|
+
num_rays=int(params["num_rays"]),
|
|
264
|
+
wavelength=get_wavelength(params),
|
|
265
|
+
power=float(params.get("power", 1.0)),
|
|
266
|
+
)
|
|
267
|
+
elif source_type == "parallel_from_positions":
|
|
268
|
+
import numpy as np
|
|
269
|
+
|
|
270
|
+
return sr.ParallelBeamFromPositions(
|
|
271
|
+
positions=np.array(params["positions"]),
|
|
272
|
+
direction=tuple(params["direction"]),
|
|
273
|
+
wavelength=params["wavelength"],
|
|
274
|
+
power=params.get("power", 1.0),
|
|
275
|
+
)
|
|
276
|
+
elif source_type == "custom":
|
|
277
|
+
import numpy as np
|
|
278
|
+
|
|
279
|
+
return sr.CustomRaySource(
|
|
280
|
+
positions=np.array(params["positions"]),
|
|
281
|
+
directions=np.array(params["directions"]),
|
|
282
|
+
wavelengths=np.array(params["wavelengths"]),
|
|
283
|
+
intensities=np.array(params["intensities"]),
|
|
284
|
+
)
|
|
285
|
+
else:
|
|
286
|
+
raise ValueError(f"Unknown source type: {source_type}")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _create_surface(surface_cfg, media_objects: dict[str, Any]):
|
|
290
|
+
"""Create a surface object from configuration."""
|
|
291
|
+
import lsurf as sr
|
|
292
|
+
from ..surfaces import (
|
|
293
|
+
SurfaceRole,
|
|
294
|
+
BoundedPlaneSurface,
|
|
295
|
+
AnnularPlaneSurface,
|
|
296
|
+
GPUGerstnerWaveSurface,
|
|
297
|
+
GPUCurvedWaveSurface,
|
|
298
|
+
GPUMultiCurvedWaveSurface,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
params = surface_cfg.params
|
|
302
|
+
|
|
303
|
+
# Map role string to enum
|
|
304
|
+
role_map = {
|
|
305
|
+
"optical": SurfaceRole.OPTICAL,
|
|
306
|
+
"detector": SurfaceRole.DETECTOR,
|
|
307
|
+
"absorber": SurfaceRole.ABSORBER,
|
|
308
|
+
}
|
|
309
|
+
role = role_map.get(surface_cfg.role, SurfaceRole.OPTICAL)
|
|
310
|
+
|
|
311
|
+
surface_type = surface_cfg.type
|
|
312
|
+
|
|
313
|
+
if surface_type == "plane":
|
|
314
|
+
return sr.PlaneSurface(
|
|
315
|
+
point=tuple(params["point"]),
|
|
316
|
+
normal=tuple(params["normal"]),
|
|
317
|
+
role=role,
|
|
318
|
+
name=surface_cfg.name,
|
|
319
|
+
)
|
|
320
|
+
elif surface_type == "bounded_plane":
|
|
321
|
+
return BoundedPlaneSurface(
|
|
322
|
+
point=tuple(params["point"]),
|
|
323
|
+
normal=tuple(params["normal"]),
|
|
324
|
+
width=params["width"],
|
|
325
|
+
height=params["height"],
|
|
326
|
+
role=role,
|
|
327
|
+
name=surface_cfg.name,
|
|
328
|
+
)
|
|
329
|
+
elif surface_type == "annular_plane":
|
|
330
|
+
return AnnularPlaneSurface(
|
|
331
|
+
point=tuple(params["point"]),
|
|
332
|
+
normal=tuple(params["normal"]),
|
|
333
|
+
inner_radius=params["inner_radius"],
|
|
334
|
+
outer_radius=params["outer_radius"],
|
|
335
|
+
role=role,
|
|
336
|
+
name=surface_cfg.name,
|
|
337
|
+
)
|
|
338
|
+
elif surface_type == "sphere":
|
|
339
|
+
return sr.SphereSurface(
|
|
340
|
+
center=tuple(params["center"]),
|
|
341
|
+
radius=params["radius"],
|
|
342
|
+
role=role,
|
|
343
|
+
name=surface_cfg.name,
|
|
344
|
+
)
|
|
345
|
+
elif surface_type == "gerstner_wave":
|
|
346
|
+
return GPUGerstnerWaveSurface(
|
|
347
|
+
amplitude=params["amplitude"],
|
|
348
|
+
wavelength=params["wavelength"],
|
|
349
|
+
direction=tuple(params["direction"]),
|
|
350
|
+
reference_z=params.get("reference_z", 0.0),
|
|
351
|
+
role=role,
|
|
352
|
+
name=surface_cfg.name,
|
|
353
|
+
phase=params.get("phase", 0.0),
|
|
354
|
+
time=params.get("time", 0.0),
|
|
355
|
+
)
|
|
356
|
+
elif surface_type == "curved_wave":
|
|
357
|
+
return GPUCurvedWaveSurface(
|
|
358
|
+
amplitude=params["amplitude"],
|
|
359
|
+
wavelength=params["wavelength"],
|
|
360
|
+
direction=tuple(params["direction"]),
|
|
361
|
+
earth_center=tuple(params.get("earth_center", (0.0, 0.0, -6.371e6))),
|
|
362
|
+
earth_radius=params.get("earth_radius", 6.371e6),
|
|
363
|
+
role=role,
|
|
364
|
+
name=surface_cfg.name,
|
|
365
|
+
time=params.get("time", 0.0),
|
|
366
|
+
)
|
|
367
|
+
elif surface_type == "multi_curved_wave":
|
|
368
|
+
from ..surfaces import GerstnerWaveParams
|
|
369
|
+
|
|
370
|
+
# Convert wave_params from dicts to GerstnerWaveParams objects
|
|
371
|
+
wave_params = []
|
|
372
|
+
for wp in params["wave_params"]:
|
|
373
|
+
if isinstance(wp, dict):
|
|
374
|
+
wave_params.append(
|
|
375
|
+
GerstnerWaveParams(
|
|
376
|
+
amplitude=wp["amplitude"],
|
|
377
|
+
wavelength=wp["wavelength"],
|
|
378
|
+
direction=tuple(wp["direction"]),
|
|
379
|
+
phase=wp.get("phase", 0.0),
|
|
380
|
+
steepness=wp.get("steepness", 0.5),
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
else:
|
|
384
|
+
wave_params.append(wp)
|
|
385
|
+
|
|
386
|
+
return GPUMultiCurvedWaveSurface(
|
|
387
|
+
wave_params=wave_params,
|
|
388
|
+
earth_center=tuple(params.get("earth_center", (0.0, 0.0, -6.371e6))),
|
|
389
|
+
earth_radius=params.get("earth_radius", 6.371e6),
|
|
390
|
+
time=params.get("time", 0.0),
|
|
391
|
+
role=role,
|
|
392
|
+
name=surface_cfg.name,
|
|
393
|
+
)
|
|
394
|
+
else:
|
|
395
|
+
raise ValueError(f"Unknown surface type: {surface_type}")
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _create_detector_surface(detector_cfg):
|
|
399
|
+
"""Create a detector surface from configuration."""
|
|
400
|
+
import lsurf as sr
|
|
401
|
+
from ..surfaces import SurfaceRole, BoundedPlaneSurface
|
|
402
|
+
|
|
403
|
+
params = detector_cfg.params
|
|
404
|
+
detector_type = detector_cfg.type
|
|
405
|
+
|
|
406
|
+
if detector_type == "spherical":
|
|
407
|
+
# Small spherical detector - use SphereSurface with DETECTOR role
|
|
408
|
+
return sr.SphereSurface(
|
|
409
|
+
center=tuple(params["center"]),
|
|
410
|
+
radius=params["radius"],
|
|
411
|
+
role=SurfaceRole.DETECTOR,
|
|
412
|
+
name=detector_cfg.name,
|
|
413
|
+
)
|
|
414
|
+
elif detector_type == "planar":
|
|
415
|
+
# Planar detector - use BoundedPlaneSurface with DETECTOR role
|
|
416
|
+
return BoundedPlaneSurface(
|
|
417
|
+
point=tuple(params["center"]),
|
|
418
|
+
normal=tuple(params["normal"]),
|
|
419
|
+
width=params["width"],
|
|
420
|
+
height=params["height"],
|
|
421
|
+
role=SurfaceRole.DETECTOR,
|
|
422
|
+
name=detector_cfg.name,
|
|
423
|
+
)
|
|
424
|
+
elif detector_type == "directional":
|
|
425
|
+
# Directional detector - approximate with bounded plane
|
|
426
|
+
return BoundedPlaneSurface(
|
|
427
|
+
point=tuple(params["position"]),
|
|
428
|
+
normal=tuple(params["direction"]),
|
|
429
|
+
width=params["radius"] * 2,
|
|
430
|
+
height=params["radius"] * 2,
|
|
431
|
+
role=SurfaceRole.DETECTOR,
|
|
432
|
+
name=detector_cfg.name,
|
|
433
|
+
)
|
|
434
|
+
elif detector_type == "recording_sphere":
|
|
435
|
+
# RecordingSphereSurface uses altitude, earth_center, earth_radius
|
|
436
|
+
# Config can specify either:
|
|
437
|
+
# - altitude directly
|
|
438
|
+
# - center + radius (where radius is either total sphere radius or altitude)
|
|
439
|
+
earth_radius = float(params.get("earth_radius", 6.371e6))
|
|
440
|
+
earth_center = tuple(
|
|
441
|
+
params.get("earth_center", params.get("center", (0.0, 0.0, -6.371e6)))
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
if "altitude" in params:
|
|
445
|
+
# Altitude specified directly
|
|
446
|
+
altitude = float(params["altitude"])
|
|
447
|
+
elif "radius" in params:
|
|
448
|
+
radius = float(params["radius"])
|
|
449
|
+
# Heuristic: if radius > earth_radius, it's the total sphere radius
|
|
450
|
+
# Otherwise, radius IS the altitude
|
|
451
|
+
if radius > earth_radius:
|
|
452
|
+
altitude = radius - earth_radius
|
|
453
|
+
else:
|
|
454
|
+
altitude = radius
|
|
455
|
+
else:
|
|
456
|
+
altitude = 33000.0 # Default 33km
|
|
457
|
+
|
|
458
|
+
return sr.RecordingSphereSurface(
|
|
459
|
+
altitude=altitude,
|
|
460
|
+
earth_center=earth_center,
|
|
461
|
+
earth_radius=earth_radius,
|
|
462
|
+
name=detector_cfg.name,
|
|
463
|
+
)
|
|
464
|
+
elif detector_type == "local_recording_sphere":
|
|
465
|
+
return sr.LocalRecordingSphereSurface(
|
|
466
|
+
radius=params["radius"],
|
|
467
|
+
center=tuple(params.get("center", (0.0, 0.0, 0.0))),
|
|
468
|
+
name=detector_cfg.name,
|
|
469
|
+
)
|
|
470
|
+
else:
|
|
471
|
+
raise ValueError(f"Unknown detector type: {detector_type}")
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def run_simulation(
|
|
475
|
+
geometry, rays, sim_config, progress: bool = False, verbose: bool = False
|
|
476
|
+
):
|
|
477
|
+
"""Run the simulation and return results."""
|
|
478
|
+
from ..simulation import Simulation
|
|
479
|
+
|
|
480
|
+
console = Console() if RICH_AVAILABLE else None
|
|
481
|
+
|
|
482
|
+
sim = Simulation(geometry, sim_config)
|
|
483
|
+
|
|
484
|
+
if progress and RICH_AVAILABLE:
|
|
485
|
+
with Progress(
|
|
486
|
+
SpinnerColumn(),
|
|
487
|
+
TextColumn("[progress.description]{task.description}"),
|
|
488
|
+
BarColumn(),
|
|
489
|
+
TaskProgressColumn(),
|
|
490
|
+
console=console,
|
|
491
|
+
) as progress_bar:
|
|
492
|
+
task = progress_bar.add_task("[cyan]Running simulation...", total=None)
|
|
493
|
+
result = sim.run(rays)
|
|
494
|
+
progress_bar.update(task, completed=True)
|
|
495
|
+
else:
|
|
496
|
+
if verbose:
|
|
497
|
+
click.echo("Running simulation...")
|
|
498
|
+
result = sim.run(rays)
|
|
499
|
+
|
|
500
|
+
return result
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def save_results(result, config: LSURFConfig, verbose: bool = False):
|
|
504
|
+
"""Save simulation results to files."""
|
|
505
|
+
import numpy as np
|
|
506
|
+
from pathlib import Path
|
|
507
|
+
|
|
508
|
+
output_cfg = config.output
|
|
509
|
+
output_dir = Path(output_cfg.directory)
|
|
510
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
511
|
+
|
|
512
|
+
prefix = output_cfg.prefix
|
|
513
|
+
fmt = output_cfg.format
|
|
514
|
+
|
|
515
|
+
console = Console() if RICH_AVAILABLE else None
|
|
516
|
+
|
|
517
|
+
if verbose and console:
|
|
518
|
+
console.print(f"[cyan]Saving results to {output_dir}...[/cyan]")
|
|
519
|
+
|
|
520
|
+
# Save statistics
|
|
521
|
+
if output_cfg.save_statistics:
|
|
522
|
+
stats = result.statistics
|
|
523
|
+
stats_data = {
|
|
524
|
+
"total_rays_initial": int(stats.total_rays_initial),
|
|
525
|
+
"total_rays_created": int(stats.total_rays_created),
|
|
526
|
+
"rays_detected": int(stats.rays_detected),
|
|
527
|
+
"rays_absorbed": int(stats.rays_absorbed),
|
|
528
|
+
"rays_terminated_bounds": int(stats.rays_terminated_bounds),
|
|
529
|
+
"rays_terminated_intensity": int(stats.rays_terminated_intensity),
|
|
530
|
+
"rays_terminated_max_bounces": int(stats.rays_terminated_max_bounces),
|
|
531
|
+
"bounces_completed": int(stats.bounces_completed),
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if fmt == "hdf5":
|
|
535
|
+
try:
|
|
536
|
+
import h5py
|
|
537
|
+
|
|
538
|
+
with h5py.File(output_dir / f"{prefix}_statistics.h5", "w") as f:
|
|
539
|
+
for key, value in stats_data.items():
|
|
540
|
+
f.create_dataset(key, data=value)
|
|
541
|
+
except ImportError:
|
|
542
|
+
# Fallback to numpy
|
|
543
|
+
np.savez(output_dir / f"{prefix}_statistics.npz", **stats_data)
|
|
544
|
+
elif fmt == "numpy":
|
|
545
|
+
np.savez(output_dir / f"{prefix}_statistics.npz", **stats_data)
|
|
546
|
+
elif fmt == "csv":
|
|
547
|
+
import csv
|
|
548
|
+
|
|
549
|
+
with open(output_dir / f"{prefix}_statistics.csv", "w", newline="") as f:
|
|
550
|
+
writer = csv.writer(f)
|
|
551
|
+
writer.writerow(["metric", "value"])
|
|
552
|
+
for key, value in stats_data.items():
|
|
553
|
+
writer.writerow([key, value])
|
|
554
|
+
|
|
555
|
+
# Save detected rays
|
|
556
|
+
if hasattr(result, "detected") and result.detected is not None:
|
|
557
|
+
detected = result.detected
|
|
558
|
+
if hasattr(detected, "positions") and detected.positions is not None:
|
|
559
|
+
positions = np.asarray(detected.positions)
|
|
560
|
+
directions = (
|
|
561
|
+
np.asarray(detected.directions)
|
|
562
|
+
if hasattr(detected, "directions") and detected.directions is not None
|
|
563
|
+
else None
|
|
564
|
+
)
|
|
565
|
+
intensities = (
|
|
566
|
+
np.asarray(detected.intensities)
|
|
567
|
+
if hasattr(detected, "intensities") and detected.intensities is not None
|
|
568
|
+
else None
|
|
569
|
+
)
|
|
570
|
+
times = (
|
|
571
|
+
np.asarray(detected.times)
|
|
572
|
+
if hasattr(detected, "times") and detected.times is not None
|
|
573
|
+
else None
|
|
574
|
+
)
|
|
575
|
+
wavelengths = (
|
|
576
|
+
np.asarray(detected.wavelengths)
|
|
577
|
+
if hasattr(detected, "wavelengths") and detected.wavelengths is not None
|
|
578
|
+
else None
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
if fmt == "hdf5":
|
|
582
|
+
try:
|
|
583
|
+
import h5py
|
|
584
|
+
|
|
585
|
+
with h5py.File(output_dir / f"{prefix}_detected.h5", "w") as f:
|
|
586
|
+
f.create_dataset("positions", data=positions)
|
|
587
|
+
if directions is not None:
|
|
588
|
+
f.create_dataset("directions", data=directions)
|
|
589
|
+
if intensities is not None:
|
|
590
|
+
f.create_dataset("intensities", data=intensities)
|
|
591
|
+
if times is not None:
|
|
592
|
+
f.create_dataset("times", data=times)
|
|
593
|
+
if wavelengths is not None:
|
|
594
|
+
f.create_dataset("wavelengths", data=wavelengths)
|
|
595
|
+
except ImportError:
|
|
596
|
+
np.savez(
|
|
597
|
+
output_dir / f"{prefix}_detected.npz",
|
|
598
|
+
positions=positions,
|
|
599
|
+
directions=directions if directions is not None else [],
|
|
600
|
+
intensities=intensities if intensities is not None else [],
|
|
601
|
+
times=times if times is not None else [],
|
|
602
|
+
wavelengths=wavelengths if wavelengths is not None else [],
|
|
603
|
+
)
|
|
604
|
+
elif fmt == "numpy":
|
|
605
|
+
np.savez(
|
|
606
|
+
output_dir / f"{prefix}_detected.npz",
|
|
607
|
+
positions=positions,
|
|
608
|
+
directions=directions if directions is not None else [],
|
|
609
|
+
intensities=intensities if intensities is not None else [],
|
|
610
|
+
times=times if times is not None else [],
|
|
611
|
+
wavelengths=wavelengths if wavelengths is not None else [],
|
|
612
|
+
)
|
|
613
|
+
elif fmt == "csv":
|
|
614
|
+
# Save positions as CSV
|
|
615
|
+
np.savetxt(
|
|
616
|
+
output_dir / f"{prefix}_detected_positions.csv",
|
|
617
|
+
positions,
|
|
618
|
+
delimiter=",",
|
|
619
|
+
header="x,y,z",
|
|
620
|
+
comments="",
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
if verbose and console:
|
|
624
|
+
console.print(f"[green]Results saved to {output_dir}[/green]")
|
|
625
|
+
|
|
626
|
+
return output_dir
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def print_summary(result, verbose: bool = False):
|
|
630
|
+
"""Print a summary of simulation results."""
|
|
631
|
+
stats = result.statistics
|
|
632
|
+
|
|
633
|
+
# Compute derived values
|
|
634
|
+
rays_initial = stats.total_rays_initial
|
|
635
|
+
rays_escaped = stats.rays_terminated_bounds
|
|
636
|
+
rays_terminated = (
|
|
637
|
+
stats.rays_terminated_intensity + stats.rays_terminated_max_bounces
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
if RICH_AVAILABLE:
|
|
641
|
+
console = Console()
|
|
642
|
+
table = Table(title="Simulation Results")
|
|
643
|
+
table.add_column("Metric", style="cyan")
|
|
644
|
+
table.add_column("Value", style="green", justify="right")
|
|
645
|
+
|
|
646
|
+
table.add_row("Rays Initial", f"{rays_initial:,}")
|
|
647
|
+
table.add_row("Rays Created (with splits)", f"{stats.total_rays_created:,}")
|
|
648
|
+
table.add_row("Rays Detected", f"{stats.rays_detected:,}")
|
|
649
|
+
table.add_row("Rays Absorbed", f"{stats.rays_absorbed:,}")
|
|
650
|
+
table.add_row("Rays Escaped (bounds)", f"{rays_escaped:,}")
|
|
651
|
+
table.add_row("Rays Terminated", f"{rays_terminated:,}")
|
|
652
|
+
table.add_row("Bounces Completed", f"{stats.bounces_completed:,}")
|
|
653
|
+
|
|
654
|
+
if rays_initial > 0:
|
|
655
|
+
detection_rate = stats.rays_detected / rays_initial * 100
|
|
656
|
+
table.add_row("Detection Rate", f"{detection_rate:.2f}%")
|
|
657
|
+
|
|
658
|
+
console.print(table)
|
|
659
|
+
else:
|
|
660
|
+
click.echo("\n=== Simulation Results ===")
|
|
661
|
+
click.echo(f" Rays Initial: {rays_initial:,}")
|
|
662
|
+
click.echo(f" Rays Created: {stats.total_rays_created:,}")
|
|
663
|
+
click.echo(f" Rays Detected: {stats.rays_detected:,}")
|
|
664
|
+
click.echo(f" Rays Absorbed: {stats.rays_absorbed:,}")
|
|
665
|
+
click.echo(f" Rays Escaped: {rays_escaped:,}")
|
|
666
|
+
click.echo(f" Rays Terminated: {rays_terminated:,}")
|
|
667
|
+
click.echo(f" Bounces: {stats.bounces_completed:,}")
|
|
668
|
+
|
|
669
|
+
if rays_initial > 0:
|
|
670
|
+
detection_rate = stats.rays_detected / rays_initial * 100
|
|
671
|
+
click.echo(f" Detection Rate: {detection_rate:.2f}%")
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
@click.command()
|
|
675
|
+
@click.argument(
|
|
676
|
+
"config_file",
|
|
677
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
678
|
+
)
|
|
679
|
+
@click.option(
|
|
680
|
+
"--num-rays",
|
|
681
|
+
type=int,
|
|
682
|
+
help="Override the number of rays in the source",
|
|
683
|
+
)
|
|
684
|
+
@click.option(
|
|
685
|
+
"--output-dir",
|
|
686
|
+
type=click.Path(file_okay=False, path_type=Path),
|
|
687
|
+
help="Override the output directory",
|
|
688
|
+
)
|
|
689
|
+
@click.option(
|
|
690
|
+
"--dry-run",
|
|
691
|
+
is_flag=True,
|
|
692
|
+
help="Validate configuration without running simulation",
|
|
693
|
+
)
|
|
694
|
+
@click.option(
|
|
695
|
+
"--progress",
|
|
696
|
+
is_flag=True,
|
|
697
|
+
help="Show progress display during simulation",
|
|
698
|
+
)
|
|
699
|
+
@click.option(
|
|
700
|
+
"--quiet",
|
|
701
|
+
is_flag=True,
|
|
702
|
+
help="Suppress all output except errors",
|
|
703
|
+
)
|
|
704
|
+
@click.option(
|
|
705
|
+
"--no-save",
|
|
706
|
+
is_flag=True,
|
|
707
|
+
help="Don't save results to files",
|
|
708
|
+
)
|
|
709
|
+
@click.pass_context
|
|
710
|
+
def run(
|
|
711
|
+
ctx: click.Context,
|
|
712
|
+
config_file: Path,
|
|
713
|
+
num_rays: int | None,
|
|
714
|
+
output_dir: Path | None,
|
|
715
|
+
dry_run: bool,
|
|
716
|
+
progress: bool,
|
|
717
|
+
quiet: bool,
|
|
718
|
+
no_save: bool,
|
|
719
|
+
) -> None:
|
|
720
|
+
"""Run a simulation from a configuration file.
|
|
721
|
+
|
|
722
|
+
\b
|
|
723
|
+
CONFIG_FILE: Path to a .yaml or .toml configuration file
|
|
724
|
+
|
|
725
|
+
\b
|
|
726
|
+
Examples:
|
|
727
|
+
# Basic run
|
|
728
|
+
lsurf run simulation.yaml
|
|
729
|
+
|
|
730
|
+
# With progress display
|
|
731
|
+
lsurf run simulation.yaml --progress
|
|
732
|
+
|
|
733
|
+
# Validate only (dry run)
|
|
734
|
+
lsurf run simulation.yaml --dry-run
|
|
735
|
+
|
|
736
|
+
# Override output directory
|
|
737
|
+
lsurf run simulation.yaml --output-dir ./my_results
|
|
738
|
+
|
|
739
|
+
# Override number of rays
|
|
740
|
+
lsurf run simulation.yaml --num-rays 1000000
|
|
741
|
+
"""
|
|
742
|
+
verbose = ctx.obj.get("verbose", False) and not quiet
|
|
743
|
+
|
|
744
|
+
# Read configuration
|
|
745
|
+
if not quiet:
|
|
746
|
+
click.echo(f"Reading configuration from: {config_file}")
|
|
747
|
+
|
|
748
|
+
config_dict = read_config(config_file)
|
|
749
|
+
|
|
750
|
+
# Apply overrides
|
|
751
|
+
if num_rays is not None:
|
|
752
|
+
config_dict["source"]["params"]["num_rays"] = num_rays
|
|
753
|
+
if verbose:
|
|
754
|
+
click.echo(f" Overriding num_rays: {num_rays}")
|
|
755
|
+
|
|
756
|
+
if output_dir is not None:
|
|
757
|
+
config_dict["output"]["directory"] = str(output_dir)
|
|
758
|
+
if verbose:
|
|
759
|
+
click.echo(f" Overriding output_dir: {output_dir}")
|
|
760
|
+
|
|
761
|
+
# Validate configuration
|
|
762
|
+
config = validate_config(config_dict)
|
|
763
|
+
|
|
764
|
+
if not quiet:
|
|
765
|
+
click.echo("Configuration validated successfully.")
|
|
766
|
+
|
|
767
|
+
if dry_run:
|
|
768
|
+
click.echo("\n[Dry run] Configuration is valid. No simulation was run.")
|
|
769
|
+
|
|
770
|
+
if verbose:
|
|
771
|
+
click.echo("\nConfiguration summary:")
|
|
772
|
+
click.echo(f" Media: {list(config.media.keys())}")
|
|
773
|
+
click.echo(f" Background: {config.background}")
|
|
774
|
+
click.echo(f" Source: {config.source.type}")
|
|
775
|
+
click.echo(f" Surfaces: {len(config.surfaces)}")
|
|
776
|
+
click.echo(f" Detectors: {len(config.detectors)}")
|
|
777
|
+
click.echo(f" Max bounces: {config.simulation.max_bounces}")
|
|
778
|
+
click.echo(f" GPU: {config.simulation.use_gpu}")
|
|
779
|
+
return
|
|
780
|
+
|
|
781
|
+
# Build simulation
|
|
782
|
+
try:
|
|
783
|
+
geometry, rays, sim_config = build_simulation(config, verbose=verbose)
|
|
784
|
+
except Exception as e:
|
|
785
|
+
raise click.ClickException(f"Failed to build simulation: {e}")
|
|
786
|
+
|
|
787
|
+
# Run simulation
|
|
788
|
+
try:
|
|
789
|
+
result = run_simulation(
|
|
790
|
+
geometry, rays, sim_config, progress=progress, verbose=verbose
|
|
791
|
+
)
|
|
792
|
+
except Exception as e:
|
|
793
|
+
raise click.ClickException(f"Simulation failed: {e}")
|
|
794
|
+
|
|
795
|
+
# Print summary
|
|
796
|
+
if not quiet:
|
|
797
|
+
print_summary(result, verbose=verbose)
|
|
798
|
+
|
|
799
|
+
# Save results
|
|
800
|
+
if not no_save:
|
|
801
|
+
try:
|
|
802
|
+
output_path = save_results(result, config, verbose=verbose)
|
|
803
|
+
if not quiet:
|
|
804
|
+
click.echo(f"\nResults saved to: {output_path}")
|
|
805
|
+
except Exception as e:
|
|
806
|
+
raise click.ClickException(f"Failed to save results: {e}")
|