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,553 @@
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
+ GPU-accelerated Spectral Gradient Propagator with per-ray wavelengths.
36
+
37
+ This module provides a CUDA-based propagator for SpectralInhomogeneousModel
38
+ materials where each ray has its own wavelength value, enabling simulation
39
+ of chromatic dispersion.
40
+ """
41
+
42
+ import numpy as np
43
+ import numpy.typing as npt
44
+
45
+ # GPU support is optional
46
+ try:
47
+ from numba import cuda
48
+
49
+ HAS_CUDA = True
50
+ except ImportError:
51
+
52
+ class _FakeCuda:
53
+ """Fake cuda module for when numba is not installed."""
54
+
55
+ @staticmethod
56
+ def jit(*args, **kwargs):
57
+ """Return a no-op decorator."""
58
+
59
+ def decorator(func):
60
+ return func
61
+
62
+ if args and callable(args[0]):
63
+ return args[0]
64
+ return decorator
65
+
66
+ @staticmethod
67
+ def is_available():
68
+ return False
69
+
70
+ @staticmethod
71
+ def grid(n):
72
+ return 0
73
+
74
+ @staticmethod
75
+ def synchronize():
76
+ pass
77
+
78
+ @staticmethod
79
+ def to_device(arr):
80
+ """Fake to_device that returns a wrapper."""
81
+ return _FakeDeviceArray(arr)
82
+
83
+ class _FakeDeviceArray:
84
+ """Fake device array for when numba is not installed."""
85
+
86
+ def __init__(self, arr):
87
+ self._arr = arr
88
+
89
+ def copy_to_host(self):
90
+ return self._arr
91
+
92
+ cuda = _FakeCuda() # type: ignore[assignment]
93
+ HAS_CUDA = False
94
+
95
+ from ..propagator_protocol import GPUMaterialProtocol, GPUMaterialID
96
+
97
+
98
+ class SpectralGPUGradientPropagator:
99
+ """
100
+ GPU-based propagator for spectral materials with per-ray wavelengths.
101
+
102
+ This propagator is designed for SpectralInhomogeneousModel materials
103
+ where each ray can have a different wavelength, enabling simulation
104
+ of chromatic dispersion effects (e.g., rainbow formation, prism dispersion).
105
+
106
+ Unlike GPUGradientPropagator which uses a single wavelength for all rays,
107
+ this propagator accepts a wavelengths array and uses specialized kernels
108
+ that read the wavelength for each ray individually.
109
+
110
+ Parameters
111
+ ----------
112
+ material : SpectralInhomogeneousModel
113
+ The spectral material to propagate through. Must have
114
+ `get_gpu_kernels_perray()` method.
115
+ method : str
116
+ Integration method: 'euler' or 'rk4'. Default: 'rk4'
117
+ threads_per_block : int
118
+ CUDA threads per block. Default: 256
119
+
120
+ Example
121
+ -------
122
+ >>> from lsurf.materials import LinsleyAtmosphere
123
+ >>> atmosphere = LinsleyAtmosphere()
124
+ >>> propagator = SpectralGPUGradientPropagator(atmosphere, method='rk4')
125
+ >>>
126
+ >>> # Create rays with different wavelengths (red, green, blue)
127
+ >>> wavelengths = np.array([700e-9, 550e-9, 450e-9]) # One per ray
128
+ >>> propagator.propagate(rays, wavelengths, total_distance=500e3, step_size=100.0)
129
+ """
130
+
131
+ def __init__(
132
+ self,
133
+ material: GPUMaterialProtocol,
134
+ method: str = "rk4",
135
+ threads_per_block: int = 256,
136
+ enable_absorption: bool = False,
137
+ ):
138
+ # Check if CUDA is available
139
+ if not HAS_CUDA:
140
+ raise ImportError(
141
+ "CUDA support requires numba with CUDA. "
142
+ "Use GradientPropagator for CPU-based propagation."
143
+ )
144
+
145
+ # Store material reference
146
+ self._material = material
147
+
148
+ # Validate material has GPU support
149
+ if not hasattr(material, "gpu_material_id"):
150
+ raise ValueError(
151
+ f"Material {type(material).__name__} does not support GPU acceleration. "
152
+ f"Use GradientPropagator.propagate_step_cpu() instead."
153
+ )
154
+
155
+ # Validate material provides per-ray kernels
156
+ if not hasattr(material, "get_gpu_kernels_perray"):
157
+ raise ValueError(
158
+ f"Material {type(material).__name__} does not provide per-ray wavelength kernels. "
159
+ f"Implement get_gpu_kernels_perray() method or use GPUGradientPropagator."
160
+ )
161
+
162
+ self._material_id = material.gpu_material_id
163
+
164
+ if method not in ("euler", "rk4"):
165
+ raise ValueError(f"method must be 'euler' or 'rk4', got {method}")
166
+ self.method = method
167
+ self.threads_per_block = threads_per_block
168
+ self.enable_absorption = enable_absorption
169
+
170
+ # Get per-ray kernels from the material
171
+ self._kernels = material.get_gpu_kernels_perray()
172
+ self._kernel = self._kernels.get(method)
173
+ if self._kernel is None:
174
+ raise ValueError(
175
+ f"Material {type(material).__name__} does not provide '{method}' per-ray kernel. "
176
+ f"Available: {list(self._kernels.keys())}"
177
+ )
178
+
179
+ # Get absorption kernel if enabled
180
+ self._absorption_kernel = None
181
+ if enable_absorption:
182
+ self._absorption_kernel = self._get_absorption_kernel()
183
+
184
+ # Check if material uses device arrays (LUTs, grids, etc.)
185
+ self._gpu_arrays = None
186
+ self._device_arrays = None
187
+ if hasattr(material, "get_gpu_arrays"):
188
+ self._gpu_arrays = material.get_gpu_arrays()
189
+ # Device arrays will be created on first propagate call
190
+
191
+ def _get_absorption_kernel(self):
192
+ """Get the appropriate absorption kernel for spectral materials."""
193
+ from ..kernels.absorption.spectral import _kernel_absorption_spectral_perray
194
+
195
+ if self._material_id == GPUMaterialID.SPECTRAL_INHOMOGENEOUS:
196
+ return _kernel_absorption_spectral_perray
197
+ else:
198
+ raise ValueError(
199
+ f"No spectral absorption kernel available for material ID {self._material_id}"
200
+ )
201
+
202
+ def _apply_absorption(
203
+ self,
204
+ positions_d,
205
+ directions_d,
206
+ wavelengths_d,
207
+ active_d,
208
+ intensities_d,
209
+ optical_depth_d,
210
+ step_size: float,
211
+ num_steps: int,
212
+ blocks: int,
213
+ ) -> None:
214
+ """Apply absorption using the spectral per-ray kernel."""
215
+ material_params = self._material.get_gpu_parameters()
216
+
217
+ # Spectral kernel: positions, directions, wavelengths, active, intensities, optical_depth,
218
+ # step_size, num_steps, center_x, center_y, center_z, ref_radius,
219
+ # alt_min, alt_delta, n_alt, wl_min, wl_delta, n_wl, lut_alpha
220
+ self._absorption_kernel[blocks, self.threads_per_block](
221
+ positions_d,
222
+ directions_d,
223
+ wavelengths_d,
224
+ active_d,
225
+ intensities_d,
226
+ optical_depth_d,
227
+ float(step_size),
228
+ num_steps,
229
+ material_params[0], # center_x
230
+ material_params[1], # center_y
231
+ material_params[2], # center_z
232
+ material_params[3], # ref_radius
233
+ material_params[4], # alt_min
234
+ material_params[5], # alt_delta
235
+ material_params[6], # n_alt
236
+ material_params[7], # wl_min
237
+ material_params[8], # wl_delta
238
+ material_params[9], # n_wl
239
+ self._device_arrays["lut_alpha"],
240
+ )
241
+
242
+ @property
243
+ def material(self) -> GPUMaterialProtocol:
244
+ """The material being propagated through."""
245
+ return self._material
246
+
247
+ def propagate_step(
248
+ self,
249
+ rays, # RayBatch
250
+ wavelengths: npt.NDArray[np.floating],
251
+ step_size: float,
252
+ ) -> None:
253
+ """
254
+ Propagate rays by a single integration step.
255
+
256
+ Parameters
257
+ ----------
258
+ rays : RayBatch
259
+ Ray batch containing positions, directions, and other ray properties.
260
+ Modified in-place.
261
+ wavelengths : ndarray, shape (N,)
262
+ Per-ray wavelengths in meters.
263
+ step_size : float
264
+ Integration step size in meters.
265
+ """
266
+ self.propagate(
267
+ rays=rays,
268
+ wavelengths=wavelengths,
269
+ total_distance=step_size,
270
+ step_size=step_size,
271
+ )
272
+
273
+ def propagate(
274
+ self,
275
+ rays, # RayBatch
276
+ wavelengths: npt.NDArray[np.floating],
277
+ total_distance: float,
278
+ step_size: float,
279
+ steps_per_kernel: int = 100,
280
+ ) -> None:
281
+ """
282
+ Propagate rays through material on GPU with per-ray wavelengths.
283
+
284
+ Modifies the rays object in-place.
285
+
286
+ Parameters
287
+ ----------
288
+ rays : RayBatch
289
+ Ray batch to propagate (modified in-place)
290
+ wavelengths : ndarray, shape (N,)
291
+ Per-ray wavelengths in meters. Must have same length as number of rays.
292
+ total_distance : float
293
+ Total distance to propagate in meters
294
+ step_size : float
295
+ Integration step size in meters
296
+ steps_per_kernel : int
297
+ Number of steps per kernel launch. Higher values reduce
298
+ kernel launch overhead but increase register pressure.
299
+
300
+ Raises
301
+ ------
302
+ ValueError
303
+ If wavelengths array length doesn't match number of rays.
304
+ """
305
+ num_rays = rays.num_rays
306
+ total_steps = int(total_distance / step_size)
307
+
308
+ # Validate wavelengths array
309
+ wavelengths = np.asarray(wavelengths)
310
+ if wavelengths.shape[0] != num_rays:
311
+ raise ValueError(
312
+ f"wavelengths array length ({wavelengths.shape[0]}) "
313
+ f"must match number of rays ({num_rays})"
314
+ )
315
+
316
+ # Prepare GPU arrays for ray state
317
+ positions_d = cuda.to_device(rays.positions.astype(np.float32))
318
+ directions_d = cuda.to_device(rays.directions.astype(np.float32))
319
+ wavelengths_d = cuda.to_device(wavelengths.astype(np.float32))
320
+ active_d = cuda.to_device(rays.active)
321
+ geo_path_d = cuda.to_device(rays.geometric_path_lengths.astype(np.float32))
322
+ opt_path_d = cuda.to_device(rays.optical_path_lengths.astype(np.float32))
323
+ acc_time_d = cuda.to_device(rays.accumulated_time.astype(np.float32))
324
+
325
+ # Prepare absorption arrays if enabled
326
+ intensities_d = None
327
+ optical_depth_d = None
328
+ if self.enable_absorption:
329
+ intensities_d = cuda.to_device(rays.intensities.astype(np.float32))
330
+ if rays.optical_depth is not None:
331
+ optical_depth_d = cuda.to_device(rays.optical_depth.astype(np.float32))
332
+ else:
333
+ optical_depth_d = cuda.to_device(np.zeros(num_rays, dtype=np.float32))
334
+
335
+ # Transfer material arrays to device if needed (LUTs, grids, etc.)
336
+ if self._gpu_arrays is not None and self._device_arrays is None:
337
+ self._device_arrays = {}
338
+ for name, arr in self._gpu_arrays.items():
339
+ self._device_arrays[name] = cuda.to_device(arr.astype(np.float32))
340
+
341
+ # Get material-specific kernel parameters from the material
342
+ material_params = self._material.get_gpu_parameters()
343
+
344
+ # Build kernel arguments: scalar params + device arrays
345
+ kernel_args = list(material_params)
346
+ if self._device_arrays is not None:
347
+ # Append device arrays in the order the kernel expects
348
+ for name in ["lut_n", "lut_dn_dh"]:
349
+ if name in self._device_arrays:
350
+ kernel_args.append(self._device_arrays[name])
351
+
352
+ # Configure kernel launch
353
+ blocks = (num_rays + self.threads_per_block - 1) // self.threads_per_block
354
+
355
+ # Launch kernels in batches
356
+ remaining_steps = total_steps
357
+ while remaining_steps > 0:
358
+ steps_this_call = min(remaining_steps, steps_per_kernel)
359
+ # Per-ray kernel signature:
360
+ # positions, directions, wavelengths, active, geo_path, opt_path, acc_time,
361
+ # step_size, num_steps, ...material_params, lut_n, lut_dn_dh
362
+ self._kernel[blocks, self.threads_per_block](
363
+ positions_d,
364
+ directions_d,
365
+ wavelengths_d,
366
+ active_d,
367
+ geo_path_d,
368
+ opt_path_d,
369
+ acc_time_d,
370
+ float(step_size),
371
+ steps_this_call,
372
+ *kernel_args,
373
+ )
374
+
375
+ # Apply absorption AFTER each batch (for proper path integration)
376
+ if self.enable_absorption and self._absorption_kernel is not None:
377
+ self._apply_absorption(
378
+ positions_d,
379
+ directions_d,
380
+ wavelengths_d,
381
+ active_d,
382
+ intensities_d,
383
+ optical_depth_d,
384
+ step_size,
385
+ steps_this_call,
386
+ blocks,
387
+ )
388
+
389
+ remaining_steps -= steps_this_call
390
+
391
+ # Synchronize and copy back
392
+ cuda.synchronize()
393
+ rays.positions[:] = positions_d.copy_to_host()
394
+ rays.directions[:] = directions_d.copy_to_host()
395
+ rays.geometric_path_lengths[:] = geo_path_d.copy_to_host()
396
+ rays.optical_path_lengths[:] = opt_path_d.copy_to_host()
397
+ rays.accumulated_time[:] = acc_time_d.copy_to_host()
398
+
399
+ # Copy back absorption data if enabled
400
+ if self.enable_absorption:
401
+ rays.intensities[:] = intensities_d.copy_to_host()
402
+ if rays.optical_depth is not None:
403
+ rays.optical_depth[:] = optical_depth_d.copy_to_host()
404
+
405
+ def propagate_with_history(
406
+ self,
407
+ rays, # RayBatch
408
+ wavelengths: npt.NDArray[np.floating],
409
+ total_distance: float,
410
+ step_size: float,
411
+ history_interval: int = 100,
412
+ ) -> dict:
413
+ """
414
+ Propagate rays and record position history at intervals.
415
+
416
+ Parameters
417
+ ----------
418
+ rays : RayBatch
419
+ Ray batch to propagate (modified in-place)
420
+ wavelengths : ndarray, shape (N,)
421
+ Per-ray wavelengths in meters.
422
+ total_distance : float
423
+ Total distance to propagate in meters
424
+ step_size : float
425
+ Integration step size in meters
426
+ history_interval : int
427
+ Record history every N steps
428
+
429
+ Returns
430
+ -------
431
+ dict
432
+ Dictionary with 'positions', 'directions', 'distances' arrays
433
+ """
434
+ num_rays = rays.num_rays
435
+ total_steps = int(total_distance / step_size)
436
+ num_records = total_steps // history_interval + 1
437
+
438
+ # Validate wavelengths array
439
+ wavelengths = np.asarray(wavelengths)
440
+ if wavelengths.shape[0] != num_rays:
441
+ raise ValueError(
442
+ f"wavelengths array length ({wavelengths.shape[0]}) "
443
+ f"must match number of rays ({num_rays})"
444
+ )
445
+
446
+ # Prepare GPU arrays for ray state
447
+ positions_d = cuda.to_device(rays.positions.astype(np.float32))
448
+ directions_d = cuda.to_device(rays.directions.astype(np.float32))
449
+ wavelengths_d = cuda.to_device(wavelengths.astype(np.float32))
450
+ active_d = cuda.to_device(rays.active)
451
+ geo_path_d = cuda.to_device(rays.geometric_path_lengths.astype(np.float32))
452
+ opt_path_d = cuda.to_device(rays.optical_path_lengths.astype(np.float32))
453
+ acc_time_d = cuda.to_device(rays.accumulated_time.astype(np.float32))
454
+
455
+ # Transfer material arrays to device if needed (LUTs, grids, etc.)
456
+ if self._gpu_arrays is not None and self._device_arrays is None:
457
+ self._device_arrays = {}
458
+ for name, arr in self._gpu_arrays.items():
459
+ self._device_arrays[name] = cuda.to_device(arr.astype(np.float32))
460
+
461
+ # Get material-specific kernel parameters
462
+ material_params = self._material.get_gpu_parameters()
463
+
464
+ # Build kernel arguments: scalar params + device arrays
465
+ kernel_args = list(material_params)
466
+ if self._device_arrays is not None:
467
+ for name in ["lut_n", "lut_dn_dh"]:
468
+ if name in self._device_arrays:
469
+ kernel_args.append(self._device_arrays[name])
470
+
471
+ # Configure kernel launch
472
+ blocks = (num_rays + self.threads_per_block - 1) // self.threads_per_block
473
+
474
+ # Storage for history
475
+ position_history = np.zeros((num_records, num_rays, 3), dtype=np.float32)
476
+ direction_history = np.zeros((num_records, num_rays, 3), dtype=np.float32)
477
+ distance_history = np.zeros(num_records, dtype=np.float32)
478
+
479
+ # Record initial state
480
+ position_history[0] = positions_d.copy_to_host()
481
+ direction_history[0] = directions_d.copy_to_host()
482
+ distance_history[0] = 0.0
483
+
484
+ # Propagate with recording
485
+ record_idx = 1
486
+ steps_done = 0
487
+
488
+ while steps_done < total_steps:
489
+ steps_to_next_record = min(
490
+ history_interval - (steps_done % history_interval),
491
+ total_steps - steps_done,
492
+ )
493
+
494
+ self._kernel[blocks, self.threads_per_block](
495
+ positions_d,
496
+ directions_d,
497
+ wavelengths_d,
498
+ active_d,
499
+ geo_path_d,
500
+ opt_path_d,
501
+ acc_time_d,
502
+ float(step_size),
503
+ steps_to_next_record,
504
+ *kernel_args,
505
+ )
506
+ steps_done += steps_to_next_record
507
+
508
+ # Record if at interval
509
+ if steps_done % history_interval == 0 and record_idx < num_records:
510
+ cuda.synchronize()
511
+ position_history[record_idx] = positions_d.copy_to_host()
512
+ direction_history[record_idx] = directions_d.copy_to_host()
513
+ distance_history[record_idx] = steps_done * step_size
514
+ record_idx += 1
515
+
516
+ # Final copy back
517
+ cuda.synchronize()
518
+ rays.positions[:] = positions_d.copy_to_host()
519
+ rays.directions[:] = directions_d.copy_to_host()
520
+ rays.geometric_path_lengths[:] = geo_path_d.copy_to_host()
521
+ rays.optical_path_lengths[:] = opt_path_d.copy_to_host()
522
+ rays.accumulated_time[:] = acc_time_d.copy_to_host()
523
+
524
+ return {
525
+ "positions": position_history[:record_idx],
526
+ "directions": direction_history[:record_idx],
527
+ "distances": distance_history[:record_idx],
528
+ }
529
+
530
+ def get_refractive_index(
531
+ self,
532
+ positions: npt.NDArray[np.floating],
533
+ wavelengths: npt.NDArray[np.floating],
534
+ ) -> npt.NDArray[np.float64]:
535
+ """
536
+ Compute refractive index at positions (CPU, delegates to material).
537
+
538
+ Parameters
539
+ ----------
540
+ positions : ndarray (N, 3)
541
+ Position coordinates
542
+ wavelengths : ndarray (N,)
543
+ Per-ray wavelengths in meters
544
+
545
+ Returns
546
+ -------
547
+ ndarray (N,)
548
+ Refractive index at each position
549
+ """
550
+ x = positions[:, 0]
551
+ y = positions[:, 1]
552
+ z = positions[:, 2]
553
+ return self._material.get_refractive_index(x, y, z, wavelengths)