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,483 @@
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
+ Curved Wave Surface (GPU-Capable)
36
+
37
+ GPU-accelerated ocean wave surface on a curved (spherical) Earth.
38
+ Supports a single wave component for efficient signed distance computation on GPU.
39
+
40
+ For multi-wave surfaces or CPU-only computation, see the CPU-only
41
+ CurvedWaveSurface in surfaces.cpu.
42
+ """
43
+
44
+ from dataclasses import dataclass, field
45
+ from typing import Any
46
+
47
+ import numpy as np
48
+ import numpy.typing as npt
49
+
50
+ from ..protocol import Surface, SurfaceRole
51
+ from ..registry import register_surface_type
52
+
53
+ # Earth parameters
54
+ EARTH_RADIUS = 6.371e6 # Earth's mean radius in meters
55
+ GRAVITY = 9.81 # Gravity for deep water dispersion
56
+
57
+
58
+ @dataclass
59
+ class GPUCurvedWaveSurface(Surface):
60
+ """
61
+ Curved-earth ocean wave surface with GPU acceleration.
62
+
63
+ This is a simplified single-wave surface on a spherical Earth,
64
+ optimized for GPU computation. The wave is treated as a perturbation
65
+ on top of Earth's spherical surface.
66
+
67
+ For multiple superimposed waves, use the CPU-only CurvedWaveSurface.
68
+
69
+ Parameters
70
+ ----------
71
+ amplitude : float
72
+ Wave amplitude in meters.
73
+ wavelength : float
74
+ Wave wavelength (crest-to-crest) in meters.
75
+ direction : tuple of float
76
+ Wave propagation direction (dx, dy) in local tangent coordinates.
77
+ earth_center : tuple of float, optional
78
+ Center of Earth sphere. Default is (0, 0, -EARTH_RADIUS).
79
+ earth_radius : float, optional
80
+ Earth radius in meters. Default is EARTH_RADIUS.
81
+ time : float, optional
82
+ Animation time in seconds. Default is 0.0.
83
+ role : SurfaceRole
84
+ What happens when a ray hits (typically OPTICAL).
85
+ name : str
86
+ Human-readable name.
87
+ material_front : MaterialField, optional
88
+ Material above surface (atmosphere).
89
+ material_back : MaterialField, optional
90
+ Material below surface (ocean water).
91
+
92
+ Examples
93
+ --------
94
+ >>> from lsurf.surfaces import GPUCurvedWaveSurface, SurfaceRole
95
+ >>> from lsurf.materials import ExponentialAtmosphere, WATER
96
+ >>>
97
+ >>> ocean = GPUCurvedWaveSurface(
98
+ ... amplitude=1.5,
99
+ ... wavelength=50.0,
100
+ ... direction=(1.0, 0.0),
101
+ ... role=SurfaceRole.OPTICAL,
102
+ ... name="ocean",
103
+ ... material_front=ExponentialAtmosphere(),
104
+ ... material_back=WATER,
105
+ ... )
106
+ """
107
+
108
+ amplitude: float
109
+ wavelength: float
110
+ direction: tuple[float, float]
111
+ role: SurfaceRole
112
+ earth_center: tuple[float, float, float] = (0, 0, -EARTH_RADIUS)
113
+ earth_radius: float = EARTH_RADIUS
114
+ time: float = 0.0
115
+ name: str = "gpu_curved_wave"
116
+ material_front: Any = None
117
+ material_back: Any = None
118
+
119
+ # GPU capability
120
+ _gpu_capable: bool = field(default=True, init=False, repr=False)
121
+ _geometry_id: int = field(default=4, init=False, repr=False) # curved_wave = 4
122
+
123
+ # Precomputed values (set in __post_init__)
124
+ _wave_number: float = field(default=0.0, init=False, repr=False)
125
+ _dir_normalized: tuple[float, float] = field(
126
+ default=(1.0, 0.0), init=False, repr=False
127
+ )
128
+ _earth_center_arr: npt.NDArray = field(default=None, init=False, repr=False)
129
+
130
+ def __post_init__(self) -> None:
131
+ if self.amplitude <= 0:
132
+ raise ValueError("Amplitude must be positive")
133
+ if self.wavelength <= 0:
134
+ raise ValueError("Wavelength must be positive")
135
+ if self.earth_radius <= 0:
136
+ raise ValueError("Earth radius must be positive")
137
+
138
+ # Compute wave number
139
+ self._wave_number = 2.0 * np.pi / self.wavelength
140
+
141
+ # Normalize direction
142
+ dx, dy = self.direction
143
+ norm = np.sqrt(dx * dx + dy * dy)
144
+ if norm < 1e-10:
145
+ self._dir_normalized = (1.0, 0.0)
146
+ else:
147
+ self._dir_normalized = (dx / norm, dy / norm)
148
+
149
+ # Store earth center as array
150
+ self._earth_center_arr = np.array(self.earth_center, dtype=np.float64)
151
+
152
+ @property
153
+ def gpu_capable(self) -> bool:
154
+ """This surface supports GPU acceleration."""
155
+ return True
156
+
157
+ @property
158
+ def geometry_id(self) -> int:
159
+ """GPU geometry type ID (curved_wave = 4)."""
160
+ return 4
161
+
162
+ @property
163
+ def wave_number(self) -> float:
164
+ """Wave number k = 2*pi/wavelength."""
165
+ return self._wave_number
166
+
167
+ @property
168
+ def angular_frequency(self) -> float:
169
+ """Angular frequency from deep water dispersion: omega = sqrt(g*k)."""
170
+ return np.sqrt(GRAVITY * self._wave_number)
171
+
172
+ def get_gpu_parameters(self) -> tuple:
173
+ """
174
+ Return parameters for GPU kernel.
175
+
176
+ Parameter layout (geometry_id = 4):
177
+ - p0: earth_center_x
178
+ - p1: earth_center_y
179
+ - p2: earth_center_z
180
+ - p3: earth_radius
181
+ - p4: amplitude
182
+ - p5: wave_number
183
+ - p6: dir_x
184
+ - p7: dir_y
185
+ - p8: time
186
+ - p9-p11: unused (0.0)
187
+
188
+ Returns
189
+ -------
190
+ tuple of 12 floats
191
+ """
192
+ return (
193
+ self.earth_center[0],
194
+ self.earth_center[1],
195
+ self.earth_center[2],
196
+ self.earth_radius,
197
+ self.amplitude,
198
+ self._wave_number,
199
+ self._dir_normalized[0],
200
+ self._dir_normalized[1],
201
+ self.time,
202
+ 0.0, # unused
203
+ 0.0, # unused
204
+ 0.0, # unused
205
+ )
206
+
207
+ def get_materials(self) -> tuple | None:
208
+ """Return materials for Fresnel calculation."""
209
+ if self.role == SurfaceRole.OPTICAL:
210
+ return (self.material_front, self.material_back)
211
+ return None
212
+
213
+ def _wave_height(self, x: npt.NDArray, y: npt.NDArray) -> npt.NDArray:
214
+ """Compute wave height at positions (x, y) in local tangent space."""
215
+ dir_x, dir_y = self._dir_normalized
216
+ omega = self.angular_frequency
217
+
218
+ # Phase at each position (simplified: use x,y as tangent coordinates)
219
+ dot = dir_x * x + dir_y * y
220
+ theta = self._wave_number * dot - omega * self.time
221
+
222
+ return self.amplitude * np.cos(theta)
223
+
224
+ def signed_distance(
225
+ self,
226
+ positions: npt.NDArray[np.float32],
227
+ ) -> npt.NDArray[np.float32]:
228
+ """
229
+ Compute signed distance from positions to curved wave surface.
230
+
231
+ Parameters
232
+ ----------
233
+ positions : ndarray, shape (N, 3)
234
+ Points to compute distance for
235
+
236
+ Returns
237
+ -------
238
+ ndarray, shape (N,)
239
+ Signed distance (positive outside, negative inside Earth+wave)
240
+ """
241
+ positions = positions.astype(np.float64)
242
+
243
+ # Vector from Earth center to each point
244
+ to_pos = positions - self._earth_center_arr
245
+ dist_from_center = np.linalg.norm(to_pos, axis=1)
246
+
247
+ # Wave height using x,y as local tangent coordinates
248
+ x = positions[:, 0]
249
+ y = positions[:, 1]
250
+ wave_height = self._wave_height(x, y)
251
+
252
+ # Surface is at earth_radius + wave_height from center
253
+ surface_radius = self.earth_radius + wave_height
254
+
255
+ # Signed distance: positive outside, negative inside
256
+ return (dist_from_center - surface_radius).astype(np.float32)
257
+
258
+ def intersect(
259
+ self,
260
+ origins: npt.NDArray[np.float32],
261
+ directions: npt.NDArray[np.float32],
262
+ min_distance: float = 1e-6,
263
+ max_iterations: int = 200,
264
+ tolerance: float = 1e-3,
265
+ max_distance: float | None = None,
266
+ ) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.bool_]]:
267
+ """
268
+ Find ray-surface intersections using ray marching.
269
+
270
+ Parameters
271
+ ----------
272
+ origins : ndarray, shape (N, 3)
273
+ Ray origin positions.
274
+ directions : ndarray, shape (N, 3)
275
+ Ray direction unit vectors.
276
+ min_distance : float
277
+ Minimum valid intersection distance.
278
+ max_iterations : int
279
+ Maximum ray marching iterations.
280
+ tolerance : float
281
+ Convergence tolerance in meters.
282
+ max_distance : float, optional
283
+ Maximum search distance.
284
+
285
+ Returns
286
+ -------
287
+ distances : ndarray, shape (N,)
288
+ Distance to intersection (inf if no hit).
289
+ hit_mask : ndarray, shape (N,), dtype=bool
290
+ True for rays that hit the surface.
291
+ """
292
+ origins = origins.astype(np.float64)
293
+ directions = directions.astype(np.float64)
294
+ n_rays = len(origins)
295
+
296
+ distances = np.full(n_rays, np.inf, dtype=np.float64)
297
+ hit_mask = np.zeros(n_rays, dtype=bool)
298
+
299
+ t = np.full(n_rays, min_distance, dtype=np.float64)
300
+ active = np.ones(n_rays, dtype=bool)
301
+
302
+ # Find intersection with outer sphere first
303
+ outer_radius = self.earth_radius + self.amplitude
304
+ oc = origins - self._earth_center_arr
305
+ a = np.sum(directions * directions, axis=1)
306
+ b = 2.0 * np.sum(directions * oc, axis=1)
307
+ c_outer = np.sum(oc * oc, axis=1) - outer_radius**2
308
+
309
+ discriminant_outer = b**2 - 4 * a * c_outer
310
+ has_potential_hit = discriminant_outer >= 0
311
+ active[~has_potential_hit] = False
312
+
313
+ sqrt_disc_outer = np.sqrt(np.maximum(discriminant_outer, 0))
314
+ t1_outer = (-b - sqrt_disc_outer) / (2 * a + 1e-20)
315
+ t_start = np.where(t1_outer > min_distance, t1_outer, min_distance)
316
+ t = t_start.copy()
317
+
318
+ prev_signed_dist = np.full(n_rays, np.inf)
319
+ prev_t = t.copy()
320
+ relaxation = 0.5
321
+
322
+ for _ in range(max_iterations):
323
+ if not np.any(active):
324
+ break
325
+
326
+ positions = origins + t[:, np.newaxis] * directions
327
+
328
+ to_pos = positions - self._earth_center_arr
329
+ dist_from_center = np.linalg.norm(to_pos, axis=1)
330
+ radial = to_pos / np.maximum(dist_from_center[:, np.newaxis], 1e-10)
331
+
332
+ cos_angle = np.abs(np.sum(directions * radial, axis=1))
333
+ cos_angle = np.maximum(cos_angle, 0.01)
334
+
335
+ x = positions[:, 0]
336
+ y = positions[:, 1]
337
+ wave_height = self._wave_height(x, y)
338
+
339
+ surface_radius = self.earth_radius + wave_height
340
+ signed_dist = dist_from_center - surface_radius
341
+
342
+ converged = np.abs(signed_dist) < tolerance
343
+ hit_mask[active & converged] = True
344
+ distances[active & converged] = t[active & converged]
345
+ active[converged] = False
346
+
347
+ # Bisection for sign changes
348
+ crossed = (
349
+ active
350
+ & (signed_dist * prev_signed_dist < 0)
351
+ & np.isfinite(prev_signed_dist)
352
+ )
353
+ if np.any(crossed):
354
+ t_low = np.where(prev_signed_dist > 0, prev_t, t)
355
+ t_high = np.where(prev_signed_dist > 0, t, prev_t)
356
+
357
+ for _ in range(15):
358
+ t_mid = (t_low + t_high) / 2
359
+ pos_mid = origins + t_mid[:, np.newaxis] * directions
360
+ to_pos_mid = pos_mid - self._earth_center_arr
361
+ dist_mid = np.linalg.norm(to_pos_mid, axis=1)
362
+ wh_mid = self._wave_height(pos_mid[:, 0], pos_mid[:, 1])
363
+ sd_mid = dist_mid - (self.earth_radius + wh_mid)
364
+
365
+ above = sd_mid > 0
366
+ t_low = np.where(crossed & above, t_mid, t_low)
367
+ t_high = np.where(crossed & ~above, t_mid, t_high)
368
+
369
+ t[crossed] = (t_low[crossed] + t_high[crossed]) / 2
370
+
371
+ positions = origins + t[:, np.newaxis] * directions
372
+ to_pos = positions - self._earth_center_arr
373
+ dist_from_center = np.linalg.norm(to_pos, axis=1)
374
+ wave_height = self._wave_height(positions[:, 0], positions[:, 1])
375
+ signed_dist = dist_from_center - (self.earth_radius + wave_height)
376
+
377
+ converged = np.abs(signed_dist) < tolerance
378
+ hit_mask[active & converged] = True
379
+ distances[active & converged] = t[active & converged]
380
+ active[converged] = False
381
+
382
+ too_far = signed_dist < -self.amplitude - 10
383
+ active[too_far] = False
384
+
385
+ if max_distance is not None:
386
+ exceeded_max = t > max_distance
387
+ active[exceeded_max] = False
388
+
389
+ prev_signed_dist = signed_dist.copy()
390
+ prev_t = t.copy()
391
+
392
+ step = signed_dist / cos_angle * relaxation
393
+ step = np.clip(step, -self.amplitude * 2, self.amplitude * 2)
394
+ t[active] += step[active]
395
+ t = np.maximum(t, 0)
396
+
397
+ return distances.astype(np.float32), hit_mask
398
+
399
+ def normal_at(
400
+ self,
401
+ positions: npt.NDArray[np.float32],
402
+ incoming_directions: npt.NDArray[np.float32] | None = None,
403
+ ) -> npt.NDArray[np.float32]:
404
+ """
405
+ Compute surface normal at given positions.
406
+
407
+ Parameters
408
+ ----------
409
+ positions : ndarray, shape (N, 3)
410
+ Points on the surface.
411
+ incoming_directions : ndarray, shape (N, 3), optional
412
+ Incoming ray directions.
413
+
414
+ Returns
415
+ -------
416
+ normals : ndarray, shape (N, 3)
417
+ Unit normal vectors.
418
+ """
419
+ positions = positions.astype(np.float64)
420
+
421
+ # Get local tangent basis
422
+ to_pos = positions - self._earth_center_arr
423
+ dist = np.linalg.norm(to_pos, axis=1, keepdims=True)
424
+ dist = np.maximum(dist, 1e-10)
425
+ radial = to_pos / dist
426
+
427
+ # Compute tangent vectors (simplified)
428
+ global_x = np.array([1.0, 0.0, 0.0], dtype=np.float64)
429
+ dot_x = np.sum(radial * global_x, axis=1, keepdims=True)
430
+ tangent_x = global_x - dot_x * radial
431
+ tangent_x_norm = np.linalg.norm(tangent_x, axis=1, keepdims=True)
432
+ tangent_x = tangent_x / np.maximum(tangent_x_norm, 1e-10)
433
+
434
+ global_y = np.array([0.0, 1.0, 0.0], dtype=np.float64)
435
+ dot_y = np.sum(radial * global_y, axis=1, keepdims=True)
436
+ tangent_y = global_y - dot_y * radial
437
+ tangent_y_norm = np.linalg.norm(tangent_y, axis=1, keepdims=True)
438
+ tangent_y = tangent_y / np.maximum(tangent_y_norm, 1e-10)
439
+
440
+ # Compute wave normal in local coordinates
441
+ x = positions[:, 0]
442
+ y = positions[:, 1]
443
+ dir_x, dir_y = self._dir_normalized
444
+ omega = self.angular_frequency
445
+
446
+ dot_val = dir_x * x + dir_y * y
447
+ theta = self._wave_number * dot_val - omega * self.time
448
+
449
+ sin_theta = np.sin(theta)
450
+ WA = self._wave_number * self.amplitude
451
+
452
+ # Local normal components
453
+ nx_local = dir_x * WA * sin_theta
454
+ ny_local = dir_y * WA * sin_theta
455
+ nz_local = 1.0 - 0.5 * WA * sin_theta # Simplified steepness
456
+
457
+ norm = np.sqrt(nx_local**2 + ny_local**2 + nz_local**2)
458
+ norm = np.maximum(norm, 1e-10)
459
+ nx_local /= norm
460
+ ny_local /= norm
461
+ nz_local /= norm
462
+
463
+ # Transform to world coordinates
464
+ normals = (
465
+ nx_local[:, np.newaxis] * tangent_x
466
+ + ny_local[:, np.newaxis] * tangent_y
467
+ + nz_local[:, np.newaxis] * radial
468
+ )
469
+
470
+ if incoming_directions is not None:
471
+ dot_products = np.sum(normals * incoming_directions, axis=1)
472
+ flip_mask = dot_products > 0
473
+ normals[flip_mask] *= -1
474
+
475
+ return normals.astype(np.float32)
476
+
477
+ def set_time(self, time: float) -> None:
478
+ """Update the wave animation time."""
479
+ self.time = time
480
+
481
+
482
+ # Register class with registry
483
+ register_surface_type("gpu_curved_wave", "gpu", 4, GPUCurvedWaveSurface)