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
@@ -0,0 +1,850 @@
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
+ """Interactive wizard helpers for L-SURF CLI.
35
+
36
+ This module provides questionary-based interactive prompts for building
37
+ geometry configurations step-by-step.
38
+ """
39
+
40
+ from typing import Any
41
+
42
+ import questionary
43
+ from questionary import Style
44
+
45
+ from .config_schema import (
46
+ DetectorConfig,
47
+ LSURFConfig,
48
+ MediaConfig,
49
+ OutputConfig,
50
+ SourceConfig,
51
+ SurfaceConfig,
52
+ )
53
+ from ..simulation import SimulationConfig
54
+
55
+ # Custom style for questionary prompts
56
+ LSURF_STYLE = Style(
57
+ [
58
+ ("qmark", "fg:cyan bold"),
59
+ ("question", "bold"),
60
+ ("answer", "fg:green"),
61
+ ("pointer", "fg:cyan bold"),
62
+ ("highlighted", "fg:cyan bold"),
63
+ ("selected", "fg:green"),
64
+ ("separator", "fg:gray"),
65
+ ("instruction", "fg:gray"),
66
+ ]
67
+ )
68
+
69
+
70
+ # =============================================================================
71
+ # Pre-built Templates
72
+ # =============================================================================
73
+
74
+ TEMPLATES: dict[str, dict[str, Any]] = {
75
+ "ocean": {
76
+ "description": "Ocean surface reflection with exponential atmosphere",
77
+ "config": {
78
+ "version": "1.0",
79
+ "media": {
80
+ "atmosphere": {
81
+ "type": "exponential_atmosphere",
82
+ "params": {"scale_height": 8500.0, "n_sea_level": 1.000293},
83
+ },
84
+ "ocean": {"type": "water", "params": {}},
85
+ },
86
+ "background": "atmosphere",
87
+ "source": {
88
+ "type": "collimated_beam",
89
+ "params": {
90
+ "center": [0.0, 0.0, 100000.0],
91
+ "direction": [0.0, 0.0, -1.0],
92
+ "beam_radius": 0.005,
93
+ "num_rays": 100000,
94
+ "wavelength": 532e-9,
95
+ },
96
+ },
97
+ "surfaces": [
98
+ {
99
+ "name": "ocean_surface",
100
+ "type": "curved_wave",
101
+ "role": "optical",
102
+ "front_medium": "atmosphere",
103
+ "back_medium": "ocean",
104
+ "params": {
105
+ "amplitude": 0.5,
106
+ "wavelength": 100.0,
107
+ "direction": [1.0, 0.0],
108
+ "earth_center": [0.0, 0.0, -6.371e6],
109
+ "earth_radius": 6.371e6,
110
+ },
111
+ }
112
+ ],
113
+ "detectors": [
114
+ {
115
+ "name": "sky_detector",
116
+ "type": "recording_sphere",
117
+ "params": {"center": [0.0, 0.0, -6.371e6], "radius": 6.404e6},
118
+ }
119
+ ],
120
+ "simulation": {
121
+ "step_size": 100.0,
122
+ "max_bounces": 1,
123
+ "polarization": "unpolarized",
124
+ "use_gpu": True,
125
+ },
126
+ "output": {"directory": "./results", "format": "hdf5"},
127
+ },
128
+ },
129
+ "glass": {
130
+ "description": "Light passing through a glass plate",
131
+ "config": {
132
+ "version": "1.0",
133
+ "media": {
134
+ "air": {"type": "air", "params": {}},
135
+ "glass": {"type": "glass", "params": {"refractive_index": 1.5168}},
136
+ },
137
+ "background": "air",
138
+ "source": {
139
+ "type": "collimated_beam",
140
+ "params": {
141
+ "center": [0.0, 0.0, -0.1],
142
+ "direction": [0.0, 0.0, 1.0],
143
+ "beam_radius": 0.01,
144
+ "num_rays": 10000,
145
+ "wavelength": 632.8e-9,
146
+ },
147
+ },
148
+ "surfaces": [
149
+ {
150
+ "name": "front_surface",
151
+ "type": "plane",
152
+ "role": "optical",
153
+ "front_medium": "air",
154
+ "back_medium": "glass",
155
+ "params": {"point": [0.0, 0.0, 0.0], "normal": [0.0, 0.0, -1.0]},
156
+ },
157
+ {
158
+ "name": "back_surface",
159
+ "type": "plane",
160
+ "role": "optical",
161
+ "front_medium": "glass",
162
+ "back_medium": "air",
163
+ "params": {"point": [0.0, 0.0, 0.01], "normal": [0.0, 0.0, -1.0]},
164
+ },
165
+ ],
166
+ "detectors": [
167
+ {
168
+ "name": "detector",
169
+ "type": "planar",
170
+ "params": {
171
+ "center": [0.0, 0.0, 0.1],
172
+ "normal": [0.0, 0.0, -1.0],
173
+ "width": 0.1,
174
+ "height": 0.1,
175
+ },
176
+ }
177
+ ],
178
+ "simulation": {"step_size": 0.001, "max_bounces": 4, "use_gpu": True},
179
+ "output": {"directory": "./results", "format": "numpy"},
180
+ },
181
+ },
182
+ "atmosphere": {
183
+ "description": "Atmospheric refraction with exponential profile",
184
+ "config": {
185
+ "version": "1.0",
186
+ "media": {
187
+ "atmosphere": {
188
+ "type": "exponential_atmosphere",
189
+ "params": {"scale_height": 8500.0},
190
+ }
191
+ },
192
+ "background": "atmosphere",
193
+ "source": {
194
+ "type": "collimated_beam",
195
+ "params": {
196
+ "center": [0.0, 0.0, 0.0],
197
+ "direction": [0.0, 0.0, 1.0],
198
+ "beam_radius": 0.001,
199
+ "num_rays": 1000,
200
+ "wavelength": 550e-9,
201
+ },
202
+ },
203
+ "surfaces": [],
204
+ "detectors": [
205
+ {
206
+ "name": "altitude_detector",
207
+ "type": "recording_sphere",
208
+ "params": {"center": [0.0, 0.0, -6.371e6], "radius": 6.406e6},
209
+ }
210
+ ],
211
+ "simulation": {
212
+ "step_size": 100.0,
213
+ "max_bounces": 0,
214
+ "bounding_radius": 50000.0,
215
+ "use_gpu": True,
216
+ },
217
+ "output": {"directory": "./results", "format": "hdf5"},
218
+ },
219
+ },
220
+ }
221
+
222
+
223
+ def get_template_names() -> list[str]:
224
+ """Return list of available template names."""
225
+ return list(TEMPLATES.keys())
226
+
227
+
228
+ def get_template(name: str) -> dict[str, Any] | None:
229
+ """Get a template configuration by name."""
230
+ template = TEMPLATES.get(name)
231
+ return template["config"] if template else None
232
+
233
+
234
+ def get_template_description(name: str) -> str:
235
+ """Get a template description by name."""
236
+ template = TEMPLATES.get(name)
237
+ return template["description"] if template else ""
238
+
239
+
240
+ # =============================================================================
241
+ # Interactive Prompts
242
+ # =============================================================================
243
+
244
+
245
+ def prompt_media() -> dict[str, MediaConfig]:
246
+ """Interactively configure media/materials."""
247
+ media: dict[str, MediaConfig] = {}
248
+
249
+ questionary.print("\n=== Media Configuration ===", style="bold fg:cyan")
250
+ questionary.print(
251
+ "Define the materials/media for your simulation.\n", style="fg:gray"
252
+ )
253
+
254
+ # Always add at least one medium
255
+ while True:
256
+ name = questionary.text(
257
+ "Medium name (e.g., 'air', 'water', 'atmosphere'):",
258
+ style=LSURF_STYLE,
259
+ ).ask()
260
+
261
+ if not name:
262
+ if not media:
263
+ questionary.print("At least one medium is required.", style="fg:red")
264
+ continue
265
+ break
266
+
267
+ media_type = questionary.select(
268
+ f"Type for '{name}':",
269
+ choices=[
270
+ questionary.Choice("vacuum - Perfect vacuum (n=1.0)", value="vacuum"),
271
+ questionary.Choice("air - Air at STP (n=1.000293)", value="air"),
272
+ questionary.Choice("water - Water (n=1.333)", value="water"),
273
+ questionary.Choice(
274
+ "glass - BK7 optical glass (n=1.5168)", value="glass"
275
+ ),
276
+ questionary.Choice(
277
+ "homogeneous - Custom refractive index", value="homogeneous"
278
+ ),
279
+ questionary.Choice(
280
+ "exponential_atmosphere - Exponential density profile",
281
+ value="exponential_atmosphere",
282
+ ),
283
+ ],
284
+ style=LSURF_STYLE,
285
+ ).ask()
286
+
287
+ params: dict[str, Any] = {}
288
+
289
+ if media_type == "homogeneous":
290
+ n = questionary.text(
291
+ "Refractive index:",
292
+ default="1.5",
293
+ style=LSURF_STYLE,
294
+ ).ask()
295
+ params["refractive_index"] = float(n)
296
+
297
+ absorption = questionary.text(
298
+ "Absorption coefficient (m^-1, 0 for none):",
299
+ default="0.0",
300
+ style=LSURF_STYLE,
301
+ ).ask()
302
+ if float(absorption) > 0:
303
+ params["absorption_coef"] = float(absorption)
304
+
305
+ elif media_type == "exponential_atmosphere":
306
+ scale_height = questionary.text(
307
+ "Scale height (m):",
308
+ default="8500.0",
309
+ style=LSURF_STYLE,
310
+ ).ask()
311
+ params["scale_height"] = float(scale_height)
312
+
313
+ n_sea = questionary.text(
314
+ "Refractive index at sea level:",
315
+ default="1.000293",
316
+ style=LSURF_STYLE,
317
+ ).ask()
318
+ params["n_sea_level"] = float(n_sea)
319
+
320
+ media[name] = MediaConfig(type=media_type, params=params)
321
+
322
+ add_more = questionary.confirm(
323
+ "Add another medium?",
324
+ default=False,
325
+ style=LSURF_STYLE,
326
+ ).ask()
327
+
328
+ if not add_more:
329
+ break
330
+
331
+ return media
332
+
333
+
334
+ def prompt_background(media_names: list[str]) -> str | None:
335
+ """Prompt for background medium selection."""
336
+ if not media_names:
337
+ return None
338
+
339
+ questionary.print("\n=== Background Medium ===", style="bold fg:cyan")
340
+
341
+ background = questionary.select(
342
+ "Select the background medium for ray propagation:",
343
+ choices=media_names + ["(none)"],
344
+ style=LSURF_STYLE,
345
+ ).ask()
346
+
347
+ return background if background != "(none)" else None
348
+
349
+
350
+ def prompt_source() -> SourceConfig:
351
+ """Interactively configure the ray source."""
352
+ questionary.print("\n=== Source Configuration ===", style="bold fg:cyan")
353
+
354
+ source_type = questionary.select(
355
+ "Source type:",
356
+ choices=[
357
+ questionary.Choice(
358
+ "collimated_beam - Parallel rays (laser, sun)", value="collimated_beam"
359
+ ),
360
+ questionary.Choice("point - Isotropic point source", value="point"),
361
+ questionary.Choice(
362
+ "diverging_beam - Cone-shaped beam (LED, fiber)", value="diverging_beam"
363
+ ),
364
+ questionary.Choice(
365
+ "gaussian_beam - Paraxial Gaussian beam", value="gaussian_beam"
366
+ ),
367
+ ],
368
+ style=LSURF_STYLE,
369
+ ).ask()
370
+
371
+ params: dict[str, Any] = {}
372
+
373
+ # Common parameters
374
+ num_rays = questionary.text(
375
+ "Number of rays:",
376
+ default="10000",
377
+ style=LSURF_STYLE,
378
+ ).ask()
379
+ params["num_rays"] = int(num_rays)
380
+
381
+ wavelength = questionary.text(
382
+ "Wavelength (m, e.g., 532e-9 for green laser):",
383
+ default="532e-9",
384
+ style=LSURF_STYLE,
385
+ ).ask()
386
+ params["wavelength"] = float(wavelength)
387
+
388
+ if source_type == "collimated_beam":
389
+ center = _prompt_vector("Beam center position [x, y, z]:", "[0, 0, 100000]")
390
+ params["center"] = center
391
+
392
+ direction = _prompt_vector("Beam direction [x, y, z]:", "[0, 0, -1]")
393
+ params["direction"] = direction
394
+
395
+ radius = questionary.text(
396
+ "Beam radius (m):",
397
+ default="0.005",
398
+ style=LSURF_STYLE,
399
+ ).ask()
400
+ params["beam_radius"] = float(radius)
401
+
402
+ elif source_type == "point":
403
+ position = _prompt_vector("Source position [x, y, z]:", "[0, 0, 0]")
404
+ params["position"] = position
405
+
406
+ elif source_type == "diverging_beam":
407
+ origin = _prompt_vector("Origin position [x, y, z]:", "[0, 0, 0]")
408
+ params["origin"] = origin
409
+
410
+ direction = _prompt_vector("Mean direction [x, y, z]:", "[0, 0, 1]")
411
+ params["mean_direction"] = direction
412
+
413
+ angle = questionary.text(
414
+ "Divergence half-angle (radians):",
415
+ default="0.1",
416
+ style=LSURF_STYLE,
417
+ ).ask()
418
+ params["divergence_angle"] = float(angle)
419
+
420
+ elif source_type == "gaussian_beam":
421
+ waist_pos = _prompt_vector("Waist position [x, y, z]:", "[0, 0, 0]")
422
+ params["waist_position"] = waist_pos
423
+
424
+ direction = _prompt_vector("Propagation direction [x, y, z]:", "[0, 0, 1]")
425
+ params["direction"] = direction
426
+
427
+ waist_radius = questionary.text(
428
+ "Waist radius (m):",
429
+ default="0.001",
430
+ style=LSURF_STYLE,
431
+ ).ask()
432
+ params["waist_radius"] = float(waist_radius)
433
+
434
+ return SourceConfig(type=source_type, params=params)
435
+
436
+
437
+ def prompt_surfaces(media_names: list[str]) -> list[SurfaceConfig]:
438
+ """Interactively configure surfaces."""
439
+ surfaces: list[SurfaceConfig] = []
440
+
441
+ questionary.print("\n=== Surface Configuration ===", style="bold fg:cyan")
442
+ questionary.print(
443
+ "Define optical surfaces for ray interactions.\n", style="fg:gray"
444
+ )
445
+
446
+ while True:
447
+ add_surface = questionary.confirm(
448
+ "Add a surface?" if not surfaces else "Add another surface?",
449
+ default=not surfaces, # Default yes for first surface
450
+ style=LSURF_STYLE,
451
+ ).ask()
452
+
453
+ if not add_surface:
454
+ break
455
+
456
+ name = questionary.text(
457
+ "Surface name:",
458
+ default=f"surface_{len(surfaces) + 1}",
459
+ style=LSURF_STYLE,
460
+ ).ask()
461
+
462
+ surface_type = questionary.select(
463
+ "Surface type:",
464
+ choices=[
465
+ questionary.Choice("plane - Infinite flat plane", value="plane"),
466
+ questionary.Choice(
467
+ "bounded_plane - Finite rectangular plane", value="bounded_plane"
468
+ ),
469
+ questionary.Choice("sphere - Spherical surface", value="sphere"),
470
+ questionary.Choice(
471
+ "curved_wave - Ocean wave (curved Earth)", value="curved_wave"
472
+ ),
473
+ questionary.Choice(
474
+ "gerstner_wave - Ocean wave (flat Earth)", value="gerstner_wave"
475
+ ),
476
+ ],
477
+ style=LSURF_STYLE,
478
+ ).ask()
479
+
480
+ role = questionary.select(
481
+ "Surface role:",
482
+ choices=[
483
+ questionary.Choice(
484
+ "optical - Reflect/refract rays (Fresnel)", value="optical"
485
+ ),
486
+ questionary.Choice(
487
+ "detector - Record and terminate rays", value="detector"
488
+ ),
489
+ questionary.Choice("absorber - Apply absorption", value="absorber"),
490
+ ],
491
+ style=LSURF_STYLE,
492
+ ).ask()
493
+
494
+ # Media selection for optical surfaces
495
+ front_medium = None
496
+ back_medium = None
497
+ if role == "optical" and media_names:
498
+ front_medium = questionary.select(
499
+ "Front medium (rays coming from):",
500
+ choices=media_names + ["(none)"],
501
+ style=LSURF_STYLE,
502
+ ).ask()
503
+ if front_medium == "(none)":
504
+ front_medium = None
505
+
506
+ back_medium = questionary.select(
507
+ "Back medium (rays going to):",
508
+ choices=media_names + ["(none)"],
509
+ style=LSURF_STYLE,
510
+ ).ask()
511
+ if back_medium == "(none)":
512
+ back_medium = None
513
+
514
+ params: dict[str, Any] = {}
515
+
516
+ if surface_type == "plane":
517
+ point = _prompt_vector("Point on plane [x, y, z]:", "[0, 0, 0]")
518
+ params["point"] = point
519
+
520
+ normal = _prompt_vector("Surface normal [x, y, z]:", "[0, 0, 1]")
521
+ params["normal"] = normal
522
+
523
+ elif surface_type == "bounded_plane":
524
+ point = _prompt_vector("Center point [x, y, z]:", "[0, 0, 0]")
525
+ params["point"] = point
526
+
527
+ normal = _prompt_vector("Surface normal [x, y, z]:", "[0, 0, 1]")
528
+ params["normal"] = normal
529
+
530
+ width = questionary.text(
531
+ "Width (m):", default="1.0", style=LSURF_STYLE
532
+ ).ask()
533
+ params["width"] = float(width)
534
+
535
+ height = questionary.text(
536
+ "Height (m):", default="1.0", style=LSURF_STYLE
537
+ ).ask()
538
+ params["height"] = float(height)
539
+
540
+ elif surface_type == "sphere":
541
+ center = _prompt_vector("Sphere center [x, y, z]:", "[0, 0, 0]")
542
+ params["center"] = center
543
+
544
+ radius = questionary.text(
545
+ "Radius (m, positive=convex, negative=concave):",
546
+ default="1.0",
547
+ style=LSURF_STYLE,
548
+ ).ask()
549
+ params["radius"] = float(radius)
550
+
551
+ elif surface_type in ("curved_wave", "gerstner_wave"):
552
+ amplitude = questionary.text(
553
+ "Wave amplitude (m):",
554
+ default="0.5",
555
+ style=LSURF_STYLE,
556
+ ).ask()
557
+ params["amplitude"] = float(amplitude)
558
+
559
+ wave_length = questionary.text(
560
+ "Wave wavelength (m):",
561
+ default="100.0",
562
+ style=LSURF_STYLE,
563
+ ).ask()
564
+ params["wavelength"] = float(wave_length)
565
+
566
+ direction = _prompt_vector("Wave direction [x, y]:", "[1, 0]")
567
+ params["direction"] = direction[:2] if len(direction) > 2 else direction
568
+
569
+ if surface_type == "curved_wave":
570
+ earth_center = _prompt_vector(
571
+ "Earth center [x, y, z]:", "[0, 0, -6.371e6]"
572
+ )
573
+ params["earth_center"] = earth_center
574
+
575
+ earth_radius = questionary.text(
576
+ "Earth radius (m):",
577
+ default="6.371e6",
578
+ style=LSURF_STYLE,
579
+ ).ask()
580
+ params["earth_radius"] = float(earth_radius)
581
+
582
+ surfaces.append(
583
+ SurfaceConfig(
584
+ name=name,
585
+ type=surface_type,
586
+ role=role,
587
+ front_medium=front_medium,
588
+ back_medium=back_medium,
589
+ params=params,
590
+ )
591
+ )
592
+
593
+ return surfaces
594
+
595
+
596
+ def prompt_detectors() -> list[DetectorConfig]:
597
+ """Interactively configure detectors."""
598
+ detectors: list[DetectorConfig] = []
599
+
600
+ questionary.print("\n=== Detector Configuration ===", style="bold fg:cyan")
601
+ questionary.print(
602
+ "Define detectors to record ray intersections.\n", style="fg:gray"
603
+ )
604
+
605
+ while True:
606
+ add_detector = questionary.confirm(
607
+ "Add a detector?" if not detectors else "Add another detector?",
608
+ default=not detectors, # Default yes for first detector
609
+ style=LSURF_STYLE,
610
+ ).ask()
611
+
612
+ if not add_detector:
613
+ break
614
+
615
+ name = questionary.text(
616
+ "Detector name:",
617
+ default=f"detector_{len(detectors) + 1}",
618
+ style=LSURF_STYLE,
619
+ ).ask()
620
+
621
+ detector_type = questionary.select(
622
+ "Detector type:",
623
+ choices=[
624
+ questionary.Choice(
625
+ "recording_sphere - Spherical surface at altitude",
626
+ value="recording_sphere",
627
+ ),
628
+ questionary.Choice(
629
+ "spherical - Small spherical detector", value="spherical"
630
+ ),
631
+ questionary.Choice(
632
+ "planar - Flat rectangular detector", value="planar"
633
+ ),
634
+ questionary.Choice(
635
+ "directional - Detector with acceptance angle", value="directional"
636
+ ),
637
+ ],
638
+ style=LSURF_STYLE,
639
+ ).ask()
640
+
641
+ params: dict[str, Any] = {}
642
+
643
+ if detector_type == "recording_sphere":
644
+ center = _prompt_vector("Sphere center [x, y, z]:", "[0, 0, -6.371e6]")
645
+ params["center"] = center
646
+
647
+ radius = questionary.text(
648
+ "Radius (m):",
649
+ default="6.404e6",
650
+ style=LSURF_STYLE,
651
+ ).ask()
652
+ params["radius"] = float(radius)
653
+
654
+ elif detector_type == "spherical":
655
+ center = _prompt_vector("Center [x, y, z]:", "[0, 0, 0]")
656
+ params["center"] = center
657
+
658
+ radius = questionary.text(
659
+ "Radius (m):",
660
+ default="0.01",
661
+ style=LSURF_STYLE,
662
+ ).ask()
663
+ params["radius"] = float(radius)
664
+
665
+ elif detector_type == "planar":
666
+ center = _prompt_vector("Center [x, y, z]:", "[0, 0, 1]")
667
+ params["center"] = center
668
+
669
+ normal = _prompt_vector("Normal [x, y, z]:", "[0, 0, -1]")
670
+ params["normal"] = normal
671
+
672
+ width = questionary.text(
673
+ "Width (m):", default="0.1", style=LSURF_STYLE
674
+ ).ask()
675
+ params["width"] = float(width)
676
+
677
+ height = questionary.text(
678
+ "Height (m):", default="0.1", style=LSURF_STYLE
679
+ ).ask()
680
+ params["height"] = float(height)
681
+
682
+ elif detector_type == "directional":
683
+ position = _prompt_vector("Position [x, y, z]:", "[0, 0, 1]")
684
+ params["position"] = position
685
+
686
+ direction = _prompt_vector("Look direction [x, y, z]:", "[0, 0, -1]")
687
+ params["direction"] = direction
688
+
689
+ angle = questionary.text(
690
+ "Acceptance half-angle (radians):",
691
+ default="0.1",
692
+ style=LSURF_STYLE,
693
+ ).ask()
694
+ params["acceptance_angle"] = float(angle)
695
+
696
+ radius = questionary.text(
697
+ "Detector radius (m):",
698
+ default="0.01",
699
+ style=LSURF_STYLE,
700
+ ).ask()
701
+ params["radius"] = float(radius)
702
+
703
+ detectors.append(DetectorConfig(name=name, type=detector_type, params=params))
704
+
705
+ return detectors
706
+
707
+
708
+ def prompt_simulation() -> SimulationConfig:
709
+ """Interactively configure simulation parameters."""
710
+ questionary.print("\n=== Simulation Parameters ===", style="bold fg:cyan")
711
+
712
+ use_defaults = questionary.confirm(
713
+ "Use default simulation parameters?",
714
+ default=True,
715
+ style=LSURF_STYLE,
716
+ ).ask()
717
+
718
+ if use_defaults:
719
+ return SimulationConfig()
720
+
721
+ step_size = questionary.text(
722
+ "Step size (m):",
723
+ default="100.0",
724
+ style=LSURF_STYLE,
725
+ ).ask()
726
+
727
+ max_bounces = questionary.text(
728
+ "Maximum bounces:",
729
+ default="10",
730
+ style=LSURF_STYLE,
731
+ ).ask()
732
+
733
+ polarization = questionary.select(
734
+ "Polarization:",
735
+ choices=[
736
+ questionary.Choice("unpolarized", value="unpolarized"),
737
+ questionary.Choice("s-polarized", value="s"),
738
+ questionary.Choice("p-polarized", value="p"),
739
+ ],
740
+ style=LSURF_STYLE,
741
+ ).ask()
742
+
743
+ use_gpu = questionary.confirm(
744
+ "Use GPU acceleration?",
745
+ default=True,
746
+ style=LSURF_STYLE,
747
+ ).ask()
748
+
749
+ return SimulationConfig(
750
+ step_size=float(step_size),
751
+ max_bounces=int(max_bounces),
752
+ polarization=polarization,
753
+ use_gpu=use_gpu,
754
+ )
755
+
756
+
757
+ def prompt_output() -> OutputConfig:
758
+ """Interactively configure output options."""
759
+ questionary.print("\n=== Output Configuration ===", style="bold fg:cyan")
760
+
761
+ directory = questionary.text(
762
+ "Output directory:",
763
+ default="./results",
764
+ style=LSURF_STYLE,
765
+ ).ask()
766
+
767
+ output_format = questionary.select(
768
+ "Output format:",
769
+ choices=[
770
+ questionary.Choice("hdf5 - Hierarchical Data Format", value="hdf5"),
771
+ questionary.Choice("numpy - NumPy binary files", value="numpy"),
772
+ questionary.Choice("csv - CSV text files", value="csv"),
773
+ ],
774
+ style=LSURF_STYLE,
775
+ ).ask()
776
+
777
+ save_paths = questionary.confirm(
778
+ "Save full ray paths? (large files)",
779
+ default=False,
780
+ style=LSURF_STYLE,
781
+ ).ask()
782
+
783
+ return OutputConfig(
784
+ directory=directory,
785
+ format=output_format,
786
+ save_ray_paths=save_paths,
787
+ )
788
+
789
+
790
+ def run_interactive_wizard() -> LSURFConfig:
791
+ """Run the full interactive configuration wizard.
792
+
793
+ Returns:
794
+ Complete LSURFConfig ready for serialization.
795
+ """
796
+ questionary.print("\n" + "=" * 60, style="fg:cyan")
797
+ questionary.print(" L-SURF Configuration Wizard", style="bold fg:cyan")
798
+ questionary.print("=" * 60 + "\n", style="fg:cyan")
799
+
800
+ # Step 1: Media
801
+ media = prompt_media()
802
+ media_names = list(media.keys())
803
+
804
+ # Step 2: Background
805
+ background = prompt_background(media_names)
806
+
807
+ # Step 3: Source
808
+ source = prompt_source()
809
+
810
+ # Step 4: Surfaces
811
+ surfaces = prompt_surfaces(media_names)
812
+
813
+ # Step 5: Detectors
814
+ detectors = prompt_detectors()
815
+
816
+ # Step 6: Simulation
817
+ simulation = prompt_simulation()
818
+
819
+ # Step 7: Output
820
+ output = prompt_output()
821
+
822
+ questionary.print("\n" + "=" * 60, style="fg:green")
823
+ questionary.print(" Configuration Complete!", style="bold fg:green")
824
+ questionary.print("=" * 60 + "\n", style="fg:green")
825
+
826
+ return LSURFConfig(
827
+ version="1.0",
828
+ media=media,
829
+ background=background,
830
+ source=source,
831
+ surfaces=surfaces,
832
+ detectors=detectors,
833
+ simulation=simulation,
834
+ output=output,
835
+ )
836
+
837
+
838
+ def _prompt_vector(message: str, default: str) -> list[float]:
839
+ """Prompt for a vector input and parse it."""
840
+ value = questionary.text(
841
+ message,
842
+ default=default,
843
+ style=LSURF_STYLE,
844
+ ).ask()
845
+
846
+ # Parse the vector string
847
+ # Remove brackets and split by comma
848
+ cleaned = value.strip("[]() ")
849
+ parts = [p.strip() for p in cleaned.split(",")]
850
+ return [float(p) for p in parts]