lsurf 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lsurf/__init__.py +471 -0
- lsurf/analysis/__init__.py +107 -0
- lsurf/analysis/healpix_utils.py +418 -0
- lsurf/analysis/sphere_viz.py +1280 -0
- lsurf/cli/__init__.py +48 -0
- lsurf/cli/build.py +398 -0
- lsurf/cli/config_schema.py +318 -0
- lsurf/cli/gui_cmd.py +76 -0
- lsurf/cli/interactive.py +850 -0
- lsurf/cli/main.py +81 -0
- lsurf/cli/run.py +806 -0
- lsurf/detectors/__init__.py +266 -0
- lsurf/detectors/analysis.py +289 -0
- lsurf/detectors/base.py +284 -0
- lsurf/detectors/constant_size_rings.py +485 -0
- lsurf/detectors/directional.py +45 -0
- lsurf/detectors/extended/__init__.py +73 -0
- lsurf/detectors/extended/local_sphere.py +353 -0
- lsurf/detectors/extended/recording_sphere.py +368 -0
- lsurf/detectors/planar.py +45 -0
- lsurf/detectors/protocol.py +187 -0
- lsurf/detectors/recording_spheres.py +63 -0
- lsurf/detectors/results.py +1140 -0
- lsurf/detectors/small/__init__.py +79 -0
- lsurf/detectors/small/directional.py +330 -0
- lsurf/detectors/small/planar.py +401 -0
- lsurf/detectors/small/spherical.py +450 -0
- lsurf/detectors/spherical.py +45 -0
- lsurf/geometry/__init__.py +199 -0
- lsurf/geometry/builder.py +478 -0
- lsurf/geometry/cell.py +228 -0
- lsurf/geometry/cell_geometry.py +247 -0
- lsurf/geometry/detector_arrays.py +1785 -0
- lsurf/geometry/geometry.py +222 -0
- lsurf/geometry/surface_analysis.py +375 -0
- lsurf/geometry/validation.py +91 -0
- lsurf/gui/__init__.py +51 -0
- lsurf/gui/app.py +903 -0
- lsurf/gui/core/__init__.py +39 -0
- lsurf/gui/core/scene.py +343 -0
- lsurf/gui/core/simulation.py +264 -0
- lsurf/gui/renderers/__init__.py +40 -0
- lsurf/gui/renderers/ray_renderer.py +353 -0
- lsurf/gui/renderers/source_renderer.py +505 -0
- lsurf/gui/renderers/surface_renderer.py +477 -0
- lsurf/gui/views/__init__.py +48 -0
- lsurf/gui/views/config_editor.py +3199 -0
- lsurf/gui/views/properties.py +257 -0
- lsurf/gui/views/results.py +291 -0
- lsurf/gui/views/scene_tree.py +180 -0
- lsurf/gui/views/viewport_3d.py +555 -0
- lsurf/gui/views/visualizations.py +712 -0
- lsurf/materials/__init__.py +169 -0
- lsurf/materials/base/__init__.py +64 -0
- lsurf/materials/base/full_inhomogeneous.py +208 -0
- lsurf/materials/base/grid_inhomogeneous.py +319 -0
- lsurf/materials/base/homogeneous.py +342 -0
- lsurf/materials/base/material_field.py +527 -0
- lsurf/materials/base/simple_inhomogeneous.py +418 -0
- lsurf/materials/base/spectral_inhomogeneous.py +497 -0
- lsurf/materials/implementations/__init__.py +120 -0
- lsurf/materials/implementations/data/alpha_values_typical_atmosphere_updated.txt +24 -0
- lsurf/materials/implementations/duct_atmosphere.py +390 -0
- lsurf/materials/implementations/exponential_atmosphere.py +435 -0
- lsurf/materials/implementations/gaussian_lens.py +120 -0
- lsurf/materials/implementations/interpolated_data.py +123 -0
- lsurf/materials/implementations/layered_atmosphere.py +134 -0
- lsurf/materials/implementations/linear_gradient.py +109 -0
- lsurf/materials/implementations/linsley_atmosphere.py +764 -0
- lsurf/materials/implementations/standard_materials.py +126 -0
- lsurf/materials/implementations/turbulent_atmosphere.py +135 -0
- lsurf/materials/implementations/us_standard_atmosphere.py +149 -0
- lsurf/materials/utils/__init__.py +77 -0
- lsurf/materials/utils/constants.py +45 -0
- lsurf/materials/utils/device_functions.py +117 -0
- lsurf/materials/utils/dispersion.py +160 -0
- lsurf/materials/utils/factories.py +142 -0
- lsurf/propagation/__init__.py +91 -0
- lsurf/propagation/detector_gpu.py +67 -0
- lsurf/propagation/gpu_device_rays.py +294 -0
- lsurf/propagation/kernels/__init__.py +175 -0
- lsurf/propagation/kernels/absorption/__init__.py +61 -0
- lsurf/propagation/kernels/absorption/grid.py +240 -0
- lsurf/propagation/kernels/absorption/simple.py +232 -0
- lsurf/propagation/kernels/absorption/spectral.py +410 -0
- lsurf/propagation/kernels/detection/__init__.py +64 -0
- lsurf/propagation/kernels/detection/protocol.py +102 -0
- lsurf/propagation/kernels/detection/spherical.py +255 -0
- lsurf/propagation/kernels/device_functions.py +790 -0
- lsurf/propagation/kernels/fresnel/__init__.py +64 -0
- lsurf/propagation/kernels/fresnel/protocol.py +97 -0
- lsurf/propagation/kernels/fresnel/standard.py +258 -0
- lsurf/propagation/kernels/intersection/__init__.py +79 -0
- lsurf/propagation/kernels/intersection/annular_plane.py +207 -0
- lsurf/propagation/kernels/intersection/bounded_plane.py +205 -0
- lsurf/propagation/kernels/intersection/plane.py +166 -0
- lsurf/propagation/kernels/intersection/protocol.py +95 -0
- lsurf/propagation/kernels/intersection/signed_distance.py +742 -0
- lsurf/propagation/kernels/intersection/sphere.py +190 -0
- lsurf/propagation/kernels/propagation/__init__.py +85 -0
- lsurf/propagation/kernels/propagation/grid.py +527 -0
- lsurf/propagation/kernels/propagation/protocol.py +105 -0
- lsurf/propagation/kernels/propagation/simple.py +460 -0
- lsurf/propagation/kernels/propagation/spectral.py +875 -0
- lsurf/propagation/kernels/registry.py +331 -0
- lsurf/propagation/kernels/surface/__init__.py +72 -0
- lsurf/propagation/kernels/surface/bisection.py +232 -0
- lsurf/propagation/kernels/surface/detection.py +402 -0
- lsurf/propagation/kernels/surface/reduction.py +166 -0
- lsurf/propagation/propagator_protocol.py +222 -0
- lsurf/propagation/propagators/__init__.py +101 -0
- lsurf/propagation/propagators/detector_handler.py +354 -0
- lsurf/propagation/propagators/factory.py +200 -0
- lsurf/propagation/propagators/fresnel_handler.py +305 -0
- lsurf/propagation/propagators/gpu_gradient.py +566 -0
- lsurf/propagation/propagators/gpu_surface_propagator.py +707 -0
- lsurf/propagation/propagators/gradient.py +429 -0
- lsurf/propagation/propagators/intersection_handler.py +327 -0
- lsurf/propagation/propagators/material_propagator.py +398 -0
- lsurf/propagation/propagators/signed_distance_handler.py +522 -0
- lsurf/propagation/propagators/spectral_gpu_gradient.py +553 -0
- lsurf/propagation/propagators/surface_interaction.py +616 -0
- lsurf/propagation/propagators/surface_propagator.py +719 -0
- lsurf/py.typed +1 -0
- lsurf/simulation/__init__.py +70 -0
- lsurf/simulation/config.py +164 -0
- lsurf/simulation/orchestrator.py +462 -0
- lsurf/simulation/result.py +299 -0
- lsurf/simulation/simulation.py +262 -0
- lsurf/sources/__init__.py +128 -0
- lsurf/sources/base.py +264 -0
- lsurf/sources/collimated.py +252 -0
- lsurf/sources/custom.py +409 -0
- lsurf/sources/diverging.py +228 -0
- lsurf/sources/gaussian.py +272 -0
- lsurf/sources/parallel_from_positions.py +197 -0
- lsurf/sources/point.py +172 -0
- lsurf/sources/uniform_diverging.py +258 -0
- lsurf/surfaces/__init__.py +184 -0
- lsurf/surfaces/cpu/__init__.py +50 -0
- lsurf/surfaces/cpu/curved_wave.py +463 -0
- lsurf/surfaces/cpu/gerstner_wave.py +381 -0
- lsurf/surfaces/cpu/wave_params.py +118 -0
- lsurf/surfaces/gpu/__init__.py +72 -0
- lsurf/surfaces/gpu/annular_plane.py +453 -0
- lsurf/surfaces/gpu/bounded_plane.py +390 -0
- lsurf/surfaces/gpu/curved_wave.py +483 -0
- lsurf/surfaces/gpu/gerstner_wave.py +377 -0
- lsurf/surfaces/gpu/multi_curved_wave.py +520 -0
- lsurf/surfaces/gpu/plane.py +299 -0
- lsurf/surfaces/gpu/recording_sphere.py +587 -0
- lsurf/surfaces/gpu/sphere.py +311 -0
- lsurf/surfaces/protocol.py +336 -0
- lsurf/surfaces/registry.py +373 -0
- lsurf/utilities/__init__.py +175 -0
- lsurf/utilities/detector_analysis.py +814 -0
- lsurf/utilities/fresnel.py +628 -0
- lsurf/utilities/interactions.py +1215 -0
- lsurf/utilities/propagation.py +602 -0
- lsurf/utilities/ray_data.py +532 -0
- lsurf/utilities/recording_sphere.py +745 -0
- lsurf/utilities/time_spread.py +463 -0
- lsurf/visualization/__init__.py +329 -0
- lsurf/visualization/absorption_plots.py +334 -0
- lsurf/visualization/atmospheric_plots.py +754 -0
- lsurf/visualization/common.py +348 -0
- lsurf/visualization/detector_plots.py +1350 -0
- lsurf/visualization/detector_sphere_plots.py +1173 -0
- lsurf/visualization/fresnel_plots.py +1061 -0
- lsurf/visualization/ocean_simulation_plots.py +999 -0
- lsurf/visualization/polarization_plots.py +916 -0
- lsurf/visualization/raytracing_plots.py +1521 -0
- lsurf/visualization/ring_detector_plots.py +1867 -0
- lsurf/visualization/time_spread_plots.py +531 -0
- lsurf-1.0.0.dist-info/METADATA +381 -0
- lsurf-1.0.0.dist-info/RECORD +180 -0
- lsurf-1.0.0.dist-info/WHEEL +5 -0
- lsurf-1.0.0.dist-info/entry_points.txt +2 -0
- lsurf-1.0.0.dist-info/licenses/LICENSE +32 -0
- lsurf-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
)
|