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,790 @@
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
+ Shared GPU Device Functions
36
+
37
+ This module consolidates device functions used across GPU kernels:
38
+ - Integration functions (Euler, RK4 steps)
39
+ - Dispersion models (Sellmeier, Cauchy equations)
40
+ - Common utilities (normalization, interpolation)
41
+
42
+ All functions are designed to be used with @cuda.jit(device=True).
43
+ """
44
+
45
+ import math
46
+ from collections.abc import Callable
47
+
48
+ import numpy as np
49
+ import numpy.typing as npt
50
+
51
+ # GPU support is optional
52
+ try:
53
+ from numba import cuda
54
+
55
+ HAS_CUDA = True
56
+ except ImportError:
57
+
58
+ class _FakeCuda:
59
+ """Fake cuda module for when numba is not installed."""
60
+
61
+ @staticmethod
62
+ def jit(*args, **kwargs):
63
+ """Return a no-op decorator."""
64
+
65
+ def decorator(func):
66
+ return func
67
+
68
+ if args and callable(args[0]):
69
+ return args[0]
70
+ return decorator
71
+
72
+ @staticmethod
73
+ def is_available():
74
+ return False
75
+
76
+ @staticmethod
77
+ def grid(n):
78
+ return 0
79
+
80
+ @staticmethod
81
+ def synchronize():
82
+ pass
83
+
84
+ cuda = _FakeCuda() # type: ignore[assignment]
85
+ HAS_CUDA = False
86
+
87
+
88
+ # =============================================================================
89
+ # CUDA Device Functions for Integration
90
+ # =============================================================================
91
+
92
+
93
+ @cuda.jit(device=True)
94
+ def device_adaptive_step_size(
95
+ gradient_magnitude: float,
96
+ refractive_index: float,
97
+ wavelength: float,
98
+ min_step: float,
99
+ max_step: float,
100
+ ) -> float:
101
+ """
102
+ Compute adaptive step size based on local gradient on GPU device.
103
+
104
+ Parameters
105
+ ----------
106
+ gradient_magnitude : float
107
+ |∇n| at current position
108
+ refractive_index : float
109
+ n at current position
110
+ wavelength : float
111
+ Wavelength in meters
112
+ min_step : float
113
+ Minimum allowed step size
114
+ max_step : float
115
+ Maximum allowed step size
116
+
117
+ Returns
118
+ -------
119
+ float
120
+ Recommended step size
121
+ """
122
+ # Radius of curvature
123
+ if gradient_magnitude > 1e-12:
124
+ radius_curvature = refractive_index / gradient_magnitude
125
+ step_from_curvature = radius_curvature / 10.0
126
+ else:
127
+ step_from_curvature = max_step
128
+
129
+ # Wavelength in medium
130
+ wavelength_medium = wavelength / refractive_index
131
+
132
+ # Take minimum
133
+ step = min(step_from_curvature, wavelength_medium)
134
+
135
+ # Clamp to limits
136
+ if step < min_step:
137
+ step = min_step
138
+ if step > max_step:
139
+ step = max_step
140
+
141
+ return step
142
+
143
+
144
+ @cuda.jit(device=True)
145
+ def device_euler_step(
146
+ x: float,
147
+ y: float,
148
+ z: float,
149
+ dx: float,
150
+ dy: float,
151
+ dz: float,
152
+ n: float,
153
+ grad_x: float,
154
+ grad_y: float,
155
+ grad_z: float,
156
+ step_size: float,
157
+ ) -> tuple[float, float, float, float, float, float]:
158
+ """
159
+ Single Euler integration step for ray equation on GPU.
160
+
161
+ The ray equation in gradient media is:
162
+ dr/ds = d̂
163
+ dd̂/ds = (∇n - (d̂·∇n)d̂) / n
164
+
165
+ Parameters
166
+ ----------
167
+ x, y, z : float
168
+ Current position
169
+ dx, dy, dz : float
170
+ Current direction (unit vector)
171
+ n : float
172
+ Refractive index at current position
173
+ grad_x, grad_y, grad_z : float
174
+ Gradient of n at current position
175
+ step_size : float
176
+ Step size ds
177
+
178
+ Returns
179
+ -------
180
+ tuple
181
+ (new_x, new_y, new_z, new_dx, new_dy, new_dz)
182
+ """
183
+ # Curvature: κ = (∇n - (d̂·∇n)d̂) / n
184
+ dot = dx * grad_x + dy * grad_y + dz * grad_z
185
+ kappa_x = (grad_x - dot * dx) / n
186
+ kappa_y = (grad_y - dot * dy) / n
187
+ kappa_z = (grad_z - dot * dz) / n
188
+
189
+ # Update position: r_new = r + d̂ * ds
190
+ new_x = x + dx * step_size
191
+ new_y = y + dy * step_size
192
+ new_z = z + dz * step_size
193
+
194
+ # Update direction: d̂_new = d̂ + κ * ds
195
+ new_dx = dx + kappa_x * step_size
196
+ new_dy = dy + kappa_y * step_size
197
+ new_dz = dz + kappa_z * step_size
198
+
199
+ # Renormalize
200
+ norm = math.sqrt(new_dx * new_dx + new_dy * new_dy + new_dz * new_dz)
201
+ if norm > 1e-12:
202
+ new_dx /= norm
203
+ new_dy /= norm
204
+ new_dz /= norm
205
+
206
+ return new_x, new_y, new_z, new_dx, new_dy, new_dz
207
+
208
+
209
+ @cuda.jit(device=True)
210
+ def device_rk4_step(
211
+ x: float,
212
+ y: float,
213
+ z: float,
214
+ dx: float,
215
+ dy: float,
216
+ dz: float,
217
+ step_size: float,
218
+ n_func,
219
+ grad_func,
220
+ *material_params,
221
+ ) -> tuple[float, float, float, float, float, float, float]:
222
+ """
223
+ RK4 integration step for ray equation on GPU.
224
+
225
+ This is a generic RK4 step that takes material evaluation functions
226
+ as parameters. For material-specific implementations with better
227
+ performance, materials should define their own device functions.
228
+
229
+ Parameters
230
+ ----------
231
+ x, y, z : float
232
+ Current position
233
+ dx, dy, dz : float
234
+ Current direction (unit vector)
235
+ step_size : float
236
+ Integration step size
237
+ n_func : device function
238
+ Function to evaluate n(x, y, z, *material_params)
239
+ grad_func : device function
240
+ Function to evaluate ∇n(x, y, z, *material_params) -> (gx, gy, gz)
241
+ material_params : tuple
242
+ Material-specific parameters passed to n_func and grad_func
243
+
244
+ Returns
245
+ -------
246
+ tuple of float
247
+ (new_x, new_y, new_z, new_dx, new_dy, new_dz, n_avg)
248
+
249
+ Notes
250
+ -----
251
+ For best performance, materials should implement their own specialized
252
+ RK4 device functions that inline the n and gradient calculations.
253
+ This generic version has function call overhead.
254
+ """
255
+ h = step_size
256
+ h2 = h / 2.0
257
+
258
+ def _normalize(vx, vy, vz):
259
+ norm = math.sqrt(vx * vx + vy * vy + vz * vz)
260
+ if norm > 1e-12:
261
+ return vx / norm, vy / norm, vz / norm
262
+ return vx, vy, vz
263
+
264
+ def _curvature(px, py, pz, dirx, diry, dirz):
265
+ n = n_func(px, py, pz, *material_params)
266
+ gx, gy, gz = grad_func(px, py, pz, *material_params)
267
+ dot = dirx * gx + diry * gy + dirz * gz
268
+ kx = (gx - dot * dirx) / n
269
+ ky = (gy - dot * diry) / n
270
+ kz = (gz - dot * dirz) / n
271
+ return n, kx, ky, kz
272
+
273
+ # k1 at current point
274
+ n0, k1_dx, k1_dy, k1_dz = _curvature(x, y, z, dx, dy, dz)
275
+ k1_rx, k1_ry, k1_rz = dx, dy, dz
276
+
277
+ # Intermediate point 1
278
+ x1 = x + h2 * k1_rx
279
+ y1 = y + h2 * k1_ry
280
+ z1 = z + h2 * k1_rz
281
+ dx1, dy1, dz1 = _normalize(dx + h2 * k1_dx, dy + h2 * k1_dy, dz + h2 * k1_dz)
282
+
283
+ # k2
284
+ n1, k2_dx, k2_dy, k2_dz = _curvature(x1, y1, z1, dx1, dy1, dz1)
285
+ k2_rx, k2_ry, k2_rz = dx1, dy1, dz1
286
+
287
+ # Intermediate point 2
288
+ x2 = x + h2 * k2_rx
289
+ y2 = y + h2 * k2_ry
290
+ z2 = z + h2 * k2_rz
291
+ dx2, dy2, dz2 = _normalize(dx + h2 * k2_dx, dy + h2 * k2_dy, dz + h2 * k2_dz)
292
+
293
+ # k3
294
+ n2, k3_dx, k3_dy, k3_dz = _curvature(x2, y2, z2, dx2, dy2, dz2)
295
+ k3_rx, k3_ry, k3_rz = dx2, dy2, dz2
296
+
297
+ # End point
298
+ x3 = x + h * k3_rx
299
+ y3 = y + h * k3_ry
300
+ z3 = z + h * k3_rz
301
+ dx3, dy3, dz3 = _normalize(dx + h * k3_dx, dy + h * k3_dy, dz + h * k3_dz)
302
+
303
+ # k4
304
+ n3, k4_dx, k4_dy, k4_dz = _curvature(x3, y3, z3, dx3, dy3, dz3)
305
+ k4_rx, k4_ry, k4_rz = dx3, dy3, dz3
306
+
307
+ # Final RK4 combination
308
+ new_x = x + (h / 6.0) * (k1_rx + 2 * k2_rx + 2 * k3_rx + k4_rx)
309
+ new_y = y + (h / 6.0) * (k1_ry + 2 * k2_ry + 2 * k3_ry + k4_ry)
310
+ new_z = z + (h / 6.0) * (k1_rz + 2 * k2_rz + 2 * k3_rz + k4_rz)
311
+
312
+ new_dx = dx + (h / 6.0) * (k1_dx + 2 * k2_dx + 2 * k3_dx + k4_dx)
313
+ new_dy = dy + (h / 6.0) * (k1_dy + 2 * k2_dy + 2 * k3_dy + k4_dy)
314
+ new_dz = dz + (h / 6.0) * (k1_dz + 2 * k2_dz + 2 * k3_dz + k4_dz)
315
+
316
+ new_dx, new_dy, new_dz = _normalize(new_dx, new_dy, new_dz)
317
+
318
+ # Simpson's rule for average n
319
+ n_avg = (n0 + 4 * n1 + n2) / 6.0
320
+
321
+ return new_x, new_y, new_z, new_dx, new_dy, new_dz, n_avg
322
+
323
+
324
+ # =============================================================================
325
+ # CUDA Device Functions for Dispersion Models
326
+ # =============================================================================
327
+
328
+
329
+ @cuda.jit(device=True)
330
+ def device_sellmeier_equation(
331
+ wl_um: float,
332
+ B1: float,
333
+ B2: float,
334
+ B3: float,
335
+ C1: float,
336
+ C2: float,
337
+ C3: float,
338
+ ) -> float:
339
+ """
340
+ GPU-compatible Sellmeier equation.
341
+
342
+ Computes refractive index from wavelength and Sellmeier coefficients.
343
+
344
+ Parameters
345
+ ----------
346
+ wl_um : float
347
+ Wavelength in micrometers.
348
+ B1, B2, B3 : float
349
+ Sellmeier B coefficients.
350
+ C1, C2, C3 : float
351
+ Sellmeier C coefficients in μm².
352
+
353
+ Returns
354
+ -------
355
+ n : float
356
+ Refractive index.
357
+ """
358
+ wl2 = wl_um * wl_um
359
+ n2_minus_1 = B1 * wl2 / (wl2 - C1) + B2 * wl2 / (wl2 - C2) + B3 * wl2 / (wl2 - C3)
360
+ return math.sqrt(1.0 + n2_minus_1)
361
+
362
+
363
+ @cuda.jit(device=True)
364
+ def device_cauchy_equation(
365
+ wl_um: float,
366
+ A: float,
367
+ B: float,
368
+ C: float,
369
+ ) -> float:
370
+ """
371
+ GPU-compatible Cauchy equation.
372
+
373
+ Computes refractive index from wavelength and Cauchy coefficients.
374
+
375
+ Parameters
376
+ ----------
377
+ wl_um : float
378
+ Wavelength in micrometers.
379
+ A : float
380
+ Constant term.
381
+ B : float
382
+ First-order dispersion coefficient in μm².
383
+ C : float
384
+ Second-order dispersion coefficient in μm⁴.
385
+
386
+ Returns
387
+ -------
388
+ n : float
389
+ Refractive index.
390
+ """
391
+ wl2 = wl_um * wl_um
392
+ return A + B / wl2 + C / (wl2 * wl2)
393
+
394
+
395
+ # =============================================================================
396
+ # Pure Python Step Functions (for CPU propagation)
397
+ # =============================================================================
398
+
399
+
400
+ def euler_step(
401
+ x: float,
402
+ y: float,
403
+ z: float,
404
+ dx: float,
405
+ dy: float,
406
+ dz: float,
407
+ n: float,
408
+ grad_x: float,
409
+ grad_y: float,
410
+ grad_z: float,
411
+ step_size: float,
412
+ ) -> tuple[float, float, float, float, float, float]:
413
+ """
414
+ Single Euler integration step for ray equation.
415
+
416
+ Parameters
417
+ ----------
418
+ x, y, z : float
419
+ Current position
420
+ dx, dy, dz : float
421
+ Current direction (unit vector)
422
+ n : float
423
+ Refractive index at current position
424
+ grad_x, grad_y, grad_z : float
425
+ Gradient of n at current position
426
+ step_size : float
427
+ Step size ds
428
+
429
+ Returns
430
+ -------
431
+ tuple
432
+ (new_x, new_y, new_z, new_dx, new_dy, new_dz)
433
+ """
434
+ # Curvature: κ = (∇n - (d̂·∇n)d̂) / n
435
+ dot = dx * grad_x + dy * grad_y + dz * grad_z
436
+ kappa_x = (grad_x - dot * dx) / n
437
+ kappa_y = (grad_y - dot * dy) / n
438
+ kappa_z = (grad_z - dot * dz) / n
439
+
440
+ # Update position: r_new = r + d̂ * ds
441
+ new_x = x + dx * step_size
442
+ new_y = y + dy * step_size
443
+ new_z = z + dz * step_size
444
+
445
+ # Update direction: d̂_new = d̂ + κ * ds
446
+ new_dx = dx + kappa_x * step_size
447
+ new_dy = dy + kappa_y * step_size
448
+ new_dz = dz + kappa_z * step_size
449
+
450
+ # Renormalize
451
+ norm = math.sqrt(new_dx**2 + new_dy**2 + new_dz**2)
452
+ if norm > 1e-12:
453
+ new_dx /= norm
454
+ new_dy /= norm
455
+ new_dz /= norm
456
+
457
+ return new_x, new_y, new_z, new_dx, new_dy, new_dz
458
+
459
+
460
+ def rk4_step(
461
+ x: float,
462
+ y: float,
463
+ z: float,
464
+ dx: float,
465
+ dy: float,
466
+ dz: float,
467
+ n_func: Callable[[float, float, float, float], float],
468
+ grad_func: Callable[[float, float, float, float], tuple[float, float, float]],
469
+ wavelength: float,
470
+ step_size: float,
471
+ ) -> tuple[float, float, float, float, float, float, float]:
472
+ """
473
+ RK4 integration step for ray equation in gradient medium.
474
+
475
+ Solves the coupled ODEs:
476
+ dr/ds = d̂
477
+ dd̂/ds = (∇n - (d̂·∇n)d̂) / n
478
+
479
+ Parameters
480
+ ----------
481
+ x, y, z : float
482
+ Current position
483
+ dx, dy, dz : float
484
+ Current direction (unit vector)
485
+ n_func : callable
486
+ Function to evaluate n(x, y, z, wavelength)
487
+ grad_func : callable
488
+ Function to evaluate ∇n(x, y, z, wavelength) -> (gx, gy, gz)
489
+ wavelength : float
490
+ Wavelength
491
+ step_size : float
492
+ Integration step size
493
+
494
+ Returns
495
+ -------
496
+ tuple of float
497
+ (new_x, new_y, new_z, new_dx, new_dy, new_dz, optical_path_increment)
498
+ """
499
+ h = step_size
500
+ h2 = h / 2.0
501
+
502
+ def _normalize(vx, vy, vz):
503
+ norm = math.sqrt(vx**2 + vy**2 + vz**2)
504
+ if norm > 1e-12:
505
+ return vx / norm, vy / norm, vz / norm
506
+ return vx, vy, vz
507
+
508
+ def _curvature(px, py, pz, dirx, diry, dirz):
509
+ n = n_func(px, py, pz, wavelength)
510
+ gx, gy, gz = grad_func(px, py, pz, wavelength)
511
+ dot = dirx * gx + diry * gy + dirz * gz
512
+ kx = (gx - dot * dirx) / n
513
+ ky = (gy - dot * diry) / n
514
+ kz = (gz - dot * dirz) / n
515
+ return n, kx, ky, kz
516
+
517
+ # k1 evaluation at current point
518
+ n0, k1_dx, k1_dy, k1_dz = _curvature(x, y, z, dx, dy, dz)
519
+ k1_rx, k1_ry, k1_rz = dx, dy, dz
520
+
521
+ # Intermediate point 1
522
+ x1 = x + h2 * k1_rx
523
+ y1 = y + h2 * k1_ry
524
+ z1 = z + h2 * k1_rz
525
+ dx1, dy1, dz1 = _normalize(dx + h2 * k1_dx, dy + h2 * k1_dy, dz + h2 * k1_dz)
526
+
527
+ # k2 evaluation
528
+ n1, k2_dx, k2_dy, k2_dz = _curvature(x1, y1, z1, dx1, dy1, dz1)
529
+ k2_rx, k2_ry, k2_rz = dx1, dy1, dz1
530
+
531
+ # Intermediate point 2
532
+ x2 = x + h2 * k2_rx
533
+ y2 = y + h2 * k2_ry
534
+ z2 = z + h2 * k2_rz
535
+ dx2, dy2, dz2 = _normalize(dx + h2 * k2_dx, dy + h2 * k2_dy, dz + h2 * k2_dz)
536
+
537
+ # k3 evaluation
538
+ n2, k3_dx, k3_dy, k3_dz = _curvature(x2, y2, z2, dx2, dy2, dz2)
539
+ k3_rx, k3_ry, k3_rz = dx2, dy2, dz2
540
+
541
+ # End point
542
+ x3 = x + h * k3_rx
543
+ y3 = y + h * k3_ry
544
+ z3 = z + h * k3_rz
545
+ dx3, dy3, dz3 = _normalize(dx + h * k3_dx, dy + h * k3_dy, dz + h * k3_dz)
546
+
547
+ # k4 evaluation
548
+ n3, k4_dx, k4_dy, k4_dz = _curvature(x3, y3, z3, dx3, dy3, dz3)
549
+ k4_rx, k4_ry, k4_rz = dx3, dy3, dz3
550
+
551
+ # Final RK4 combination
552
+ new_x = x + (h / 6.0) * (k1_rx + 2 * k2_rx + 2 * k3_rx + k4_rx)
553
+ new_y = y + (h / 6.0) * (k1_ry + 2 * k2_ry + 2 * k3_ry + k4_ry)
554
+ new_z = z + (h / 6.0) * (k1_rz + 2 * k2_rz + 2 * k3_rz + k4_rz)
555
+
556
+ new_dx = dx + (h / 6.0) * (k1_dx + 2 * k2_dx + 2 * k3_dx + k4_dx)
557
+ new_dy = dy + (h / 6.0) * (k1_dy + 2 * k2_dy + 2 * k3_dy + k4_dy)
558
+ new_dz = dz + (h / 6.0) * (k1_dz + 2 * k2_dz + 2 * k3_dz + k4_dz)
559
+
560
+ new_dx, new_dy, new_dz = _normalize(new_dx, new_dy, new_dz)
561
+
562
+ # Optical path: Simpson's rule approximation
563
+ n_avg = (n0 + 4 * n1 + n2) / 6.0
564
+ optical_increment = n_avg * h
565
+
566
+ return new_x, new_y, new_z, new_dx, new_dy, new_dz, optical_increment
567
+
568
+
569
+ def compute_adaptive_step_size(
570
+ gradient_magnitude: float,
571
+ refractive_index: float,
572
+ wavelength: float,
573
+ min_step: float,
574
+ max_step: float,
575
+ ) -> float:
576
+ """
577
+ Compute adaptive step size based on local gradient.
578
+
579
+ Parameters
580
+ ----------
581
+ gradient_magnitude : float
582
+ |∇n| at current position
583
+ refractive_index : float
584
+ n at current position
585
+ wavelength : float
586
+ Wavelength in meters
587
+ min_step : float
588
+ Minimum allowed step size
589
+ max_step : float
590
+ Maximum allowed step size
591
+
592
+ Returns
593
+ -------
594
+ float
595
+ Recommended step size
596
+
597
+ Notes
598
+ -----
599
+ Step size is chosen to resolve curvature: Δs ≤ R_c / 10
600
+ where R_c = n / |∇n| is the radius of curvature.
601
+ """
602
+ # Radius of curvature
603
+ if gradient_magnitude > 1e-12:
604
+ radius_curvature = refractive_index / gradient_magnitude
605
+ step_from_curvature = radius_curvature / 10.0
606
+ else:
607
+ step_from_curvature = max_step
608
+
609
+ # Wavelength in medium
610
+ wavelength_medium = wavelength / refractive_index
611
+
612
+ # Take minimum
613
+ step = min(step_from_curvature, wavelength_medium)
614
+
615
+ # Clamp to limits
616
+ return max(min_step, min(step, max_step))
617
+
618
+
619
+ # =============================================================================
620
+ # Vectorized NumPy Operations (for batch CPU propagation)
621
+ # =============================================================================
622
+
623
+
624
+ def normalize_directions(
625
+ directions: npt.NDArray[np.float32],
626
+ ) -> npt.NDArray[np.float32]:
627
+ """Normalize direction vectors to unit length."""
628
+ norms = np.linalg.norm(directions, axis=1, keepdims=True)
629
+ norms = np.maximum(norms, 1e-12) # Avoid division by zero
630
+ return directions / norms
631
+
632
+
633
+ def euler_step_batch(
634
+ positions: npt.NDArray[np.float32],
635
+ directions: npt.NDArray[np.float32],
636
+ active_mask: npt.NDArray[np.bool_],
637
+ n: npt.NDArray[np.float32],
638
+ grad_n: npt.NDArray[np.float32], # (N, 3)
639
+ step_size: float,
640
+ ) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.float32]]:
641
+ """
642
+ Vectorized Euler step for batch of rays.
643
+
644
+ Parameters
645
+ ----------
646
+ positions : ndarray of shape (N, 3)
647
+ Ray positions
648
+ directions : ndarray of shape (N, 3)
649
+ Ray directions (unit vectors)
650
+ active_mask : ndarray of shape (N,)
651
+ Boolean mask for active rays
652
+ n : ndarray of shape (N,)
653
+ Refractive index at each position
654
+ grad_n : ndarray of shape (N, 3)
655
+ Gradient of n at each position
656
+ step_size : float
657
+ Step size
658
+
659
+ Returns
660
+ -------
661
+ new_positions : ndarray of shape (N, 3)
662
+ new_directions : ndarray of shape (N, 3)
663
+ """
664
+ # Only process active rays
665
+ new_positions = positions.copy()
666
+ new_directions = directions.copy()
667
+
668
+ # d̂ · ∇n
669
+ dot = np.sum(directions[active_mask] * grad_n[active_mask], axis=1, keepdims=True)
670
+
671
+ # κ = (∇n - (d̂·∇n)d̂) / n
672
+ n_active = n[active_mask][:, np.newaxis]
673
+ kappa = (grad_n[active_mask] - dot * directions[active_mask]) / n_active
674
+
675
+ # Update position and direction
676
+ new_positions[active_mask] += directions[active_mask] * step_size
677
+ new_directions[active_mask] += kappa * step_size
678
+
679
+ # Renormalize
680
+ new_directions[active_mask] = normalize_directions(new_directions[active_mask])
681
+
682
+ return new_positions, new_directions
683
+
684
+
685
+ def rk4_step_batch(
686
+ positions: npt.NDArray[np.float32],
687
+ directions: npt.NDArray[np.float32],
688
+ active_mask: npt.NDArray[np.bool_],
689
+ material, # MaterialFieldProtocol
690
+ step_size: float,
691
+ wavelength: float,
692
+ ) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.float32], npt.NDArray[np.float32]]:
693
+ """
694
+ Vectorized RK4 step for batch of rays.
695
+
696
+ Parameters
697
+ ----------
698
+ positions : ndarray of shape (N, 3)
699
+ Ray positions
700
+ directions : ndarray of shape (N, 3)
701
+ Ray directions (unit vectors)
702
+ active_mask : ndarray of shape (N,)
703
+ Boolean mask for active rays
704
+ material : MaterialFieldProtocol
705
+ Material providing n and ∇n evaluation
706
+ step_size : float
707
+ Step size
708
+ wavelength : float
709
+ Wavelength
710
+
711
+ Returns
712
+ -------
713
+ new_positions : ndarray of shape (N, 3)
714
+ new_directions : ndarray of shape (N, 3)
715
+ n_avg : ndarray of shape (N,)
716
+ Average refractive index over step (for optical path)
717
+ """
718
+ h = step_size
719
+ h2 = h / 2.0
720
+ num_rays = len(positions)
721
+
722
+ # Initialize outputs
723
+ new_positions = positions.copy()
724
+ new_directions = directions.copy()
725
+ n_avg = np.ones(num_rays, dtype=np.float32)
726
+
727
+ if not np.any(active_mask):
728
+ return new_positions, new_directions, n_avg
729
+
730
+ # Get active rays
731
+ pos = positions[active_mask]
732
+ dirs = directions[active_mask]
733
+
734
+ def get_n_and_grad(p):
735
+ x, y, z = p[:, 0], p[:, 1], p[:, 2]
736
+ n = material.get_refractive_index(x, y, z, wavelength).astype(np.float32)
737
+ gx, gy, gz = material.get_refractive_index_gradient(x, y, z, wavelength)
738
+ grad = np.stack([gx, gy, gz], axis=1).astype(np.float32)
739
+ return n, grad
740
+
741
+ def compute_curvature(n, grad, d):
742
+ dot = np.sum(d * grad, axis=1, keepdims=True)
743
+ kappa = (grad - dot * d) / n[:, np.newaxis]
744
+ return kappa
745
+
746
+ # k1
747
+ n0, grad0 = get_n_and_grad(pos)
748
+ k1_r = dirs
749
+ k1_d = compute_curvature(n0, grad0, dirs)
750
+
751
+ # Intermediate 1
752
+ pos1 = pos + h2 * k1_r
753
+ dirs1 = normalize_directions(dirs + h2 * k1_d)
754
+
755
+ # k2
756
+ n1, grad1 = get_n_and_grad(pos1)
757
+ k2_r = dirs1
758
+ k2_d = compute_curvature(n1, grad1, dirs1)
759
+
760
+ # Intermediate 2
761
+ pos2 = pos + h2 * k2_r
762
+ dirs2 = normalize_directions(dirs + h2 * k2_d)
763
+
764
+ # k3
765
+ n2, grad2 = get_n_and_grad(pos2)
766
+ k3_r = dirs2
767
+ k3_d = compute_curvature(n2, grad2, dirs2)
768
+
769
+ # End
770
+ pos3 = pos + h * k3_r
771
+ dirs3 = normalize_directions(dirs + h * k3_d)
772
+
773
+ # k4
774
+ n3, grad3 = get_n_and_grad(pos3)
775
+ k4_r = dirs3
776
+ k4_d = compute_curvature(n3, grad3, dirs3)
777
+
778
+ # RK4 combination
779
+ new_pos = pos + (h / 6.0) * (k1_r + 2 * k2_r + 2 * k3_r + k4_r)
780
+ new_dir = dirs + (h / 6.0) * (k1_d + 2 * k2_d + 2 * k3_d + k4_d)
781
+ new_dir = normalize_directions(new_dir)
782
+
783
+ # Store results
784
+ new_positions[active_mask] = new_pos
785
+ new_directions[active_mask] = new_dir
786
+
787
+ # Simpson's rule for average n
788
+ n_avg[active_mask] = (n0 + 4 * n1 + n2) / 6.0
789
+
790
+ return new_positions, new_directions, n_avg