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,587 @@
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
+ Recording Sphere Surface (GPU-Capable)
36
+
37
+ Specialized spherical detector surface for recording rays at a specific
38
+ altitude above Earth. Follows the Surface protocol with GPU acceleration.
39
+
40
+ This is the primary detector setup for Earth-scale simulations.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ from dataclasses import dataclass, field
46
+ from typing import TYPE_CHECKING, Any
47
+
48
+ import numpy as np
49
+ import numpy.typing as npt
50
+
51
+ from ..protocol import Surface, SurfaceRole
52
+
53
+ if TYPE_CHECKING:
54
+ from ...propagation.kernels.registry import IntersectionKernelID
55
+
56
+
57
+ # Earth's mean radius in meters
58
+ EARTH_RADIUS = 6.371e6
59
+
60
+
61
+ @dataclass
62
+ class RecordingSphereSurface(Surface):
63
+ """
64
+ Recording sphere surface at a specified altitude above Earth.
65
+
66
+ A specialized detector surface designed for Earth-scale simulations.
67
+ The sphere is centered at Earth's center with radius = earth_radius + altitude.
68
+
69
+ This is the recommended detector setup for atmospheric ray tracing.
70
+
71
+ Parameters
72
+ ----------
73
+ altitude : float
74
+ Altitude above Earth's surface in meters (default 33 km).
75
+ earth_center : tuple of float
76
+ Center of Earth in simulation coordinates.
77
+ Default (0, 0, -EARTH_RADIUS) places Earth surface at z=0.
78
+ earth_radius : float
79
+ Earth radius in meters. Default is 6.371e6 m.
80
+ name : str
81
+ Human-readable name for the detector.
82
+
83
+ Examples
84
+ --------
85
+ >>> # Create a recording sphere at 33 km altitude
86
+ >>> detector = RecordingSphereSurface(
87
+ ... altitude=33000.0,
88
+ ... name="satellite_detector",
89
+ ... )
90
+ >>>
91
+ >>> # Use in geometry builder
92
+ >>> geometry = (
93
+ ... GeometryBuilder()
94
+ ... .register_medium("atmosphere", atmosphere)
95
+ ... .set_background("atmosphere")
96
+ ... .add_detector(detector)
97
+ ... .build()
98
+ ... )
99
+ >>>
100
+ >>> # Check GPU capability
101
+ >>> print(detector.gpu_capable) # True
102
+ >>> print(detector.geometry_id) # 2 (sphere)
103
+
104
+ Notes
105
+ -----
106
+ This surface is GPU-capable and uses the same GPU kernels as SphereSurface.
107
+ The geometry_id is 2 (sphere), which uses analytical ray-sphere intersection.
108
+
109
+ The coordinate system places:
110
+ - Earth's center at (0, 0, -EARTH_RADIUS) by default
111
+ - Earth's surface at z=0
112
+ - Recording sphere at z = altitude at the pole
113
+ """
114
+
115
+ altitude: float = 33000.0
116
+ earth_center: tuple[float, float, float] = (0, 0, -EARTH_RADIUS)
117
+ earth_radius: float = EARTH_RADIUS
118
+ name: str = "recording_sphere"
119
+
120
+ # Role is always DETECTOR
121
+ role: SurfaceRole = field(default=SurfaceRole.DETECTOR, init=False)
122
+
123
+ # No materials needed for detector
124
+ material_front: Any = field(default=None, init=False)
125
+ material_back: Any = field(default=None, init=False)
126
+
127
+ # GPU capability - same as SphereSurface
128
+ _gpu_capable: bool = field(default=True, init=False, repr=False)
129
+ _geometry_id: int = field(default=2, init=False, repr=False) # sphere = 2
130
+
131
+ # Kernel ID for this instance
132
+ _kernel_id: "IntersectionKernelID | None" = field(
133
+ default=None, init=False, repr=False
134
+ )
135
+
136
+ # Computed property
137
+ _sphere_radius: float = field(default=0.0, init=False, repr=False)
138
+ _center_array: npt.NDArray[np.float64] = field(
139
+ default_factory=lambda: np.zeros(3), init=False, repr=False
140
+ )
141
+
142
+ @classmethod
143
+ def _get_supported_kernels(cls) -> list["IntersectionKernelID"]:
144
+ """Get supported intersection kernels (lazy initialization)."""
145
+ from ...propagation.kernels.registry import IntersectionKernelID
146
+
147
+ return [IntersectionKernelID.SPHERE_ANALYTICAL]
148
+
149
+ @classmethod
150
+ def _get_default_kernel(cls) -> "IntersectionKernelID":
151
+ """Get default intersection kernel."""
152
+ from ...propagation.kernels.registry import IntersectionKernelID
153
+
154
+ return IntersectionKernelID.SPHERE_ANALYTICAL
155
+
156
+ @classmethod
157
+ def supported_kernels(cls) -> list["IntersectionKernelID"]:
158
+ """Return list of intersection kernels supported by this surface type."""
159
+ return cls._get_supported_kernels()
160
+
161
+ @classmethod
162
+ def default_kernel(cls) -> "IntersectionKernelID":
163
+ """Return the default intersection kernel for this surface type."""
164
+ return cls._get_default_kernel()
165
+
166
+ def __post_init__(self) -> None:
167
+ """Initialize computed properties."""
168
+ if self.altitude < 0:
169
+ raise ValueError(f"Altitude must be non-negative, got {self.altitude}")
170
+
171
+ # Compute sphere radius
172
+ self._sphere_radius = self.earth_radius + self.altitude
173
+
174
+ # Convert center to array
175
+ self._center_array = np.array(self.earth_center, dtype=np.float64)
176
+
177
+ # Set default kernel
178
+ self._kernel_id = self._get_default_kernel()
179
+
180
+ @property
181
+ def gpu_capable(self) -> bool:
182
+ """This surface supports GPU acceleration."""
183
+ return True
184
+
185
+ @property
186
+ def geometry_id(self) -> int:
187
+ """GPU geometry type ID (sphere = 2)."""
188
+ return 2
189
+
190
+ @property
191
+ def sphere_radius(self) -> float:
192
+ """Total radius of the recording sphere (earth_radius + altitude)."""
193
+ return self._sphere_radius
194
+
195
+ @property
196
+ def center(self) -> tuple[float, float, float]:
197
+ """Center of the sphere (same as earth_center)."""
198
+ return self.earth_center
199
+
200
+ @property
201
+ def center_array(self) -> npt.NDArray[np.float64]:
202
+ """Center of the sphere as numpy array."""
203
+ return self._center_array
204
+
205
+ def get_gpu_parameters(self) -> tuple:
206
+ """
207
+ Return parameters for GPU kernel.
208
+
209
+ Returns
210
+ -------
211
+ tuple
212
+ (center_x, center_y, center_z, radius)
213
+ """
214
+ return (
215
+ self.earth_center[0],
216
+ self.earth_center[1],
217
+ self.earth_center[2],
218
+ self._sphere_radius,
219
+ )
220
+
221
+ def get_materials(self) -> tuple | None:
222
+ """
223
+ Return materials for Fresnel calculation.
224
+
225
+ Returns None since DETECTOR surfaces don't use Fresnel equations.
226
+ """
227
+ return None
228
+
229
+ def signed_distance(
230
+ self,
231
+ positions: npt.NDArray[np.float32],
232
+ ) -> npt.NDArray[np.float32]:
233
+ """
234
+ Compute signed distance from positions to recording sphere surface.
235
+
236
+ Parameters
237
+ ----------
238
+ positions : ndarray, shape (N, 3)
239
+ Points to compute distance for
240
+
241
+ Returns
242
+ -------
243
+ ndarray, shape (N,)
244
+ Signed distance (positive outside, negative inside)
245
+ """
246
+ center = self._center_array.astype(np.float32)
247
+ diff = positions - center
248
+ dist = np.linalg.norm(diff, axis=1)
249
+ return (dist - self._sphere_radius).astype(np.float32)
250
+
251
+ def intersect(
252
+ self,
253
+ origins: npt.NDArray[np.float32],
254
+ directions: npt.NDArray[np.float32],
255
+ min_distance: float = 1e-6,
256
+ ) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.bool_]]:
257
+ """
258
+ Compute ray-sphere intersection.
259
+
260
+ Parameters
261
+ ----------
262
+ origins : ndarray, shape (N, 3)
263
+ Ray origins
264
+ directions : ndarray, shape (N, 3)
265
+ Ray directions (normalized)
266
+ min_distance : float
267
+ Minimum valid intersection distance
268
+
269
+ Returns
270
+ -------
271
+ distances : ndarray, shape (N,)
272
+ Distance to intersection (inf if no hit)
273
+ hit_mask : ndarray, shape (N,)
274
+ Boolean mask of valid intersections
275
+ """
276
+ center = self._center_array.astype(np.float32)
277
+ r = self._sphere_radius
278
+
279
+ # Ray-sphere intersection:
280
+ # |origin + t*direction - center|^2 = r^2
281
+ oc = origins - center
282
+
283
+ a = np.sum(directions * directions, axis=1)
284
+ b = 2.0 * np.sum(oc * directions, axis=1)
285
+ c = np.sum(oc * oc, axis=1) - r * r
286
+
287
+ discriminant = b * b - 4 * a * c
288
+
289
+ # No intersection if discriminant < 0
290
+ no_hit = discriminant < 0
291
+
292
+ # Compute both roots
293
+ sqrt_disc = np.sqrt(np.maximum(discriminant, 0))
294
+ t1 = (-b - sqrt_disc) / (2 * a)
295
+ t2 = (-b + sqrt_disc) / (2 * a)
296
+
297
+ # Choose closest positive intersection >= min_distance
298
+ t1_valid = t1 >= min_distance
299
+ t2_valid = t2 >= min_distance
300
+
301
+ # Prefer t1 (closer) if valid, else t2
302
+ t = np.where(t1_valid, t1, np.where(t2_valid, t2, np.inf))
303
+
304
+ hit_mask = (~no_hit) & (t1_valid | t2_valid)
305
+ distances = np.where(hit_mask, t, np.inf)
306
+
307
+ return distances.astype(np.float32), hit_mask
308
+
309
+ def normal_at(
310
+ self,
311
+ positions: npt.NDArray[np.float32],
312
+ incoming_directions: npt.NDArray[np.float32] | None = None,
313
+ ) -> npt.NDArray[np.float32]:
314
+ """
315
+ Compute surface normal at positions.
316
+
317
+ For a sphere, normal points radially outward from center.
318
+
319
+ Parameters
320
+ ----------
321
+ positions : ndarray, shape (N, 3)
322
+ Points on the surface
323
+ incoming_directions : ndarray, shape (N, 3), optional
324
+ Ray directions (used to flip normal if needed)
325
+
326
+ Returns
327
+ -------
328
+ ndarray, shape (N, 3)
329
+ Normal vectors at each position
330
+ """
331
+ center = self._center_array.astype(np.float32)
332
+ diff = positions - center
333
+
334
+ # Normalize
335
+ norms = np.linalg.norm(diff, axis=1, keepdims=True)
336
+ normals = diff / np.maximum(norms, 1e-12)
337
+
338
+ # Optionally flip normals to face incoming rays
339
+ if incoming_directions is not None:
340
+ dot = np.sum(normals * incoming_directions, axis=1)
341
+ flip_mask = dot > 0
342
+ normals[flip_mask] = -normals[flip_mask]
343
+
344
+ return normals.astype(np.float32)
345
+
346
+ def compute_angular_coordinates(
347
+ self,
348
+ positions: npt.NDArray[np.float32],
349
+ ) -> dict[str, npt.NDArray[np.float32]]:
350
+ """
351
+ Compute angular coordinates for intersection points on the sphere.
352
+
353
+ Computes spherical coordinates (latitude/longitude) of points
354
+ on the detection sphere relative to Earth's center.
355
+
356
+ Parameters
357
+ ----------
358
+ positions : ndarray, shape (N, 3)
359
+ Intersection positions on the recording sphere
360
+
361
+ Returns
362
+ -------
363
+ dict
364
+ Dictionary with:
365
+ - 'latitude': Latitude angle (-pi/2 to pi/2)
366
+ - 'longitude': Longitude angle (-pi to pi)
367
+ - 'colatitude': Colatitude angle (0 to pi)
368
+ """
369
+ # Vector from Earth center to position
370
+ to_pos = positions.astype(np.float64) - self._center_array
371
+ r = np.linalg.norm(to_pos, axis=1, keepdims=True)
372
+
373
+ # Latitude: angle above equatorial plane
374
+ latitude = np.arcsin(np.clip(to_pos[:, 2] / r.squeeze(), -1.0, 1.0))
375
+
376
+ # Longitude: angle in XY plane
377
+ longitude = np.arctan2(to_pos[:, 1], to_pos[:, 0])
378
+
379
+ # Colatitude: angle from +Z axis
380
+ colatitude = np.arccos(np.clip(to_pos[:, 2] / r.squeeze(), -1.0, 1.0))
381
+
382
+ return {
383
+ "latitude": latitude.astype(np.float32),
384
+ "longitude": longitude.astype(np.float32),
385
+ "colatitude": colatitude.astype(np.float32),
386
+ }
387
+
388
+ def __repr__(self) -> str:
389
+ """Return string representation."""
390
+ return (
391
+ f"RecordingSphereSurface("
392
+ f"altitude={self.altitude/1000:.1f}km, "
393
+ f"name='{self.name}', GPU)"
394
+ )
395
+
396
+
397
+ # Also export as LocalRecordingSphereSurface for local-scale simulations
398
+ @dataclass
399
+ class LocalRecordingSphereSurface(Surface):
400
+ """
401
+ Local recording sphere surface centered at an arbitrary position.
402
+
403
+ A simplified recording sphere for local-scale simulations without
404
+ Earth curvature considerations.
405
+
406
+ Parameters
407
+ ----------
408
+ radius : float
409
+ Sphere radius in meters (default 33 km).
410
+ center : tuple of float
411
+ Center position (default (0, 0, 0)).
412
+ name : str
413
+ Human-readable name for the detector.
414
+
415
+ Examples
416
+ --------
417
+ >>> # Create a local recording sphere at origin
418
+ >>> detector = LocalRecordingSphereSurface(
419
+ ... radius=1000.0,
420
+ ... center=(0, 0, 0),
421
+ ... name="local_detector",
422
+ ... )
423
+ """
424
+
425
+ radius: float = 33000.0
426
+ center: tuple[float, float, float] = (0, 0, 0)
427
+ name: str = "local_recording_sphere"
428
+
429
+ # Role is always DETECTOR
430
+ role: SurfaceRole = field(default=SurfaceRole.DETECTOR, init=False)
431
+
432
+ # No materials needed for detector
433
+ material_front: Any = field(default=None, init=False)
434
+ material_back: Any = field(default=None, init=False)
435
+
436
+ # GPU capability
437
+ _gpu_capable: bool = field(default=True, init=False, repr=False)
438
+ _geometry_id: int = field(default=2, init=False, repr=False) # sphere = 2
439
+
440
+ # Kernel ID for this instance
441
+ _kernel_id: "IntersectionKernelID | None" = field(
442
+ default=None, init=False, repr=False
443
+ )
444
+
445
+ # Computed property
446
+ _center_array: npt.NDArray[np.float64] = field(
447
+ default_factory=lambda: np.zeros(3), init=False, repr=False
448
+ )
449
+
450
+ @classmethod
451
+ def _get_supported_kernels(cls) -> list["IntersectionKernelID"]:
452
+ """Get supported intersection kernels."""
453
+ from ...propagation.kernels.registry import IntersectionKernelID
454
+
455
+ return [IntersectionKernelID.SPHERE_ANALYTICAL]
456
+
457
+ @classmethod
458
+ def _get_default_kernel(cls) -> "IntersectionKernelID":
459
+ """Get default intersection kernel."""
460
+ from ...propagation.kernels.registry import IntersectionKernelID
461
+
462
+ return IntersectionKernelID.SPHERE_ANALYTICAL
463
+
464
+ @classmethod
465
+ def supported_kernels(cls) -> list["IntersectionKernelID"]:
466
+ """Return list of intersection kernels supported by this surface type."""
467
+ return cls._get_supported_kernels()
468
+
469
+ @classmethod
470
+ def default_kernel(cls) -> "IntersectionKernelID":
471
+ """Return the default intersection kernel for this surface type."""
472
+ return cls._get_default_kernel()
473
+
474
+ def __post_init__(self) -> None:
475
+ """Initialize computed properties."""
476
+ if self.radius <= 0:
477
+ raise ValueError(f"Radius must be positive, got {self.radius}")
478
+
479
+ self._center_array = np.array(self.center, dtype=np.float64)
480
+ self._kernel_id = self._get_default_kernel()
481
+
482
+ @property
483
+ def gpu_capable(self) -> bool:
484
+ """This surface supports GPU acceleration."""
485
+ return True
486
+
487
+ @property
488
+ def geometry_id(self) -> int:
489
+ """GPU geometry type ID (sphere = 2)."""
490
+ return 2
491
+
492
+ @property
493
+ def sphere_radius(self) -> float:
494
+ """Radius of the recording sphere."""
495
+ return self.radius
496
+
497
+ @property
498
+ def center_array(self) -> npt.NDArray[np.float64]:
499
+ """Center as numpy array."""
500
+ return self._center_array
501
+
502
+ def get_gpu_parameters(self) -> tuple:
503
+ """Return parameters for GPU kernel."""
504
+ return (
505
+ self.center[0],
506
+ self.center[1],
507
+ self.center[2],
508
+ self.radius,
509
+ )
510
+
511
+ def get_materials(self) -> tuple | None:
512
+ """Return None (detectors don't use Fresnel)."""
513
+ return None
514
+
515
+ def signed_distance(
516
+ self,
517
+ positions: npt.NDArray[np.float32],
518
+ ) -> npt.NDArray[np.float32]:
519
+ """Compute signed distance from positions to sphere surface."""
520
+ center = self._center_array.astype(np.float32)
521
+ diff = positions - center
522
+ dist = np.linalg.norm(diff, axis=1)
523
+ return (dist - self.radius).astype(np.float32)
524
+
525
+ def intersect(
526
+ self,
527
+ origins: npt.NDArray[np.float32],
528
+ directions: npt.NDArray[np.float32],
529
+ min_distance: float = 1e-6,
530
+ ) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.bool_]]:
531
+ """Compute ray-sphere intersection."""
532
+ center = self._center_array.astype(np.float32)
533
+ r = self.radius
534
+
535
+ oc = origins - center
536
+ a = np.sum(directions * directions, axis=1)
537
+ b = 2.0 * np.sum(oc * directions, axis=1)
538
+ c = np.sum(oc * oc, axis=1) - r * r
539
+
540
+ discriminant = b * b - 4 * a * c
541
+ no_hit = discriminant < 0
542
+
543
+ sqrt_disc = np.sqrt(np.maximum(discriminant, 0))
544
+ t1 = (-b - sqrt_disc) / (2 * a)
545
+ t2 = (-b + sqrt_disc) / (2 * a)
546
+
547
+ t1_valid = t1 >= min_distance
548
+ t2_valid = t2 >= min_distance
549
+ t = np.where(t1_valid, t1, np.where(t2_valid, t2, np.inf))
550
+
551
+ hit_mask = (~no_hit) & (t1_valid | t2_valid)
552
+ distances = np.where(hit_mask, t, np.inf)
553
+
554
+ return distances.astype(np.float32), hit_mask
555
+
556
+ def normal_at(
557
+ self,
558
+ positions: npt.NDArray[np.float32],
559
+ incoming_directions: npt.NDArray[np.float32] | None = None,
560
+ ) -> npt.NDArray[np.float32]:
561
+ """Compute surface normal at positions."""
562
+ center = self._center_array.astype(np.float32)
563
+ diff = positions - center
564
+
565
+ norms = np.linalg.norm(diff, axis=1, keepdims=True)
566
+ normals = diff / np.maximum(norms, 1e-12)
567
+
568
+ if incoming_directions is not None:
569
+ dot = np.sum(normals * incoming_directions, axis=1)
570
+ flip_mask = dot > 0
571
+ normals[flip_mask] = -normals[flip_mask]
572
+
573
+ return normals.astype(np.float32)
574
+
575
+ def __repr__(self) -> str:
576
+ """Return string representation."""
577
+ return (
578
+ f"LocalRecordingSphereSurface("
579
+ f"radius={self.radius/1000:.1f}km, "
580
+ f"center={self.center}, "
581
+ f"name='{self.name}', GPU)"
582
+ )
583
+
584
+
585
+ # Note: We don't register these classes with the registry because they use
586
+ # the same geometry_id (2 = sphere) as SphereSurface. They share GPU kernels
587
+ # but provide a specialized interface for recording sphere functionality.