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,429 @@
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
+ CPU-based Gradient Propagator for inhomogeneous media.
36
+
37
+ This module provides a propagator for materials with spatially-varying
38
+ refractive indices, using vectorized NumPy operations for efficiency.
39
+ """
40
+
41
+ import numpy as np
42
+ import numpy.typing as npt
43
+
44
+ from ..propagator_protocol import MaterialFieldProtocol
45
+
46
+
47
+ class GradientPropagator:
48
+ """
49
+ Propagator for inhomogeneous media using ray equation integration.
50
+
51
+ This propagator handles materials with spatially-varying refractive
52
+ indices by numerically integrating the ray equation (eikonal equation).
53
+ Supports both Euler and RK4 integration methods.
54
+
55
+ Attributes
56
+ ----------
57
+ use_rk4 : bool
58
+ If True, use RK4 integration; otherwise use Euler
59
+ adaptive_stepping : bool
60
+ If True, adapt step size to local gradient
61
+ min_step_size : float
62
+ Minimum step size in meters
63
+ max_step_size : float
64
+ Maximum step size in meters
65
+ threads_per_block : int
66
+ CUDA threads per block (legacy, not used for CPU)
67
+
68
+ References
69
+ ----------
70
+ .. [1] Born, M., & Wolf, E. (1999). Principles of Optics. Section 3.1.
71
+ .. [2] Sharma, A., Kumar, D. V., & Ghatak, A. K. (1982). Tracing rays
72
+ through graded-index media: a new method. Applied Optics, 21(6),
73
+ 984-987.
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ use_rk4: bool | None = None,
79
+ method: str | None = None,
80
+ adaptive_stepping: bool = True,
81
+ min_step_size: float = 1e-6,
82
+ max_step_size: float = 1e-3,
83
+ threads_per_block: int = 256,
84
+ ):
85
+ """
86
+ Initialize gradient propagator.
87
+
88
+ Parameters
89
+ ----------
90
+ use_rk4 : bool, optional
91
+ Use RK4 instead of Euler (deprecated, use method instead)
92
+ method : str, optional
93
+ Integration method: "euler" or "rk4", default "rk4"
94
+ adaptive_stepping : bool, optional
95
+ Enable adaptive step sizing, default True
96
+ min_step_size : float, optional
97
+ Minimum step size in meters, default 1 μm
98
+ max_step_size : float, optional
99
+ Maximum step size in meters, default 1 mm
100
+ threads_per_block : int, optional
101
+ CUDA threads per block (legacy), default 256
102
+ """
103
+ # Handle both old (use_rk4) and new (method) parameter styles
104
+ if method is not None:
105
+ if method.lower() == "rk4":
106
+ self.use_rk4 = True
107
+ elif method.lower() == "euler":
108
+ self.use_rk4 = False
109
+ else:
110
+ raise ValueError(f"Unknown method '{method}'. Use 'euler' or 'rk4'.")
111
+ elif use_rk4 is not None:
112
+ self.use_rk4 = use_rk4
113
+ else:
114
+ self.use_rk4 = True # Default to RK4
115
+
116
+ self.adaptive_stepping = adaptive_stepping
117
+ self.min_step_size = min_step_size
118
+ self.max_step_size = max_step_size
119
+ self.threads_per_block = threads_per_block
120
+
121
+ def compute_gradient_threshold(self, wavelength: float) -> float:
122
+ """
123
+ Compute gradient threshold for switching to curved-path mode.
124
+
125
+ Parameters
126
+ ----------
127
+ wavelength : float
128
+ Wavelength in meters
129
+
130
+ Returns
131
+ -------
132
+ float
133
+ Gradient magnitude threshold in m^-1
134
+
135
+ Notes
136
+ -----
137
+ Threshold is chosen such that ray curvature becomes significant
138
+ over a distance of ~wavelength: |∇n|/n > 1/λ
139
+ """
140
+ return 1.0 / wavelength
141
+
142
+ def propagate_step_cpu(
143
+ self,
144
+ positions: npt.NDArray[np.floating],
145
+ directions: npt.NDArray[np.floating],
146
+ active: npt.NDArray[np.bool_],
147
+ material: MaterialFieldProtocol,
148
+ step_size: float,
149
+ wavelength: float,
150
+ geometric_path_lengths: npt.NDArray[np.floating] | None = None,
151
+ optical_path_lengths: npt.NDArray[np.floating] | None = None,
152
+ accumulated_time: npt.NDArray[np.floating] | None = None,
153
+ ) -> None:
154
+ """
155
+ Propagate rays through gradient medium using CPU (vectorized NumPy).
156
+
157
+ This method modifies arrays in-place using Euler or RK4 integration.
158
+
159
+ Parameters
160
+ ----------
161
+ positions : ndarray
162
+ Ray positions, shape (N, 3), modified in-place.
163
+ directions : ndarray
164
+ Ray directions, shape (N, 3), modified in-place.
165
+ active : ndarray
166
+ Active ray mask, shape (N,).
167
+ material : MaterialFieldProtocol
168
+ Material with get_refractive_index and get_refractive_index_gradient.
169
+ step_size : float
170
+ Step size in meters.
171
+ wavelength : float
172
+ Wavelength in meters.
173
+ geometric_path_lengths : ndarray, optional
174
+ Geometric path lengths, shape (N,), updated in-place.
175
+ optical_path_lengths : ndarray, optional
176
+ Optical path lengths, shape (N,), updated in-place.
177
+ accumulated_time : ndarray, optional
178
+ Accumulated time, shape (N,), updated in-place.
179
+ """
180
+ c = 299792458.0 # Speed of light
181
+
182
+ active_mask = active.astype(bool)
183
+ if not np.any(active_mask):
184
+ return
185
+
186
+ pos = positions[active_mask]
187
+ dirs = directions[active_mask]
188
+
189
+ if self.use_rk4:
190
+ new_pos, new_dirs, n_avg = self._rk4_step_vectorized(
191
+ pos, dirs, material, step_size, wavelength
192
+ )
193
+ else:
194
+ new_pos, new_dirs, n_avg = self._euler_step_vectorized(
195
+ pos, dirs, material, step_size, wavelength
196
+ )
197
+
198
+ positions[active_mask] = new_pos.astype(np.float32)
199
+ directions[active_mask] = new_dirs.astype(np.float32)
200
+
201
+ if geometric_path_lengths is not None:
202
+ geometric_path_lengths[active_mask] += step_size
203
+
204
+ if optical_path_lengths is not None:
205
+ optical_path_lengths[active_mask] += (n_avg * step_size).astype(np.float32)
206
+
207
+ if accumulated_time is not None:
208
+ dt = (n_avg * step_size / c).astype(np.float32)
209
+ accumulated_time[active_mask] += dt
210
+
211
+ def _euler_step_vectorized(
212
+ self,
213
+ positions: npt.NDArray[np.floating],
214
+ directions: npt.NDArray[np.floating],
215
+ material: MaterialFieldProtocol,
216
+ step_size: float,
217
+ wavelength: float,
218
+ ) -> tuple[npt.NDArray, npt.NDArray, npt.NDArray]:
219
+ """
220
+ Vectorized Euler step for gradient medium.
221
+
222
+ Returns
223
+ -------
224
+ new_positions : ndarray
225
+ Updated positions.
226
+ new_directions : ndarray
227
+ Updated directions.
228
+ n_values : ndarray
229
+ Refractive index values for optical path calculation.
230
+ """
231
+ x, y, z = positions[:, 0], positions[:, 1], positions[:, 2]
232
+ dx, dy, dz = directions[:, 0], directions[:, 1], directions[:, 2]
233
+
234
+ n = material.get_refractive_index(x, y, z, wavelength)
235
+ grad = material.get_refractive_index_gradient(x, y, z, wavelength)
236
+ grad_x, grad_y, grad_z = (
237
+ np.asarray(grad[0]),
238
+ np.asarray(grad[1]),
239
+ np.asarray(grad[2]),
240
+ )
241
+
242
+ dot_product = dx * grad_x + dy * grad_y + dz * grad_z
243
+ kappa_x = (grad_x - dot_product * dx) / n
244
+ kappa_y = (grad_y - dot_product * dy) / n
245
+ kappa_z = (grad_z - dot_product * dz) / n
246
+
247
+ new_x = x + dx * step_size
248
+ new_y = y + dy * step_size
249
+ new_z = z + dz * step_size
250
+
251
+ new_dx = dx + kappa_x * step_size
252
+ new_dy = dy + kappa_y * step_size
253
+ new_dz = dz + kappa_z * step_size
254
+
255
+ norm = np.sqrt(new_dx**2 + new_dy**2 + new_dz**2)
256
+ norm = np.where(norm < 1e-12, 1.0, norm)
257
+ new_dx /= norm
258
+ new_dy /= norm
259
+ new_dz /= norm
260
+
261
+ new_positions = np.column_stack([new_x, new_y, new_z])
262
+ new_directions = np.column_stack([new_dx, new_dy, new_dz])
263
+
264
+ return new_positions, new_directions, n
265
+
266
+ def _rk4_step_vectorized(
267
+ self,
268
+ positions: npt.NDArray[np.floating],
269
+ directions: npt.NDArray[np.floating],
270
+ material: MaterialFieldProtocol,
271
+ step_size: float,
272
+ wavelength: float,
273
+ ) -> tuple[npt.NDArray, npt.NDArray, npt.NDArray]:
274
+ """
275
+ Vectorized RK4 step for gradient medium.
276
+
277
+ Returns
278
+ -------
279
+ new_positions : ndarray
280
+ Updated positions.
281
+ new_directions : ndarray
282
+ Updated directions.
283
+ n_avg : ndarray
284
+ Average refractive index for optical path calculation.
285
+ """
286
+ h = step_size
287
+ h2 = h / 2.0
288
+
289
+ def compute_derivatives(pos, dirs):
290
+ x, y, z = pos[:, 0], pos[:, 1], pos[:, 2]
291
+ dx, dy, dz = dirs[:, 0], dirs[:, 1], dirs[:, 2]
292
+
293
+ n = material.get_refractive_index(x, y, z, wavelength)
294
+ grad = material.get_refractive_index_gradient(x, y, z, wavelength)
295
+ grad_x, grad_y, grad_z = (
296
+ np.asarray(grad[0]),
297
+ np.asarray(grad[1]),
298
+ np.asarray(grad[2]),
299
+ )
300
+
301
+ dot_product = dx * grad_x + dy * grad_y + dz * grad_z
302
+ kappa_x = (grad_x - dot_product * dx) / n
303
+ kappa_y = (grad_y - dot_product * dy) / n
304
+ kappa_z = (grad_z - dot_product * dz) / n
305
+
306
+ dr_ds = dirs.copy()
307
+ dd_ds = np.column_stack([kappa_x, kappa_y, kappa_z])
308
+
309
+ return dr_ds, dd_ds, n
310
+
311
+ def normalize_directions(dirs):
312
+ norm = np.linalg.norm(dirs, axis=1, keepdims=True)
313
+ norm = np.where(norm < 1e-12, 1.0, norm)
314
+ return dirs / norm
315
+
316
+ # k1
317
+ k1_r, k1_d, n0 = compute_derivatives(positions, directions)
318
+
319
+ # k2
320
+ pos1 = positions + h2 * k1_r
321
+ dirs1 = normalize_directions(directions + h2 * k1_d)
322
+ k2_r, k2_d, n1 = compute_derivatives(pos1, dirs1)
323
+
324
+ # k3
325
+ pos2 = positions + h2 * k2_r
326
+ dirs2 = normalize_directions(directions + h2 * k2_d)
327
+ k3_r, k3_d, n2 = compute_derivatives(pos2, dirs2)
328
+
329
+ # k4
330
+ pos3 = positions + h * k3_r
331
+ dirs3 = normalize_directions(directions + h * k3_d)
332
+ k4_r, k4_d, n3 = compute_derivatives(pos3, dirs3)
333
+
334
+ # Final RK4 combination
335
+ new_positions = positions + (h / 6.0) * (k1_r + 2 * k2_r + 2 * k3_r + k4_r)
336
+ new_directions = directions + (h / 6.0) * (k1_d + 2 * k2_d + 2 * k3_d + k4_d)
337
+ new_directions = normalize_directions(new_directions)
338
+
339
+ # Simpson's rule for average n
340
+ n_avg = (n0 + 4 * n1 + n2) / 6.0
341
+
342
+ return new_positions, new_directions, n_avg
343
+
344
+ # ========================================================================
345
+ # Unified Interface Methods (RayPropagatorProtocol)
346
+ # ========================================================================
347
+
348
+ def propagate_step(
349
+ self,
350
+ rays, # RayBatch
351
+ step_size: float,
352
+ wavelength: float = 532e-9,
353
+ material: MaterialFieldProtocol | None = None,
354
+ ) -> None:
355
+ """
356
+ Propagate rays by a single integration step.
357
+
358
+ This is the unified interface method compatible with GPU propagators.
359
+ Delegates to propagate_step_cpu internally.
360
+
361
+ Parameters
362
+ ----------
363
+ rays : RayBatch
364
+ Ray batch containing positions, directions, and other ray properties.
365
+ Modified in-place.
366
+ step_size : float
367
+ Integration step size in meters.
368
+ wavelength : float, optional
369
+ Wavelength in meters, default 532 nm.
370
+ material : MaterialFieldProtocol, optional
371
+ Material to propagate through. If not provided, must be set via
372
+ a separate mechanism (e.g., stored during initialization).
373
+ """
374
+ if material is None:
375
+ raise ValueError(
376
+ "material must be provided. CPU propagator requires explicit material."
377
+ )
378
+
379
+ self.propagate_step_cpu(
380
+ positions=rays.positions,
381
+ directions=rays.directions,
382
+ active=rays.active,
383
+ material=material,
384
+ step_size=step_size,
385
+ wavelength=wavelength,
386
+ geometric_path_lengths=rays.geometric_path_lengths,
387
+ optical_path_lengths=rays.optical_path_lengths,
388
+ accumulated_time=rays.accumulated_time,
389
+ )
390
+
391
+ def propagate(
392
+ self,
393
+ rays, # RayBatch
394
+ total_distance: float,
395
+ step_size: float,
396
+ wavelength: float = 532e-9,
397
+ material: MaterialFieldProtocol | None = None,
398
+ ) -> None:
399
+ """
400
+ Propagate rays through a total distance.
401
+
402
+ This is the unified interface method compatible with GPU propagators.
403
+
404
+ Parameters
405
+ ----------
406
+ rays : RayBatch
407
+ Ray batch to propagate (modified in-place)
408
+ total_distance : float
409
+ Total distance to propagate in meters
410
+ step_size : float
411
+ Integration step size in meters
412
+ wavelength : float, optional
413
+ Wavelength in meters, default 532 nm
414
+ material : MaterialFieldProtocol, optional
415
+ Material to propagate through
416
+ """
417
+ if material is None:
418
+ raise ValueError(
419
+ "material must be provided. CPU propagator requires explicit material."
420
+ )
421
+
422
+ num_steps = int(total_distance / step_size)
423
+ for _ in range(num_steps):
424
+ self.propagate_step(
425
+ rays=rays,
426
+ step_size=step_size,
427
+ wavelength=wavelength,
428
+ material=material,
429
+ )