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,745 @@
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 for Ray Detection and Data Storage
36
+
37
+ This module implements a spherical detection surface for capturing rays
38
+ at a specified altitude above Earth's surface, along with HDF5/numpy
39
+ data storage for ray information.
40
+
41
+ The recording sphere captures:
42
+ - Position (x, y, z)
43
+ - Direction (dx, dy, dz)
44
+ - Time of arrival
45
+ - Intensity
46
+ - Wavelength
47
+ - Generation number
48
+ - All geometric information needed to fully reconstruct rays
49
+ """
50
+
51
+ from dataclasses import dataclass
52
+ from datetime import datetime
53
+ from pathlib import Path
54
+ from typing import Any
55
+
56
+ import numpy as np
57
+ from numpy.typing import NDArray
58
+
59
+ # Optional h5py import
60
+ try:
61
+ import h5py
62
+
63
+ HAS_H5PY = True
64
+ except ImportError:
65
+ HAS_H5PY = False
66
+
67
+ from ..surfaces import EARTH_RADIUS
68
+ from .ray_data import RayBatch
69
+
70
+
71
+ @dataclass
72
+ class RecordedRays:
73
+ """
74
+ Container for recorded ray data at the detection sphere.
75
+
76
+ All arrays have shape (N,) or (N, 3) where N is the number of recorded rays.
77
+
78
+ Attributes
79
+ ----------
80
+ positions : ndarray, shape (N, 3)
81
+ Intersection positions on the recording sphere (meters)
82
+ directions : ndarray, shape (N, 3)
83
+ Ray directions at intersection (unit vectors)
84
+ times : ndarray, shape (N,)
85
+ Time of arrival at recording sphere (seconds)
86
+ intensities : ndarray, shape (N,)
87
+ Ray intensity at recording sphere
88
+ wavelengths : ndarray, shape (N,)
89
+ Ray wavelength (meters)
90
+ generations : ndarray, shape (N,)
91
+ Ray generation (number of surface interactions)
92
+ polarization_vectors : ndarray, shape (N, 3), optional
93
+ 3D polarization vectors (electric field direction) at intersection.
94
+ Unit vectors perpendicular to ray direction representing E-field orientation.
95
+
96
+ Derived quantities (computed on demand or at save time):
97
+
98
+ elevation_angles : ndarray, shape (N,)
99
+ Elevation angle above local horizon (radians)
100
+ azimuth_angles : ndarray, shape (N,)
101
+ Azimuth angle in local tangent plane (radians)
102
+ zenith_angles : ndarray, shape (N,)
103
+ Angle from local zenith (radians)
104
+ local_positions : ndarray, shape (N, 2)
105
+ Position in local tangent coordinates (meters)
106
+ """
107
+
108
+ positions: NDArray[np.float32]
109
+ directions: NDArray[np.float32]
110
+ times: NDArray[np.float32]
111
+ intensities: NDArray[np.float32]
112
+ wavelengths: NDArray[np.float32]
113
+ generations: NDArray[np.int32]
114
+ polarization_vectors: NDArray[np.float32] | None = None
115
+ ray_indices: NDArray[np.int32] | None = None
116
+
117
+ @property
118
+ def num_rays(self) -> int:
119
+ """Number of recorded rays."""
120
+ return len(self.positions)
121
+
122
+ def compute_angular_coordinates(
123
+ self,
124
+ earth_center: NDArray[np.float64] = None,
125
+ ) -> dict[str, NDArray[np.float32]]:
126
+ """
127
+ Compute angular coordinates for all recorded ray intersection points.
128
+
129
+ Computes spherical coordinates (latitude/longitude) of intersection points
130
+ on the detection sphere relative to Earth's center.
131
+
132
+ Parameters
133
+ ----------
134
+ earth_center : ndarray, optional
135
+ Earth center position, default (0, 0, -EARTH_RADIUS)
136
+
137
+ Returns
138
+ -------
139
+ dict
140
+ Dictionary with:
141
+ - 'elevation': Latitude angle above equator (radians, -π/2 to π/2)
142
+ - 'azimuth': Longitude angle (radians, -π to π)
143
+ - 'zenith': Zenith angle from north pole (radians, 0 to π)
144
+ - 'incidence': Angle between ray direction and outward radial (radians)
145
+ """
146
+ if earth_center is None:
147
+ earth_center = np.array([0, 0, -EARTH_RADIUS], dtype=np.float64)
148
+
149
+ # Vector from Earth center to intersection point
150
+ to_pos = self.positions.astype(np.float64) - earth_center
151
+ r = np.linalg.norm(to_pos, axis=1, keepdims=True)
152
+
153
+ # Spherical coordinates of the intersection point
154
+ # Elevation (latitude): angle above the equatorial plane (XY plane through Earth center)
155
+ elevation = np.arcsin(to_pos[:, 2] / r.squeeze())
156
+
157
+ # Azimuth (longitude): angle in the XY plane
158
+ azimuth = np.arctan2(to_pos[:, 1], to_pos[:, 0])
159
+
160
+ # Zenith angle: angle from +Z axis (north pole)
161
+ np.arccos(to_pos[:, 2] / r.squeeze())
162
+
163
+ # Incidence angle: angle between ray direction and outward radial (for reference)
164
+ radial = to_pos / r
165
+ cos_incidence = np.sum(self.directions * radial, axis=1)
166
+ incidence = np.arccos(np.clip(cos_incidence, -1.0, 1.0))
167
+
168
+ # For azimuth, project direction onto local tangent plane
169
+ # Create local basis
170
+ global_z = np.array([0, 0, 1], dtype=np.float64)
171
+ tangent_x = np.cross(global_z, radial)
172
+ tangent_x_norm = np.linalg.norm(tangent_x, axis=1, keepdims=True)
173
+ tangent_x = tangent_x / np.maximum(tangent_x_norm, 1e-10)
174
+ tangent_y = np.cross(radial, tangent_x)
175
+
176
+ # Project direction onto tangent plane
177
+ dir_x = np.sum(self.directions * tangent_x, axis=1)
178
+ dir_y = np.sum(self.directions * tangent_y, axis=1)
179
+ azimuth = np.arctan2(dir_y, dir_x)
180
+
181
+ return {
182
+ "elevation": elevation.astype(np.float32),
183
+ "azimuth": azimuth.astype(np.float32),
184
+ "zenith": incidence.astype(np.float32),
185
+ "incidence": incidence.astype(np.float32),
186
+ }
187
+
188
+ def compute_viewing_angle_from_origin(
189
+ self,
190
+ origin: NDArray[np.float64] = None,
191
+ ) -> NDArray[np.float32]:
192
+ """
193
+ Compute viewing angle from horizontal at specified origin.
194
+
195
+ Calculates the angle above the horizontal plane (XY plane) when
196
+ viewing each intersection point from the origin position.
197
+
198
+ Parameters
199
+ ----------
200
+ origin : ndarray, optional
201
+ Observer position, default (0, 0, 0) - Earth surface at reference point
202
+
203
+ Returns
204
+ -------
205
+ ndarray
206
+ Viewing angle from horizontal in radians (-π/2 to π/2)
207
+ Positive angles are above horizontal, negative below
208
+ """
209
+ if origin is None:
210
+ origin = np.array([0, 0, 0], dtype=np.float64)
211
+
212
+ # Vector from origin to intersection point
213
+ to_point = self.positions.astype(np.float64) - origin
214
+
215
+ # Horizontal distance (in XY plane)
216
+ horiz_dist = np.sqrt(to_point[:, 0] ** 2 + to_point[:, 1] ** 2)
217
+
218
+ # Vertical distance (Z component)
219
+ vert_dist = to_point[:, 2]
220
+
221
+ # Angle from horizontal: arctan(z / sqrt(x^2 + y^2))
222
+ viewing_angle = np.arctan2(vert_dist, horiz_dist)
223
+
224
+ return viewing_angle.astype(np.float32)
225
+
226
+ def compute_ray_direction_angles(self) -> dict[str, NDArray[np.float32]]:
227
+ """
228
+ Compute elevation and azimuth angles of ray directions.
229
+
230
+ Calculates the angles of the ray direction vectors themselves,
231
+ not the position coordinates. Useful for understanding the
232
+ angular distribution of ray propagation.
233
+
234
+ Returns
235
+ -------
236
+ dict
237
+ Dictionary with:
238
+ - 'elevation': Angle above horizontal plane in radians (-π/2 to π/2)
239
+ 0 = horizontal, π/2 = straight up, -π/2 = straight down
240
+ - 'azimuth': Azimuth angle in horizontal plane in radians (-π to π)
241
+ 0 = +X direction, π/2 = +Y direction
242
+ """
243
+ # Elevation: angle from horizontal (XY) plane
244
+ # elevation = arcsin(z_component)
245
+ elevation = np.arcsin(np.clip(self.directions[:, 2], -1.0, 1.0))
246
+
247
+ # Azimuth: angle in XY plane
248
+ # azimuth = arctan2(y, x)
249
+ azimuth = np.arctan2(self.directions[:, 1], self.directions[:, 0])
250
+
251
+ return {
252
+ "elevation": elevation.astype(np.float32),
253
+ "azimuth": azimuth.astype(np.float32),
254
+ }
255
+
256
+
257
+ class RecordingSphere:
258
+ """
259
+ Spherical detection surface at a specified altitude above Earth.
260
+
261
+ Records all rays that intersect the sphere, capturing full ray state
262
+ for later analysis.
263
+
264
+ Parameters
265
+ ----------
266
+ altitude : float
267
+ Altitude above Earth's surface in meters (default 33 km)
268
+ earth_center : Tuple[float, float, float]
269
+ Center of Earth, default (0, 0, -EARTH_RADIUS)
270
+ earth_radius : float
271
+ Earth radius in meters
272
+
273
+ Notes
274
+ -----
275
+ The recording sphere has radius = earth_radius + altitude, centered
276
+ at earth_center.
277
+ """
278
+
279
+ def __init__(
280
+ self,
281
+ altitude: float = 33000.0, # 33 km default
282
+ earth_center: tuple[float, float, float] = (0, 0, -EARTH_RADIUS),
283
+ earth_radius: float = EARTH_RADIUS,
284
+ ):
285
+ self.altitude = altitude
286
+ self.earth_center = np.array(earth_center, dtype=np.float64)
287
+ self.earth_radius = earth_radius
288
+ self.sphere_radius = earth_radius + altitude
289
+
290
+ def detect(
291
+ self,
292
+ rays: RayBatch,
293
+ compute_travel_time: bool = True,
294
+ speed_of_light: float = 299792458.0,
295
+ max_propagation_distance: float | None = None,
296
+ ) -> RecordedRays:
297
+ """
298
+ Detect rays intersecting the recording sphere.
299
+
300
+ Parameters
301
+ ----------
302
+ rays : RayBatch
303
+ Rays to detect
304
+ compute_travel_time : bool
305
+ If True, add travel time to intersection to ray's accumulated time
306
+ speed_of_light : float
307
+ Speed of light for time computation
308
+ max_propagation_distance : float, optional
309
+ Maximum distance rays can propagate before detection (meters).
310
+ If None, no limit is applied. Use this to prevent detecting rays
311
+ that would hit the sphere from the opposite hemisphere.
312
+
313
+ Returns
314
+ -------
315
+ RecordedRays
316
+ Recorded ray data for all intersecting rays
317
+ """
318
+ active_mask = rays.active
319
+ if not np.any(active_mask):
320
+ return RecordedRays(
321
+ positions=np.zeros((0, 3), dtype=np.float32),
322
+ directions=np.zeros((0, 3), dtype=np.float32),
323
+ times=np.zeros(0, dtype=np.float32),
324
+ intensities=np.zeros(0, dtype=np.float32),
325
+ wavelengths=np.zeros(0, dtype=np.float32),
326
+ generations=np.zeros(0, dtype=np.int32),
327
+ )
328
+
329
+ origins = rays.positions[active_mask].astype(np.float64)
330
+ directions = rays.directions[active_mask].astype(np.float64)
331
+
332
+ # Ray-sphere intersection using numerically stable formulation
333
+ # Ray: P = O + t*D
334
+ # Sphere: |P - C|² = R²
335
+ # Using half_b = b/2 to reduce magnitude and improve numerical stability
336
+ oc = origins - self.earth_center
337
+ a = np.sum(directions * directions, axis=1)
338
+ half_b = np.sum(directions * oc, axis=1) # This is b/2
339
+ c = np.sum(oc * oc, axis=1) - self.sphere_radius**2
340
+
341
+ # Discriminant using half_b: (b/2)² - ac instead of b² - 4ac
342
+ discriminant = half_b**2 - a * c
343
+ has_hit = discriminant >= 0
344
+
345
+ # Get intersection distance using stable formula
346
+ sqrt_disc = np.sqrt(np.maximum(discriminant, 0))
347
+ # t = (-b ± sqrt(b²-4ac)) / 2a = (-half_b ± sqrt(half_b² - ac)) / a
348
+ t1 = (-half_b - sqrt_disc) / (a + 1e-20)
349
+ t2 = (-half_b + sqrt_disc) / (a + 1e-20)
350
+
351
+ # For rays inside the sphere going outward, use t2 (far intersection)
352
+ # For rays outside going inward, use t1 (near intersection)
353
+ # Check if origin is inside or outside sphere
354
+ dist_from_center = np.linalg.norm(oc, axis=1)
355
+ inside = dist_from_center < self.sphere_radius
356
+
357
+ t = np.where(inside, t2, t1)
358
+ t = np.where(t > 0, t, t2) # If first choice was negative, try second
359
+
360
+ # Valid hits: positive t, discriminant >= 0, and within max distance
361
+ valid_hits = has_hit & (t > 1e-6)
362
+ if max_propagation_distance is not None:
363
+ valid_hits = valid_hits & (t < max_propagation_distance)
364
+
365
+ if not np.any(valid_hits):
366
+ return RecordedRays(
367
+ positions=np.zeros((0, 3), dtype=np.float32),
368
+ directions=np.zeros((0, 3), dtype=np.float32),
369
+ times=np.zeros(0, dtype=np.float32),
370
+ intensities=np.zeros(0, dtype=np.float32),
371
+ wavelengths=np.zeros(0, dtype=np.float32),
372
+ generations=np.zeros(0, dtype=np.int32),
373
+ )
374
+
375
+ # Compute intersection positions
376
+ hit_positions = (
377
+ origins[valid_hits] + t[valid_hits, np.newaxis] * directions[valid_hits]
378
+ )
379
+ hit_directions = directions[valid_hits]
380
+ hit_distances = t[valid_hits]
381
+
382
+ # Get other ray properties
383
+ active_indices = np.where(active_mask)[0]
384
+ hit_indices = active_indices[valid_hits]
385
+
386
+ hit_intensities = rays.intensities[hit_indices]
387
+ hit_wavelengths = rays.wavelengths[hit_indices]
388
+ hit_generations = rays.generations[hit_indices]
389
+ hit_times = rays.accumulated_time[hit_indices]
390
+
391
+ # Add travel time
392
+ if compute_travel_time:
393
+ travel_time = hit_distances / speed_of_light
394
+ hit_times = hit_times + travel_time.astype(np.float32)
395
+
396
+ # Get polarization vectors if available
397
+ hit_polarization_vectors = None
398
+ if rays.polarization_vector is not None:
399
+ hit_polarization_vectors = rays.polarization_vector[hit_indices]
400
+
401
+ return RecordedRays(
402
+ positions=hit_positions.astype(np.float32),
403
+ directions=hit_directions.astype(np.float32),
404
+ times=hit_times,
405
+ intensities=hit_intensities,
406
+ wavelengths=hit_wavelengths,
407
+ generations=hit_generations,
408
+ polarization_vectors=hit_polarization_vectors,
409
+ )
410
+
411
+
412
+ def save_recorded_rays_hdf5(
413
+ recorded_rays: RecordedRays,
414
+ filepath: str,
415
+ metadata: dict[str, Any] | None = None,
416
+ compression: str = "gzip",
417
+ ) -> None:
418
+ """
419
+ Save recorded rays to HDF5 file.
420
+
421
+ Parameters
422
+ ----------
423
+ recorded_rays : RecordedRays
424
+ Ray data to save
425
+ filepath : str
426
+ Output file path
427
+ metadata : dict, optional
428
+ Additional metadata to store (simulation parameters, etc.)
429
+ compression : str
430
+ Compression algorithm ('gzip', 'lzf', or None)
431
+ """
432
+ if not HAS_H5PY:
433
+ raise ImportError(
434
+ "h5py is required for HDF5 support. Install with: pip install h5py"
435
+ )
436
+
437
+ filepath = Path(filepath)
438
+ filepath.parent.mkdir(parents=True, exist_ok=True)
439
+
440
+ with h5py.File(filepath, "w") as f:
441
+ # Create rays group
442
+ rays_grp = f.create_group("rays")
443
+
444
+ # Store ray data with compression
445
+ rays_grp.create_dataset(
446
+ "positions", data=recorded_rays.positions, compression=compression
447
+ )
448
+ rays_grp.create_dataset(
449
+ "directions", data=recorded_rays.directions, compression=compression
450
+ )
451
+ rays_grp.create_dataset(
452
+ "times", data=recorded_rays.times, compression=compression
453
+ )
454
+ rays_grp.create_dataset(
455
+ "intensities", data=recorded_rays.intensities, compression=compression
456
+ )
457
+ rays_grp.create_dataset(
458
+ "wavelengths", data=recorded_rays.wavelengths, compression=compression
459
+ )
460
+ rays_grp.create_dataset(
461
+ "generations", data=recorded_rays.generations, compression=compression
462
+ )
463
+
464
+ # Save polarization vectors if available
465
+ if recorded_rays.polarization_vectors is not None:
466
+ rays_grp.create_dataset(
467
+ "polarization_vectors",
468
+ data=recorded_rays.polarization_vectors,
469
+ compression=compression,
470
+ )
471
+
472
+ # Compute and store angular coordinates
473
+ angular = recorded_rays.compute_angular_coordinates()
474
+ angular_grp = f.create_group("angular")
475
+ for key, value in angular.items():
476
+ angular_grp.create_dataset(key, data=value, compression=compression)
477
+
478
+ # Store metadata
479
+ meta_grp = f.create_group("metadata")
480
+ meta_grp.attrs["num_rays"] = recorded_rays.num_rays
481
+ meta_grp.attrs["save_time"] = datetime.now().isoformat()
482
+
483
+ if metadata is not None:
484
+ for key, value in metadata.items():
485
+ if isinstance(value, (int, float, str, bool)):
486
+ meta_grp.attrs[key] = value
487
+ elif isinstance(value, np.ndarray):
488
+ meta_grp.create_dataset(key, data=value)
489
+ elif isinstance(value, (list, tuple)):
490
+ meta_grp.create_dataset(key, data=np.array(value))
491
+ else:
492
+ meta_grp.attrs[key] = str(value)
493
+
494
+
495
+ def load_recorded_rays_hdf5(filepath: str) -> tuple[RecordedRays, dict[str, Any]]:
496
+ """
497
+ Load recorded rays from HDF5 file.
498
+
499
+ Parameters
500
+ ----------
501
+ filepath : str
502
+ Input file path
503
+
504
+ Returns
505
+ -------
506
+ recorded_rays : RecordedRays
507
+ Loaded ray data
508
+ metadata : dict
509
+ Loaded metadata
510
+ """
511
+ if not HAS_H5PY:
512
+ raise ImportError(
513
+ "h5py is required for HDF5 support. Install with: pip install h5py"
514
+ )
515
+
516
+ with h5py.File(filepath, "r") as f:
517
+ rays_grp = f["rays"]
518
+
519
+ # Load polarization vectors if available
520
+ polarization_vectors = None
521
+ if "polarization_vectors" in rays_grp:
522
+ polarization_vectors = rays_grp["polarization_vectors"][...]
523
+
524
+ recorded_rays = RecordedRays(
525
+ positions=rays_grp["positions"][...],
526
+ directions=rays_grp["directions"][...],
527
+ times=rays_grp["times"][...],
528
+ intensities=rays_grp["intensities"][...],
529
+ wavelengths=rays_grp["wavelengths"][...],
530
+ generations=rays_grp["generations"][...],
531
+ polarization_vectors=polarization_vectors,
532
+ )
533
+
534
+ metadata = {}
535
+ if "metadata" in f:
536
+ meta_grp = f["metadata"]
537
+ for key, value in meta_grp.attrs.items():
538
+ metadata[key] = value
539
+ for key in meta_grp.keys():
540
+ metadata[key] = meta_grp[key][...]
541
+
542
+ return recorded_rays, metadata
543
+
544
+
545
+ def save_recorded_rays_numpy(
546
+ recorded_rays: RecordedRays,
547
+ filepath: str,
548
+ metadata: dict[str, Any] | None = None,
549
+ ) -> None:
550
+ """
551
+ Save recorded rays to numpy .npz file.
552
+
553
+ Parameters
554
+ ----------
555
+ recorded_rays : RecordedRays
556
+ Ray data to save
557
+ filepath : str
558
+ Output file path
559
+ metadata : dict, optional
560
+ Additional metadata to store
561
+ """
562
+ filepath = Path(filepath)
563
+ filepath.parent.mkdir(parents=True, exist_ok=True)
564
+
565
+ # Compute angular coordinates
566
+ angular = recorded_rays.compute_angular_coordinates()
567
+
568
+ # Prepare save dict
569
+ save_dict = {
570
+ "positions": recorded_rays.positions,
571
+ "directions": recorded_rays.directions,
572
+ "times": recorded_rays.times,
573
+ "intensities": recorded_rays.intensities,
574
+ "wavelengths": recorded_rays.wavelengths,
575
+ "generations": recorded_rays.generations,
576
+ "elevation": angular["elevation"],
577
+ "azimuth": angular["azimuth"],
578
+ "zenith": angular["zenith"],
579
+ }
580
+
581
+ # Save polarization vectors if available
582
+ if recorded_rays.polarization_vectors is not None:
583
+ save_dict["polarization_vectors"] = recorded_rays.polarization_vectors
584
+
585
+ # Add metadata as arrays or scalars
586
+ if metadata is not None:
587
+ for key, value in metadata.items():
588
+ save_dict[f"meta_{key}"] = np.array(value)
589
+
590
+ np.savez_compressed(filepath, **save_dict)
591
+
592
+
593
+ def load_recorded_rays_numpy(filepath: str) -> tuple[RecordedRays, dict[str, Any]]:
594
+ """
595
+ Load recorded rays from numpy .npz file.
596
+
597
+ Parameters
598
+ ----------
599
+ filepath : str
600
+ Input file path
601
+
602
+ Returns
603
+ -------
604
+ recorded_rays : RecordedRays
605
+ Loaded ray data
606
+ metadata : dict
607
+ Loaded metadata
608
+ """
609
+ data = np.load(filepath)
610
+
611
+ # Load polarization vectors if available
612
+ polarization_vectors = None
613
+ if "polarization_vectors" in data.files:
614
+ polarization_vectors = data["polarization_vectors"]
615
+
616
+ recorded_rays = RecordedRays(
617
+ positions=data["positions"],
618
+ directions=data["directions"],
619
+ times=data["times"],
620
+ intensities=data["intensities"],
621
+ wavelengths=data["wavelengths"],
622
+ generations=data["generations"],
623
+ polarization_vectors=polarization_vectors,
624
+ )
625
+
626
+ metadata = {}
627
+ for key in data.files:
628
+ if key.startswith("meta_"):
629
+ metadata[key[5:]] = data[key]
630
+
631
+ return recorded_rays, metadata
632
+
633
+
634
+ class LocalRecordingSphere:
635
+ """
636
+ Simple spherical detection surface centered at origin.
637
+
638
+ Records all rays that intersect the sphere from inside, useful for
639
+ local-scale simulations without Earth curvature.
640
+
641
+ Parameters
642
+ ----------
643
+ radius : float
644
+ Sphere radius in meters (default 33 km)
645
+ center : tuple
646
+ Center position (default (0, 0, 0))
647
+ """
648
+
649
+ def __init__(self, radius: float = 33000.0, center=(0, 0, 0)):
650
+ self.radius = radius
651
+ self.center = np.array(center, dtype=np.float64)
652
+ self.sphere_radius = radius # For compatibility with RecordingSphere
653
+
654
+ def record_rays(self, rays: RayBatch) -> RecordedRays:
655
+ """
656
+ Record rays that intersect the sphere.
657
+
658
+ Parameters
659
+ ----------
660
+ rays : RayBatch
661
+ Rays to check for intersections
662
+
663
+ Returns
664
+ -------
665
+ RecordedRays
666
+ Recorded ray data
667
+ """
668
+ # Vector from ray origins to sphere center
669
+ oc = rays.positions - self.center
670
+
671
+ # Quadratic equation coefficients for ray-sphere intersection
672
+ # (origin + t*direction - center)^2 = radius^2
673
+ a = np.sum(rays.directions**2, axis=1)
674
+ b = 2 * np.sum(oc * rays.directions, axis=1)
675
+ c = np.sum(oc**2, axis=1) - self.radius**2
676
+
677
+ discriminant = b**2 - 4 * a * c
678
+
679
+ # Find rays that hit the sphere
680
+ hit_mask = discriminant >= 0
681
+
682
+ if np.sum(hit_mask) == 0:
683
+ # No intersections
684
+ return RecordedRays(
685
+ positions=np.empty((0, 3), dtype=np.float32),
686
+ directions=np.empty((0, 3), dtype=np.float32),
687
+ times=np.empty(0, dtype=np.float32),
688
+ intensities=np.empty(0, dtype=np.float32),
689
+ wavelengths=np.empty(0, dtype=np.float32),
690
+ generations=np.empty(0, dtype=np.int32),
691
+ )
692
+
693
+ # Calculate intersection distances
694
+ sqrt_disc = np.sqrt(discriminant[hit_mask])
695
+ t1 = (-b[hit_mask] - sqrt_disc) / (2 * a[hit_mask])
696
+ t2 = (-b[hit_mask] + sqrt_disc) / (2 * a[hit_mask])
697
+
698
+ # Take the positive intersection (forward along ray)
699
+ # For rays inside the sphere, take t2 (exit point)
700
+ t = np.where(t1 > 0, t1, t2)
701
+ valid = t > 0
702
+
703
+ if np.sum(valid) == 0:
704
+ return RecordedRays(
705
+ positions=np.empty((0, 3), dtype=np.float32),
706
+ directions=np.empty((0, 3), dtype=np.float32),
707
+ times=np.empty(0, dtype=np.float32),
708
+ intensities=np.empty(0, dtype=np.float32),
709
+ wavelengths=np.empty(0, dtype=np.float32),
710
+ generations=np.empty(0, dtype=np.int32),
711
+ )
712
+
713
+ # Extract hit rays
714
+ final_mask = np.zeros(len(hit_mask), dtype=bool)
715
+ final_mask[np.where(hit_mask)[0][valid]] = True
716
+
717
+ hit_positions = rays.positions[final_mask]
718
+ hit_directions = rays.directions[final_mask]
719
+ hit_intensities = rays.intensities[final_mask]
720
+ hit_times = rays.accumulated_time[final_mask]
721
+ hit_wavelengths = rays.wavelengths[final_mask]
722
+ hit_generations = rays.generations[final_mask]
723
+ t_final = t[valid]
724
+
725
+ # Calculate intersection positions
726
+ intersection_positions = hit_positions + t_final[:, np.newaxis] * hit_directions
727
+
728
+ # Update times (distance / speed of light)
729
+ c_light = 299792458.0 # m/s
730
+ arrival_times = hit_times + (t_final / c_light).astype(np.float32)
731
+
732
+ # Get polarization vectors if available
733
+ hit_polarization_vectors = None
734
+ if rays.polarization_vector is not None:
735
+ hit_polarization_vectors = rays.polarization_vector[final_mask]
736
+
737
+ return RecordedRays(
738
+ positions=intersection_positions.astype(np.float32),
739
+ directions=hit_directions.astype(np.float32),
740
+ times=arrival_times,
741
+ intensities=hit_intensities,
742
+ wavelengths=hit_wavelengths,
743
+ generations=hit_generations,
744
+ polarization_vectors=hit_polarization_vectors,
745
+ )