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.
Files changed (180) hide show
  1. lsurf/__init__.py +471 -0
  2. lsurf/analysis/__init__.py +107 -0
  3. lsurf/analysis/healpix_utils.py +418 -0
  4. lsurf/analysis/sphere_viz.py +1280 -0
  5. lsurf/cli/__init__.py +48 -0
  6. lsurf/cli/build.py +398 -0
  7. lsurf/cli/config_schema.py +318 -0
  8. lsurf/cli/gui_cmd.py +76 -0
  9. lsurf/cli/interactive.py +850 -0
  10. lsurf/cli/main.py +81 -0
  11. lsurf/cli/run.py +806 -0
  12. lsurf/detectors/__init__.py +266 -0
  13. lsurf/detectors/analysis.py +289 -0
  14. lsurf/detectors/base.py +284 -0
  15. lsurf/detectors/constant_size_rings.py +485 -0
  16. lsurf/detectors/directional.py +45 -0
  17. lsurf/detectors/extended/__init__.py +73 -0
  18. lsurf/detectors/extended/local_sphere.py +353 -0
  19. lsurf/detectors/extended/recording_sphere.py +368 -0
  20. lsurf/detectors/planar.py +45 -0
  21. lsurf/detectors/protocol.py +187 -0
  22. lsurf/detectors/recording_spheres.py +63 -0
  23. lsurf/detectors/results.py +1140 -0
  24. lsurf/detectors/small/__init__.py +79 -0
  25. lsurf/detectors/small/directional.py +330 -0
  26. lsurf/detectors/small/planar.py +401 -0
  27. lsurf/detectors/small/spherical.py +450 -0
  28. lsurf/detectors/spherical.py +45 -0
  29. lsurf/geometry/__init__.py +199 -0
  30. lsurf/geometry/builder.py +478 -0
  31. lsurf/geometry/cell.py +228 -0
  32. lsurf/geometry/cell_geometry.py +247 -0
  33. lsurf/geometry/detector_arrays.py +1785 -0
  34. lsurf/geometry/geometry.py +222 -0
  35. lsurf/geometry/surface_analysis.py +375 -0
  36. lsurf/geometry/validation.py +91 -0
  37. lsurf/gui/__init__.py +51 -0
  38. lsurf/gui/app.py +903 -0
  39. lsurf/gui/core/__init__.py +39 -0
  40. lsurf/gui/core/scene.py +343 -0
  41. lsurf/gui/core/simulation.py +264 -0
  42. lsurf/gui/renderers/__init__.py +40 -0
  43. lsurf/gui/renderers/ray_renderer.py +353 -0
  44. lsurf/gui/renderers/source_renderer.py +505 -0
  45. lsurf/gui/renderers/surface_renderer.py +477 -0
  46. lsurf/gui/views/__init__.py +48 -0
  47. lsurf/gui/views/config_editor.py +3199 -0
  48. lsurf/gui/views/properties.py +257 -0
  49. lsurf/gui/views/results.py +291 -0
  50. lsurf/gui/views/scene_tree.py +180 -0
  51. lsurf/gui/views/viewport_3d.py +555 -0
  52. lsurf/gui/views/visualizations.py +712 -0
  53. lsurf/materials/__init__.py +169 -0
  54. lsurf/materials/base/__init__.py +64 -0
  55. lsurf/materials/base/full_inhomogeneous.py +208 -0
  56. lsurf/materials/base/grid_inhomogeneous.py +319 -0
  57. lsurf/materials/base/homogeneous.py +342 -0
  58. lsurf/materials/base/material_field.py +527 -0
  59. lsurf/materials/base/simple_inhomogeneous.py +418 -0
  60. lsurf/materials/base/spectral_inhomogeneous.py +497 -0
  61. lsurf/materials/implementations/__init__.py +120 -0
  62. lsurf/materials/implementations/data/alpha_values_typical_atmosphere_updated.txt +24 -0
  63. lsurf/materials/implementations/duct_atmosphere.py +390 -0
  64. lsurf/materials/implementations/exponential_atmosphere.py +435 -0
  65. lsurf/materials/implementations/gaussian_lens.py +120 -0
  66. lsurf/materials/implementations/interpolated_data.py +123 -0
  67. lsurf/materials/implementations/layered_atmosphere.py +134 -0
  68. lsurf/materials/implementations/linear_gradient.py +109 -0
  69. lsurf/materials/implementations/linsley_atmosphere.py +764 -0
  70. lsurf/materials/implementations/standard_materials.py +126 -0
  71. lsurf/materials/implementations/turbulent_atmosphere.py +135 -0
  72. lsurf/materials/implementations/us_standard_atmosphere.py +149 -0
  73. lsurf/materials/utils/__init__.py +77 -0
  74. lsurf/materials/utils/constants.py +45 -0
  75. lsurf/materials/utils/device_functions.py +117 -0
  76. lsurf/materials/utils/dispersion.py +160 -0
  77. lsurf/materials/utils/factories.py +142 -0
  78. lsurf/propagation/__init__.py +91 -0
  79. lsurf/propagation/detector_gpu.py +67 -0
  80. lsurf/propagation/gpu_device_rays.py +294 -0
  81. lsurf/propagation/kernels/__init__.py +175 -0
  82. lsurf/propagation/kernels/absorption/__init__.py +61 -0
  83. lsurf/propagation/kernels/absorption/grid.py +240 -0
  84. lsurf/propagation/kernels/absorption/simple.py +232 -0
  85. lsurf/propagation/kernels/absorption/spectral.py +410 -0
  86. lsurf/propagation/kernels/detection/__init__.py +64 -0
  87. lsurf/propagation/kernels/detection/protocol.py +102 -0
  88. lsurf/propagation/kernels/detection/spherical.py +255 -0
  89. lsurf/propagation/kernels/device_functions.py +790 -0
  90. lsurf/propagation/kernels/fresnel/__init__.py +64 -0
  91. lsurf/propagation/kernels/fresnel/protocol.py +97 -0
  92. lsurf/propagation/kernels/fresnel/standard.py +258 -0
  93. lsurf/propagation/kernels/intersection/__init__.py +79 -0
  94. lsurf/propagation/kernels/intersection/annular_plane.py +207 -0
  95. lsurf/propagation/kernels/intersection/bounded_plane.py +205 -0
  96. lsurf/propagation/kernels/intersection/plane.py +166 -0
  97. lsurf/propagation/kernels/intersection/protocol.py +95 -0
  98. lsurf/propagation/kernels/intersection/signed_distance.py +742 -0
  99. lsurf/propagation/kernels/intersection/sphere.py +190 -0
  100. lsurf/propagation/kernels/propagation/__init__.py +85 -0
  101. lsurf/propagation/kernels/propagation/grid.py +527 -0
  102. lsurf/propagation/kernels/propagation/protocol.py +105 -0
  103. lsurf/propagation/kernels/propagation/simple.py +460 -0
  104. lsurf/propagation/kernels/propagation/spectral.py +875 -0
  105. lsurf/propagation/kernels/registry.py +331 -0
  106. lsurf/propagation/kernels/surface/__init__.py +72 -0
  107. lsurf/propagation/kernels/surface/bisection.py +232 -0
  108. lsurf/propagation/kernels/surface/detection.py +402 -0
  109. lsurf/propagation/kernels/surface/reduction.py +166 -0
  110. lsurf/propagation/propagator_protocol.py +222 -0
  111. lsurf/propagation/propagators/__init__.py +101 -0
  112. lsurf/propagation/propagators/detector_handler.py +354 -0
  113. lsurf/propagation/propagators/factory.py +200 -0
  114. lsurf/propagation/propagators/fresnel_handler.py +305 -0
  115. lsurf/propagation/propagators/gpu_gradient.py +566 -0
  116. lsurf/propagation/propagators/gpu_surface_propagator.py +707 -0
  117. lsurf/propagation/propagators/gradient.py +429 -0
  118. lsurf/propagation/propagators/intersection_handler.py +327 -0
  119. lsurf/propagation/propagators/material_propagator.py +398 -0
  120. lsurf/propagation/propagators/signed_distance_handler.py +522 -0
  121. lsurf/propagation/propagators/spectral_gpu_gradient.py +553 -0
  122. lsurf/propagation/propagators/surface_interaction.py +616 -0
  123. lsurf/propagation/propagators/surface_propagator.py +719 -0
  124. lsurf/py.typed +1 -0
  125. lsurf/simulation/__init__.py +70 -0
  126. lsurf/simulation/config.py +164 -0
  127. lsurf/simulation/orchestrator.py +462 -0
  128. lsurf/simulation/result.py +299 -0
  129. lsurf/simulation/simulation.py +262 -0
  130. lsurf/sources/__init__.py +128 -0
  131. lsurf/sources/base.py +264 -0
  132. lsurf/sources/collimated.py +252 -0
  133. lsurf/sources/custom.py +409 -0
  134. lsurf/sources/diverging.py +228 -0
  135. lsurf/sources/gaussian.py +272 -0
  136. lsurf/sources/parallel_from_positions.py +197 -0
  137. lsurf/sources/point.py +172 -0
  138. lsurf/sources/uniform_diverging.py +258 -0
  139. lsurf/surfaces/__init__.py +184 -0
  140. lsurf/surfaces/cpu/__init__.py +50 -0
  141. lsurf/surfaces/cpu/curved_wave.py +463 -0
  142. lsurf/surfaces/cpu/gerstner_wave.py +381 -0
  143. lsurf/surfaces/cpu/wave_params.py +118 -0
  144. lsurf/surfaces/gpu/__init__.py +72 -0
  145. lsurf/surfaces/gpu/annular_plane.py +453 -0
  146. lsurf/surfaces/gpu/bounded_plane.py +390 -0
  147. lsurf/surfaces/gpu/curved_wave.py +483 -0
  148. lsurf/surfaces/gpu/gerstner_wave.py +377 -0
  149. lsurf/surfaces/gpu/multi_curved_wave.py +520 -0
  150. lsurf/surfaces/gpu/plane.py +299 -0
  151. lsurf/surfaces/gpu/recording_sphere.py +587 -0
  152. lsurf/surfaces/gpu/sphere.py +311 -0
  153. lsurf/surfaces/protocol.py +336 -0
  154. lsurf/surfaces/registry.py +373 -0
  155. lsurf/utilities/__init__.py +175 -0
  156. lsurf/utilities/detector_analysis.py +814 -0
  157. lsurf/utilities/fresnel.py +628 -0
  158. lsurf/utilities/interactions.py +1215 -0
  159. lsurf/utilities/propagation.py +602 -0
  160. lsurf/utilities/ray_data.py +532 -0
  161. lsurf/utilities/recording_sphere.py +745 -0
  162. lsurf/utilities/time_spread.py +463 -0
  163. lsurf/visualization/__init__.py +329 -0
  164. lsurf/visualization/absorption_plots.py +334 -0
  165. lsurf/visualization/atmospheric_plots.py +754 -0
  166. lsurf/visualization/common.py +348 -0
  167. lsurf/visualization/detector_plots.py +1350 -0
  168. lsurf/visualization/detector_sphere_plots.py +1173 -0
  169. lsurf/visualization/fresnel_plots.py +1061 -0
  170. lsurf/visualization/ocean_simulation_plots.py +999 -0
  171. lsurf/visualization/polarization_plots.py +916 -0
  172. lsurf/visualization/raytracing_plots.py +1521 -0
  173. lsurf/visualization/ring_detector_plots.py +1867 -0
  174. lsurf/visualization/time_spread_plots.py +531 -0
  175. lsurf-1.0.0.dist-info/METADATA +381 -0
  176. lsurf-1.0.0.dist-info/RECORD +180 -0
  177. lsurf-1.0.0.dist-info/WHEEL +5 -0
  178. lsurf-1.0.0.dist-info/entry_points.txt +2 -0
  179. lsurf-1.0.0.dist-info/licenses/LICENSE +32 -0
  180. 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}")