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,719 @@
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
+ """
35
+ Surface Propagator for GPU Ray Tracing
36
+
37
+ Orchestrates GPU kernel launches for ray propagation with multi-surface
38
+ detection. Uses separate kernels for propagation and intersection,
39
+ with GPU memory management handled in the propagator layer.
40
+
41
+ Architecture
42
+ ------------
43
+ This propagator follows the clean kernel architecture:
44
+ 1. Kernels = Pure CUDA device functions + parallel kernels (no memory management)
45
+ 2. Propagators = Orchestration + GPU memory management
46
+
47
+ The propagation loop:
48
+ 1. Take one propagation step for all active rays
49
+ 2. Check intersection with all surfaces
50
+ 3. Record hits and deactivate rays that hit
51
+ 4. Repeat until all rays hit or max_steps reached
52
+ """
53
+
54
+ from dataclasses import dataclass
55
+ from typing import TYPE_CHECKING
56
+
57
+ import numpy as np
58
+ import numpy.typing as npt
59
+
60
+ from .signed_distance_handler import (
61
+ compute_signed_distance_gpu,
62
+ )
63
+
64
+ # GPU support is optional
65
+ try:
66
+ from numba import cuda
67
+
68
+ HAS_CUDA = True
69
+ except ImportError:
70
+
71
+ class _FakeCuda:
72
+ """Fake cuda module for when numba is not installed."""
73
+
74
+ @staticmethod
75
+ def is_available():
76
+ return False
77
+
78
+ @staticmethod
79
+ def synchronize():
80
+ pass
81
+
82
+ @staticmethod
83
+ def to_device(arr):
84
+ return _FakeDeviceArray(arr)
85
+
86
+ class _FakeDeviceArray:
87
+ def __init__(self, arr):
88
+ self._arr = arr
89
+
90
+ def copy_to_host(self):
91
+ return self._arr
92
+
93
+ cuda = _FakeCuda() # type: ignore[assignment]
94
+ HAS_CUDA = False
95
+
96
+
97
+ if TYPE_CHECKING:
98
+ from ...utilities.ray_data import RayBatch
99
+
100
+ # 64 params allows up to 8 wave components for multi-wave surfaces
101
+ MAX_SURFACE_PARAMS = 64
102
+
103
+ # Speed of light
104
+ SPEED_OF_LIGHT = 299792458.0
105
+
106
+
107
+ @dataclass
108
+ class HitData:
109
+ """
110
+ Data about ray-surface intersections.
111
+
112
+ Attributes
113
+ ----------
114
+ hit_surface_idx : ndarray, shape (N,)
115
+ Index of surface hit for each ray (-1 = no hit)
116
+ hit_positions : ndarray, shape (N, 3)
117
+ Position of each intersection
118
+ hit_directions : ndarray, shape (N, 3)
119
+ Direction at each intersection
120
+ num_rays : int
121
+ Total number of rays
122
+ """
123
+
124
+ hit_surface_idx: npt.NDArray[np.int32]
125
+ hit_positions: npt.NDArray[np.float32]
126
+ hit_directions: npt.NDArray[np.float32]
127
+ num_rays: int
128
+
129
+ def get_hit_mask(self, surface_idx: int) -> npt.NDArray[np.bool_]:
130
+ """Return mask of rays that hit a specific surface."""
131
+ return self.hit_surface_idx == surface_idx
132
+
133
+ def get_no_hit_mask(self) -> npt.NDArray[np.bool_]:
134
+ """Return mask of rays that didn't hit any surface."""
135
+ return self.hit_surface_idx == -1
136
+
137
+ def count_hits(self, surface_idx: int) -> int:
138
+ """Count rays that hit a specific surface."""
139
+ return int(np.sum(self.hit_surface_idx == surface_idx))
140
+
141
+ def count_no_hits(self) -> int:
142
+ """Count rays that didn't hit any surface."""
143
+ return int(np.sum(self.hit_surface_idx == -1))
144
+
145
+
146
+ class SurfacePropagator:
147
+ """
148
+ GPU propagator with multi-surface intersection detection.
149
+
150
+ This propagator advances rays through a material while checking for
151
+ intersections with multiple surfaces. When a ray crosses any surface,
152
+ the intersection point is recorded and the ray is deactivated.
153
+ The hit data is returned for CPU post-processing based on surface role.
154
+
155
+ Parameters
156
+ ----------
157
+ material : GPUMaterialProtocol
158
+ The material to propagate through. Must support get_refractive_index()
159
+ and get_refractive_index_gradient() methods.
160
+ surfaces : list of Surface
161
+ List of surfaces to check for intersections.
162
+ method : str
163
+ Integration method: 'euler' (default).
164
+ threads_per_block : int
165
+ CUDA threads per block. Default: 256.
166
+ use_gpu : bool, optional
167
+ Whether to use GPU acceleration. If True (default), uses CUDA if
168
+ available, otherwise falls back to CPU.
169
+
170
+ Notes
171
+ -----
172
+ This propagator uses a step-by-step approach:
173
+ 1. Propagate rays one step
174
+ 2. Check all surfaces for intersection
175
+ 3. Record hits and deactivate hitting rays
176
+ 4. Repeat until done
177
+ """
178
+
179
+ def __init__(
180
+ self,
181
+ material,
182
+ surfaces: list,
183
+ method: str = "euler",
184
+ threads_per_block: int = 256,
185
+ use_gpu: bool = True,
186
+ apply_absorption: bool = False,
187
+ ):
188
+ if method not in ("euler",):
189
+ raise ValueError(f"method must be 'euler', got {method}")
190
+
191
+ self._material = material
192
+ self._surfaces = list(surfaces)
193
+ self.method = method
194
+ self.threads_per_block = threads_per_block
195
+ self._use_gpu = use_gpu and HAS_CUDA and cuda.is_available()
196
+ self._apply_absorption = apply_absorption
197
+
198
+ @property
199
+ def material(self):
200
+ """The material being propagated through."""
201
+ return self._material
202
+
203
+ @property
204
+ def surfaces(self) -> list:
205
+ """List of surfaces being checked for intersection."""
206
+ return self._surfaces
207
+
208
+ @property
209
+ def num_surfaces(self) -> int:
210
+ """Number of surfaces."""
211
+ return len(self._surfaces)
212
+
213
+ def _compute_min_surface_distance(
214
+ self,
215
+ positions: np.ndarray,
216
+ active_mask: np.ndarray,
217
+ ) -> np.ndarray:
218
+ """
219
+ Compute minimum absolute signed distance to any surface for each ray.
220
+
221
+ Parameters
222
+ ----------
223
+ positions : ndarray, shape (N, 3)
224
+ Ray positions.
225
+ active_mask : ndarray, shape (N,)
226
+ Boolean mask for active rays.
227
+
228
+ Returns
229
+ -------
230
+ ndarray, shape (N,)
231
+ Minimum distance to any surface for each ray. Inactive rays get inf.
232
+ """
233
+ num_rays = len(positions)
234
+ min_distances = np.full(num_rays, np.inf, dtype=np.float32)
235
+
236
+ if not np.any(active_mask):
237
+ return min_distances
238
+
239
+ active_indices = np.where(active_mask)[0]
240
+ active_positions = positions[active_indices].astype(np.float32)
241
+
242
+ for surface in self._surfaces:
243
+ if hasattr(surface, "signed_distance"):
244
+ # Use GPU if available and surface supports it
245
+ if (
246
+ self._use_gpu
247
+ and hasattr(surface, "gpu_capable")
248
+ and surface.gpu_capable
249
+ and hasattr(surface, "get_gpu_parameters")
250
+ ):
251
+ geometry_id = surface.geometry_id
252
+ params = surface.get_gpu_parameters()
253
+ sd = compute_signed_distance_gpu(
254
+ active_positions, geometry_id, params
255
+ )
256
+ else:
257
+ sd = surface.signed_distance(active_positions)
258
+
259
+ # Take absolute value (we care about distance, not which side)
260
+ abs_sd = np.abs(sd)
261
+ min_distances[active_indices] = np.minimum(
262
+ min_distances[active_indices], abs_sd
263
+ )
264
+
265
+ return min_distances
266
+
267
+ def propagate_to_surface(
268
+ self,
269
+ rays: "RayBatch",
270
+ step_size: float,
271
+ max_steps: int,
272
+ adaptive_stepping: bool = False,
273
+ min_step_size: float = 3e-4,
274
+ surface_proximity_factor: float = 0.5,
275
+ surface_proximity_threshold: float = 10.0,
276
+ ) -> HitData:
277
+ """
278
+ Propagate rays until any surface is hit (or max_steps reached).
279
+
280
+ Parameters
281
+ ----------
282
+ rays : RayBatch
283
+ Ray batch to propagate. Modified in-place.
284
+ step_size : float
285
+ Maximum integration step size in meters.
286
+ max_steps : int
287
+ Maximum number of propagation steps.
288
+ adaptive_stepping : bool, optional
289
+ Whether to use adaptive step sizing near surfaces (default False).
290
+ min_step_size : float, optional
291
+ Minimum step size in meters (default 3e-4 = 0.3mm → ~1ps resolution).
292
+ surface_proximity_factor : float, optional
293
+ Step = distance * factor when within threshold (default 0.5).
294
+ surface_proximity_threshold : float, optional
295
+ Distance within which to start adaptive stepping (default 10.0 m).
296
+
297
+ Returns
298
+ -------
299
+ HitData
300
+ Information about which rays hit which surfaces.
301
+ """
302
+ if self._use_gpu and self._all_surfaces_gpu_capable():
303
+ return self._propagate_gpu(
304
+ rays,
305
+ step_size,
306
+ max_steps,
307
+ adaptive_stepping=adaptive_stepping,
308
+ min_step_size=min_step_size,
309
+ surface_proximity_factor=surface_proximity_factor,
310
+ surface_proximity_threshold=surface_proximity_threshold,
311
+ )
312
+ return self._propagate_cpu(
313
+ rays,
314
+ step_size,
315
+ max_steps,
316
+ adaptive_stepping=adaptive_stepping,
317
+ min_step_size=min_step_size,
318
+ surface_proximity_factor=surface_proximity_factor,
319
+ surface_proximity_threshold=surface_proximity_threshold,
320
+ )
321
+
322
+ def _all_surfaces_gpu_capable(self) -> bool:
323
+ """Check if all surfaces support GPU acceleration."""
324
+ return all(getattr(s, "gpu_capable", False) for s in self._surfaces)
325
+
326
+ def _propagate_gpu(
327
+ self,
328
+ rays: "RayBatch",
329
+ step_size: float,
330
+ max_steps: int,
331
+ adaptive_stepping: bool = False,
332
+ min_step_size: float = 3e-4,
333
+ surface_proximity_factor: float = 0.5,
334
+ surface_proximity_threshold: float = 10.0,
335
+ ) -> HitData:
336
+ """GPU-accelerated propagation using GPUSurfacePropagator."""
337
+ from .gpu_surface_propagator import GPUSurfacePropagator
338
+
339
+ gpu_prop = GPUSurfacePropagator(
340
+ material=self._material,
341
+ surfaces=self._surfaces,
342
+ apply_absorption=self._apply_absorption,
343
+ )
344
+ return gpu_prop.propagate_to_surface(
345
+ rays,
346
+ step_size,
347
+ max_steps,
348
+ adaptive_stepping=adaptive_stepping,
349
+ min_step_size=min_step_size,
350
+ surface_proximity_factor=surface_proximity_factor,
351
+ surface_proximity_threshold=surface_proximity_threshold,
352
+ )
353
+
354
+ def _propagate_cpu(
355
+ self,
356
+ rays: "RayBatch",
357
+ step_size: float,
358
+ max_steps: int,
359
+ adaptive_stepping: bool = False,
360
+ min_step_size: float = 3e-4,
361
+ surface_proximity_factor: float = 0.5,
362
+ surface_proximity_threshold: float = 10.0,
363
+ ) -> HitData:
364
+ """
365
+ CPU implementation of propagation with surface detection.
366
+
367
+ Uses Euler integration for ray propagation through the material,
368
+ checking for surface intersections at each step.
369
+
370
+ When adaptive_stepping is enabled, step sizes are reduced as rays
371
+ approach surfaces to achieve sub-nanosecond timing precision.
372
+ """
373
+ num_rays = rays.num_rays
374
+ c = SPEED_OF_LIGHT
375
+
376
+ # Initialize hit tracking
377
+ hit_surface_idx = np.full(num_rays, -1, dtype=np.int32)
378
+ hit_positions = np.zeros((num_rays, 3), dtype=np.float32)
379
+ hit_directions = np.zeros((num_rays, 3), dtype=np.float32)
380
+
381
+ # Get working arrays
382
+ positions = rays.positions.copy()
383
+ directions = rays.directions.copy()
384
+ active = rays.active.copy()
385
+ geo_path = rays.geometric_path_lengths.copy()
386
+ opt_path = rays.optical_path_lengths.copy()
387
+ acc_time = rays.accumulated_time.copy()
388
+
389
+ # Per-ray step sizes (for adaptive stepping)
390
+ ray_step_sizes = np.full(num_rays, step_size, dtype=np.float32)
391
+
392
+ # Default wavelength for material queries
393
+ default_wavelength = 500e-9
394
+
395
+ for step in range(max_steps):
396
+ # Check if any rays are still active
397
+ if not np.any(active):
398
+ break
399
+
400
+ # Compute adaptive step sizes if enabled
401
+ if adaptive_stepping:
402
+ min_distances = self._compute_min_surface_distance(positions, active)
403
+
404
+ # Vectorized adaptive step computation
405
+ # For rays within threshold: step = distance * factor, clamped
406
+ # For rays outside threshold: use max step_size
407
+ within_threshold = min_distances < surface_proximity_threshold
408
+ adaptive_steps = np.where(
409
+ within_threshold,
410
+ np.clip(
411
+ min_distances * surface_proximity_factor,
412
+ min_step_size,
413
+ step_size,
414
+ ),
415
+ step_size,
416
+ )
417
+ ray_step_sizes = adaptive_steps.astype(np.float32)
418
+
419
+ # Store previous positions for intersection detection
420
+ prev_positions = positions.copy()
421
+
422
+ # Propagate each active ray one step
423
+ for i in range(num_rays):
424
+ if not active[i]:
425
+ continue
426
+
427
+ x, y, z = positions[i]
428
+ dx, dy, dz = directions[i]
429
+
430
+ # Get per-ray step size
431
+ current_step = ray_step_sizes[i]
432
+
433
+ # Get material properties
434
+ n = self._material.get_refractive_index(x, y, z, default_wavelength)
435
+ gx, gy, gz = self._material.get_refractive_index_gradient(
436
+ x, y, z, default_wavelength
437
+ )
438
+
439
+ # Euler step: update direction (ray curvature in gradient field)
440
+ dot = dx * gx + dy * gy + dz * gz
441
+ kx = (gx - dot * dx) / n
442
+ ky = (gy - dot * dy) / n
443
+ kz = (gz - dot * dz) / n
444
+
445
+ # Update position using adaptive step
446
+ positions[i, 0] = x + dx * current_step
447
+ positions[i, 1] = y + dy * current_step
448
+ positions[i, 2] = z + dz * current_step
449
+
450
+ # Update direction
451
+ dx_new = dx + kx * current_step
452
+ dy_new = dy + ky * current_step
453
+ dz_new = dz + kz * current_step
454
+
455
+ # Normalize direction
456
+ norm = np.sqrt(dx_new**2 + dy_new**2 + dz_new**2)
457
+ if norm > 1e-12:
458
+ directions[i] = [dx_new / norm, dy_new / norm, dz_new / norm]
459
+
460
+ # Update path lengths with adaptive step
461
+ geo_path[i] += current_step
462
+ opt_path[i] += n * current_step
463
+ acc_time[i] += n * current_step / c
464
+
465
+ # Check each surface for intersections
466
+ for surf_idx, surface in enumerate(self._surfaces):
467
+ if not np.any(active):
468
+ break
469
+
470
+ # Get active ray indices
471
+ active_indices = np.where(active)[0]
472
+ if len(active_indices) == 0:
473
+ continue
474
+
475
+ # Compute signed distances for all active rays (vectorized)
476
+ active_prev_pos = prev_positions[active_indices].astype(np.float32)
477
+ active_curr_pos = positions[active_indices].astype(np.float32)
478
+
479
+ # Use GPU signed distance if surface supports it and GPU is enabled
480
+ if (
481
+ self._use_gpu
482
+ and hasattr(surface, "gpu_capable")
483
+ and surface.gpu_capable
484
+ and hasattr(surface, "get_gpu_parameters")
485
+ ):
486
+ geometry_id = surface.geometry_id
487
+ params = surface.get_gpu_parameters()
488
+ prev_sd = compute_signed_distance_gpu(
489
+ active_prev_pos, geometry_id, params
490
+ )
491
+ curr_sd = compute_signed_distance_gpu(
492
+ active_curr_pos, geometry_id, params
493
+ )
494
+ else:
495
+ # CPU fallback using surface's own method
496
+ prev_sd = surface.signed_distance(active_prev_pos)
497
+ curr_sd = surface.signed_distance(active_curr_pos)
498
+
499
+ # Check for sign change (crossing) - vectorized
500
+ crossing = ((prev_sd >= 0) & (curr_sd < 0)) | (
501
+ (prev_sd < 0) & (curr_sd >= 0)
502
+ )
503
+
504
+ if not np.any(crossing):
505
+ continue
506
+
507
+ # Process rays that crossed this surface
508
+ crossing_indices = active_indices[crossing]
509
+
510
+ for i in crossing_indices:
511
+ local_idx = np.where(active_indices == i)[0][0]
512
+
513
+ # Bisect to find exact intersection point
514
+ p0 = prev_positions[i].copy()
515
+ p1 = positions[i].copy()
516
+ sd0 = prev_sd[local_idx]
517
+
518
+ for _ in range(
519
+ 20
520
+ ): # 20 iterations gives ~nm precision from 10m step
521
+ mid = (p0 + p1) / 2
522
+
523
+ # Get signed distance at midpoint
524
+ if (
525
+ self._use_gpu
526
+ and hasattr(surface, "gpu_capable")
527
+ and surface.gpu_capable
528
+ ):
529
+ geometry_id = surface.geometry_id
530
+ params = surface.get_gpu_parameters()
531
+ mid_sd = compute_signed_distance_gpu(
532
+ mid[np.newaxis, :].astype(np.float32),
533
+ geometry_id,
534
+ params,
535
+ )[0]
536
+ else:
537
+ mid_sd = surface.signed_distance(
538
+ mid[np.newaxis, :].astype(np.float32)
539
+ )[0]
540
+
541
+ if abs(mid_sd) < 1e-6:
542
+ break
543
+
544
+ # Determine which half contains the crossing
545
+ if (sd0 >= 0 and mid_sd < 0) or (sd0 < 0 and mid_sd >= 0):
546
+ p1 = mid
547
+ else:
548
+ p0 = mid
549
+ sd0 = mid_sd
550
+
551
+ # Record hit
552
+ hit_pos = (p0 + p1) / 2
553
+ hit_surface_idx[i] = surf_idx
554
+ hit_positions[i] = hit_pos
555
+ hit_directions[i] = directions[i]
556
+
557
+ # Correct accumulated time/path for exact intersection distance
558
+ # The step already accumulated time for current_step, but
559
+ # actual distance traveled is less (to hit_pos, not full step)
560
+ actual_distance = np.linalg.norm(hit_pos - prev_positions[i])
561
+ excess_distance = ray_step_sizes[i] - actual_distance
562
+
563
+ # Get refractive index at intersection for correction
564
+ # (use hit_pos which is close enough for correction purposes)
565
+ n_hit = self._material.get_refractive_index(
566
+ hit_pos[0], hit_pos[1], hit_pos[2], default_wavelength
567
+ )
568
+
569
+ # Remove excess accumulated values
570
+ geo_path[i] -= excess_distance
571
+ opt_path[i] -= n_hit * excess_distance
572
+ acc_time[i] -= n_hit * excess_distance / c
573
+
574
+ # Update ray state to intersection point
575
+ positions[i] = hit_pos
576
+ active[i] = False
577
+
578
+ # Copy final state back to rays
579
+ rays.positions[:] = positions
580
+ rays.directions[:] = directions
581
+ rays.active[:] = active
582
+ rays.geometric_path_lengths[:] = geo_path
583
+ rays.optical_path_lengths[:] = opt_path
584
+ rays.accumulated_time[:] = acc_time
585
+
586
+ return HitData(
587
+ hit_surface_idx=hit_surface_idx,
588
+ hit_positions=hit_positions,
589
+ hit_directions=hit_directions,
590
+ num_rays=num_rays,
591
+ )
592
+
593
+ def get_surface_by_index(self, idx: int):
594
+ """Get surface by index."""
595
+ return self._surfaces[idx]
596
+
597
+ def extract_hits_for_surface(
598
+ self,
599
+ rays: "RayBatch",
600
+ hit_data: HitData,
601
+ surface_idx: int,
602
+ ) -> "RayBatch":
603
+ """
604
+ Extract rays that hit a specific surface as a new RayBatch.
605
+
606
+ Parameters
607
+ ----------
608
+ rays : RayBatch
609
+ Original ray batch
610
+ hit_data : HitData
611
+ Hit data from propagation
612
+ surface_idx : int
613
+ Index of surface to extract hits for
614
+
615
+ Returns
616
+ -------
617
+ RayBatch
618
+ New batch containing only rays that hit the specified surface.
619
+ Positions and directions are set to intersection values.
620
+ """
621
+ from ...utilities.ray_data import RayBatch
622
+
623
+ mask = hit_data.get_hit_mask(surface_idx)
624
+ num_hits = np.sum(mask)
625
+
626
+ if num_hits == 0:
627
+ # Return empty batch
628
+ from ...utilities.ray_data import create_ray_batch
629
+
630
+ return create_ray_batch(0)
631
+
632
+ # Create new batch with intersection data
633
+ return RayBatch(
634
+ positions=hit_data.hit_positions[mask].copy(),
635
+ directions=hit_data.hit_directions[mask].copy(),
636
+ wavelengths=rays.wavelengths[mask].copy(),
637
+ intensities=rays.intensities[mask].copy(),
638
+ optical_path_lengths=rays.optical_path_lengths[mask].copy(),
639
+ geometric_path_lengths=rays.geometric_path_lengths[mask].copy(),
640
+ accumulated_time=rays.accumulated_time[mask].copy(),
641
+ generations=rays.generations[mask].copy(),
642
+ domain_ids=rays.domain_ids[mask].copy(),
643
+ active=np.ones(num_hits, dtype=np.bool_),
644
+ polarization_s=(
645
+ rays.polarization_s[mask].copy()
646
+ if rays.polarization_s is not None
647
+ else None
648
+ ),
649
+ polarization_p=(
650
+ rays.polarization_p[mask].copy()
651
+ if rays.polarization_p is not None
652
+ else None
653
+ ),
654
+ polarization_vector=(
655
+ rays.polarization_vector[mask].copy()
656
+ if rays.polarization_vector is not None
657
+ else None
658
+ ),
659
+ phase=rays.phase[mask].copy() if rays.phase is not None else None,
660
+ )
661
+
662
+ def extract_no_hits(
663
+ self,
664
+ rays: "RayBatch",
665
+ hit_data: HitData,
666
+ ) -> "RayBatch":
667
+ """
668
+ Extract rays that didn't hit any surface.
669
+
670
+ Parameters
671
+ ----------
672
+ rays : RayBatch
673
+ Original ray batch
674
+ hit_data : HitData
675
+ Hit data from propagation
676
+
677
+ Returns
678
+ -------
679
+ RayBatch
680
+ New batch containing only rays that didn't hit any surface.
681
+ """
682
+ from ...utilities.ray_data import RayBatch
683
+
684
+ mask = hit_data.get_no_hit_mask()
685
+ num_no_hits = np.sum(mask)
686
+
687
+ if num_no_hits == 0:
688
+ from ...utilities.ray_data import create_ray_batch
689
+
690
+ return create_ray_batch(0)
691
+
692
+ return RayBatch(
693
+ positions=rays.positions[mask].copy(),
694
+ directions=rays.directions[mask].copy(),
695
+ wavelengths=rays.wavelengths[mask].copy(),
696
+ intensities=rays.intensities[mask].copy(),
697
+ optical_path_lengths=rays.optical_path_lengths[mask].copy(),
698
+ geometric_path_lengths=rays.geometric_path_lengths[mask].copy(),
699
+ accumulated_time=rays.accumulated_time[mask].copy(),
700
+ generations=rays.generations[mask].copy(),
701
+ domain_ids=rays.domain_ids[mask].copy(),
702
+ active=rays.active[mask].copy(),
703
+ polarization_s=(
704
+ rays.polarization_s[mask].copy()
705
+ if rays.polarization_s is not None
706
+ else None
707
+ ),
708
+ polarization_p=(
709
+ rays.polarization_p[mask].copy()
710
+ if rays.polarization_p is not None
711
+ else None
712
+ ),
713
+ polarization_vector=(
714
+ rays.polarization_vector[mask].copy()
715
+ if rays.polarization_vector is not None
716
+ else None
717
+ ),
718
+ phase=rays.phase[mask].copy() if rays.phase is not None else None,
719
+ )