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,463 @@
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
+ Time Spread Estimation Utilities
36
+
37
+ Provides geometric estimation of time spread for diverging beams reflecting
38
+ off surfaces and reaching a detector. Useful for computing upper/lower bounds
39
+ on arrival time distributions without full ray tracing.
40
+
41
+ Examples
42
+ --------
43
+ >>> from lsurf.utilities.time_spread import estimate_time_spread
44
+ >>> from lsurf.surfaces import GerstnerWaveSurface, GerstnerWaveParams
45
+ >>>
46
+ >>> # Create a wave surface
47
+ >>> wave = GerstnerWaveParams(amplitude=1.0, wavelength=50.0)
48
+ >>> surface = GerstnerWaveSurface(wave_params=[wave])
49
+ >>>
50
+ >>> result = estimate_time_spread(
51
+ ... source_position=(0, 0, 500),
52
+ ... beam_direction=(0.98, 0, -0.17),
53
+ ... divergence_angle=np.radians(1.0),
54
+ ... detector_position=(32000, 0, 5700),
55
+ ... surface=surface,
56
+ ... )
57
+ >>> print(f"Time spread: {result.time_spread_ns:.2f} ns")
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ from dataclasses import dataclass
63
+ from typing import TYPE_CHECKING
64
+
65
+ import numpy as np
66
+ from numpy.typing import NDArray
67
+
68
+ if TYPE_CHECKING:
69
+ from ..surfaces import Surface
70
+
71
+ # Speed of light in m/s
72
+ SPEED_OF_LIGHT = 299792458.0
73
+
74
+
75
+ @dataclass
76
+ class TimeSpreadResult:
77
+ """
78
+ Results from time spread estimation.
79
+
80
+ Attributes
81
+ ----------
82
+ min_path : float
83
+ Shortest path length in meters
84
+ max_path : float
85
+ Longest path length in meters
86
+ path_spread : float
87
+ Difference between max and min path in meters
88
+ time_spread_s : float
89
+ Time spread in seconds
90
+ time_spread_ns : float
91
+ Time spread in nanoseconds
92
+ min_path_point : ndarray
93
+ Surface point with shortest path
94
+ max_path_point : ndarray
95
+ Surface point with longest path
96
+ edge_points : ndarray
97
+ All beam edge intersection points on surface
98
+ path_lengths : ndarray
99
+ Path lengths for all edge points
100
+ center_point : ndarray
101
+ Center ray intersection point
102
+ """
103
+
104
+ min_path: float
105
+ max_path: float
106
+ path_spread: float
107
+ time_spread_s: float
108
+ time_spread_ns: float
109
+ min_path_point: NDArray[np.float64]
110
+ max_path_point: NDArray[np.float64]
111
+ edge_points: NDArray[np.float64]
112
+ path_lengths: NDArray[np.float64]
113
+ center_point: NDArray[np.float64]
114
+
115
+
116
+ def compute_beam_footprint(
117
+ source_position: tuple[float, float, float],
118
+ beam_direction: tuple[float, float, float],
119
+ divergence_angle: float,
120
+ n_edge_points: int = 100,
121
+ surface: Surface | None = None,
122
+ ) -> dict[str, float | NDArray[np.float64]]:
123
+ """
124
+ Compute where the edges of a diverging beam hit a surface.
125
+
126
+ Parameters
127
+ ----------
128
+ source_position : tuple
129
+ (x, y, z) position of the source in meters
130
+ beam_direction : tuple
131
+ Unit vector of beam center direction
132
+ divergence_angle : float
133
+ Half-angle divergence of the beam in radians
134
+ n_edge_points : int
135
+ Number of points around the beam edge cone
136
+ surface : Surface, optional
137
+ Surface object to intersect with (e.g., GerstnerWaveSurface,
138
+ CurvedWaveSurface, PlanarSurface). If None, uses flat surface at z=0.
139
+
140
+ Returns
141
+ -------
142
+ dict
143
+ Dictionary containing:
144
+ - edge_points : ndarray, shape (N, 3) - Surface intersection points
145
+ - center_point : ndarray, shape (3,) - Center ray intersection
146
+ - edge_distances : ndarray - Distances from source to each edge point
147
+ - center_distance : float - Distance from source to center point
148
+ """
149
+ src = np.array(source_position, dtype=np.float64)
150
+ beam_dir = np.array(beam_direction, dtype=np.float64)
151
+ beam_dir = beam_dir / np.linalg.norm(beam_dir)
152
+
153
+ # Create orthonormal basis around beam direction
154
+ if abs(beam_dir[2]) < 0.9:
155
+ up = np.array([0.0, 0.0, 1.0])
156
+ else:
157
+ up = np.array([1.0, 0.0, 0.0])
158
+
159
+ v1 = np.cross(beam_dir, up)
160
+ v1 = v1 / np.linalg.norm(v1)
161
+ v2 = np.cross(beam_dir, v1)
162
+ v2 = v2 / np.linalg.norm(v2)
163
+
164
+ # Generate all edge ray directions
165
+ phi_angles = np.linspace(0, 2 * np.pi, n_edge_points, endpoint=False)
166
+
167
+ edge_directions = []
168
+ for phi in phi_angles:
169
+ edge_dir = np.cos(divergence_angle) * beam_dir + np.sin(divergence_angle) * (
170
+ np.cos(phi) * v1 + np.sin(phi) * v2
171
+ )
172
+ edge_dir = edge_dir / np.linalg.norm(edge_dir)
173
+ edge_directions.append(edge_dir)
174
+
175
+ edge_directions = np.array(edge_directions, dtype=np.float32)
176
+ origins = np.tile(src.astype(np.float32), (n_edge_points, 1))
177
+
178
+ # Find intersections
179
+ edge_points = []
180
+ edge_distances = []
181
+
182
+ if surface is not None:
183
+ # Use surface.intersect() for batch intersection
184
+ distances, hit_mask = surface.intersect(origins, edge_directions)
185
+
186
+ for i in range(n_edge_points):
187
+ if hit_mask[i] and distances[i] > 0:
188
+ t = float(distances[i])
189
+ hit_point = src + t * edge_directions[i].astype(np.float64)
190
+ edge_points.append(hit_point)
191
+ edge_distances.append(t)
192
+ else:
193
+ # Flat surface at z=0
194
+ for i in range(n_edge_points):
195
+ edge_dir = edge_directions[i].astype(np.float64)
196
+ if abs(edge_dir[2]) > 1e-10:
197
+ t = -src[2] / edge_dir[2]
198
+ if t > 0:
199
+ hit_point = src + t * edge_dir
200
+ edge_points.append(hit_point)
201
+ edge_distances.append(t)
202
+
203
+ edge_points = np.array(edge_points) if edge_points else np.empty((0, 3))
204
+ edge_distances = np.array(edge_distances)
205
+
206
+ # Center ray intersection
207
+ center_origins = src.astype(np.float32).reshape(1, 3)
208
+ center_directions = beam_dir.astype(np.float32).reshape(1, 3)
209
+
210
+ if surface is not None:
211
+ distances, hit_mask = surface.intersect(center_origins, center_directions)
212
+ if hit_mask[0] and distances[0] > 0:
213
+ t_center = float(distances[0])
214
+ center_point = src + t_center * beam_dir
215
+ else:
216
+ # Fallback to flat surface
217
+ t_center = -src[2] / beam_dir[2]
218
+ center_point = src + t_center * beam_dir
219
+ else:
220
+ t_center = -src[2] / beam_dir[2]
221
+ center_point = src + t_center * beam_dir
222
+
223
+ return {
224
+ "edge_points": edge_points,
225
+ "center_point": center_point,
226
+ "edge_distances": edge_distances,
227
+ "center_distance": t_center,
228
+ }
229
+
230
+
231
+ def estimate_time_spread(
232
+ source_position: tuple[float, float, float],
233
+ beam_direction: tuple[float, float, float],
234
+ divergence_angle: float,
235
+ detector_position: tuple[float, float, float],
236
+ surface: Surface | None = None,
237
+ n_edge_points: int = 100,
238
+ speed_of_light: float = SPEED_OF_LIGHT,
239
+ ) -> TimeSpreadResult:
240
+ """
241
+ Estimate time spread for a diverging beam reflecting to a detector.
242
+
243
+ Computes geometric path lengths from source through surface edge points
244
+ to detector, giving an upper bound estimate on arrival time spread.
245
+
246
+ Parameters
247
+ ----------
248
+ source_position : tuple
249
+ (x, y, z) source position in meters
250
+ beam_direction : tuple
251
+ Beam center direction (will be normalized)
252
+ divergence_angle : float
253
+ Beam half-angle divergence in radians
254
+ detector_position : tuple
255
+ (x, y, z) detector position in meters
256
+ surface : Surface, optional
257
+ Surface object to intersect with (e.g., GerstnerWaveSurface,
258
+ CurvedWaveSurface, PlanarSurface). If None, uses flat surface at z=0.
259
+ n_edge_points : int
260
+ Number of points around beam edge for sampling
261
+ speed_of_light : float
262
+ Speed of light in m/s
263
+
264
+ Returns
265
+ -------
266
+ TimeSpreadResult
267
+ Dataclass containing path lengths, time spread, and geometry info
268
+
269
+ Examples
270
+ --------
271
+ >>> # Flat surface estimate
272
+ >>> result = estimate_time_spread(
273
+ ... source_position=(-2800, 0, 500),
274
+ ... beam_direction=(0.98, 0, -0.17),
275
+ ... divergence_angle=np.radians(1.0),
276
+ ... detector_position=(32000, 0, 5700),
277
+ ... )
278
+ >>> print(f"Flat surface: {result.time_spread_ns:.2f} ns")
279
+
280
+ >>> # Wavy surface estimate with GerstnerWaveSurface
281
+ >>> from lsurf.surfaces import GerstnerWaveSurface, GerstnerWaveParams
282
+ >>> wave = GerstnerWaveParams(amplitude=1.0, wavelength=50.0, direction=(0, 1))
283
+ >>> surface = GerstnerWaveSurface(wave_params=[wave])
284
+ >>> result = estimate_time_spread(
285
+ ... source_position=(-2800, 0, 500),
286
+ ... beam_direction=(0.98, 0, -0.17),
287
+ ... divergence_angle=np.radians(1.0),
288
+ ... detector_position=(32000, 0, 5700),
289
+ ... surface=surface,
290
+ ... )
291
+ >>> print(f"Wavy surface: {result.time_spread_ns:.2f} ns")
292
+ """
293
+ src = np.array(source_position, dtype=np.float64)
294
+ det = np.array(detector_position, dtype=np.float64)
295
+
296
+ # Get beam footprint on surface
297
+ footprint = compute_beam_footprint(
298
+ source_position,
299
+ beam_direction,
300
+ divergence_angle,
301
+ n_edge_points=n_edge_points,
302
+ surface=surface,
303
+ )
304
+
305
+ edge_points = footprint["edge_points"]
306
+ center_point = footprint["center_point"]
307
+
308
+ if len(edge_points) == 0:
309
+ # No intersections found
310
+ return TimeSpreadResult(
311
+ min_path=0.0,
312
+ max_path=0.0,
313
+ path_spread=0.0,
314
+ time_spread_s=0.0,
315
+ time_spread_ns=0.0,
316
+ min_path_point=center_point,
317
+ max_path_point=center_point,
318
+ edge_points=edge_points,
319
+ path_lengths=np.array([]),
320
+ center_point=center_point,
321
+ )
322
+
323
+ # Compute total path lengths: source -> surface -> detector
324
+ path_lengths = []
325
+ for pt in edge_points:
326
+ d_src_to_surface = np.linalg.norm(pt - src)
327
+ d_surface_to_det = np.linalg.norm(det - pt)
328
+ total_path = d_src_to_surface + d_surface_to_det
329
+ path_lengths.append(total_path)
330
+
331
+ path_lengths = np.array(path_lengths)
332
+
333
+ min_path = np.min(path_lengths)
334
+ max_path = np.max(path_lengths)
335
+ path_spread = max_path - min_path
336
+
337
+ time_spread_s = path_spread / speed_of_light
338
+ time_spread_ns = time_spread_s * 1e9
339
+
340
+ min_idx = np.argmin(path_lengths)
341
+ max_idx = np.argmax(path_lengths)
342
+
343
+ return TimeSpreadResult(
344
+ min_path=min_path,
345
+ max_path=max_path,
346
+ path_spread=path_spread,
347
+ time_spread_s=time_spread_s,
348
+ time_spread_ns=time_spread_ns,
349
+ min_path_point=edge_points[min_idx],
350
+ max_path_point=edge_points[max_idx],
351
+ edge_points=edge_points,
352
+ path_lengths=path_lengths,
353
+ center_point=center_point,
354
+ )
355
+
356
+
357
+ def estimate_time_spread_bounds(
358
+ source_position: tuple[float, float, float],
359
+ beam_direction: tuple[float, float, float],
360
+ divergence_angle: float,
361
+ detector_positions: NDArray[np.float64],
362
+ surface: Surface | None = None,
363
+ n_edge_points: int = 100,
364
+ ) -> dict[str, NDArray[np.float64]]:
365
+ """
366
+ Estimate time spread bounds for multiple detector positions.
367
+
368
+ Useful for computing time spread maps across a detector surface.
369
+
370
+ Parameters
371
+ ----------
372
+ source_position : tuple
373
+ (x, y, z) source position in meters
374
+ beam_direction : tuple
375
+ Beam center direction
376
+ divergence_angle : float
377
+ Beam half-angle divergence in radians
378
+ detector_positions : ndarray, shape (N, 3)
379
+ Array of detector positions to evaluate
380
+ surface : Surface, optional
381
+ Surface object to intersect with. If None, uses flat surface at z=0.
382
+ n_edge_points : int
383
+ Number of edge points for beam footprint
384
+
385
+ Returns
386
+ -------
387
+ dict
388
+ Dictionary containing:
389
+ - time_spread_ns : ndarray, shape (N,) - Time spread at each position
390
+ - path_spread : ndarray, shape (N,) - Path spread at each position
391
+ - min_path : ndarray, shape (N,) - Minimum path at each position
392
+ - max_path : ndarray, shape (N,) - Maximum path at each position
393
+
394
+ Examples
395
+ --------
396
+ >>> # Compute time spread for grid of detector positions
397
+ >>> lon = np.linspace(-5, 5, 20)
398
+ >>> lat = np.linspace(5, 15, 20)
399
+ >>> lon_grid, lat_grid = np.meshgrid(lon, lat)
400
+ >>> r = 33000 # 33 km
401
+ >>> x = r * np.cos(np.radians(lat_grid)) * np.cos(np.radians(lon_grid))
402
+ >>> y = r * np.cos(np.radians(lat_grid)) * np.sin(np.radians(lon_grid))
403
+ >>> z = r * np.sin(np.radians(lat_grid))
404
+ >>> positions = np.stack([x.ravel(), y.ravel(), z.ravel()], axis=1)
405
+ >>>
406
+ >>> bounds = estimate_time_spread_bounds(
407
+ ... source_position=(-2800, 0, 500),
408
+ ... beam_direction=(0.98, 0, -0.17),
409
+ ... divergence_angle=np.radians(1.0),
410
+ ... detector_positions=positions,
411
+ ... )
412
+ >>> time_spread_map = bounds['time_spread_ns'].reshape(lat_grid.shape)
413
+ """
414
+ n_detectors = len(detector_positions)
415
+
416
+ time_spreads = np.zeros(n_detectors)
417
+ path_spreads = np.zeros(n_detectors)
418
+ min_paths = np.zeros(n_detectors)
419
+ max_paths = np.zeros(n_detectors)
420
+
421
+ # Compute footprint once (same for all detectors)
422
+ footprint = compute_beam_footprint(
423
+ source_position,
424
+ beam_direction,
425
+ divergence_angle,
426
+ n_edge_points=n_edge_points,
427
+ surface=surface,
428
+ )
429
+
430
+ src = np.array(source_position, dtype=np.float64)
431
+ edge_points = footprint["edge_points"]
432
+
433
+ if len(edge_points) == 0:
434
+ return {
435
+ "time_spread_ns": time_spreads,
436
+ "path_spread": path_spreads,
437
+ "min_path": min_paths,
438
+ "max_path": max_paths,
439
+ }
440
+
441
+ # Pre-compute source-to-surface distances (same for all detectors)
442
+ src_to_surface = np.linalg.norm(edge_points - src, axis=1)
443
+
444
+ for i, det_pos in enumerate(detector_positions):
445
+ det = np.array(det_pos, dtype=np.float64)
446
+
447
+ # Surface-to-detector distances
448
+ surface_to_det = np.linalg.norm(edge_points - det, axis=1)
449
+
450
+ # Total paths
451
+ total_paths = src_to_surface + surface_to_det
452
+
453
+ min_paths[i] = np.min(total_paths)
454
+ max_paths[i] = np.max(total_paths)
455
+ path_spreads[i] = max_paths[i] - min_paths[i]
456
+ time_spreads[i] = path_spreads[i] / SPEED_OF_LIGHT * 1e9
457
+
458
+ return {
459
+ "time_spread_ns": time_spreads,
460
+ "path_spread": path_spreads,
461
+ "min_path": min_paths,
462
+ "max_path": max_paths,
463
+ }