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,1785 @@
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
+ Detector Array Utilities
36
+
37
+ Factory functions for creating arrays of detectors with various placement patterns.
38
+ Supports Fibonacci lattice distribution on spheres and cones for uniform coverage.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ from dataclasses import dataclass
44
+ from typing import Any
45
+
46
+ import numpy as np
47
+ from numpy.typing import NDArray
48
+
49
+ from ..surfaces import AnnularPlaneSurface, BoundedPlaneSurface, SurfaceRole
50
+
51
+
52
+ def fibonacci_sphere_points(n_points: int) -> NDArray[np.float64]:
53
+ """
54
+ Generate uniformly distributed points on unit sphere using Fibonacci lattice.
55
+
56
+ The Fibonacci lattice provides nearly uniform point distribution on a sphere
57
+ without clustering at poles (unlike latitude/longitude grids).
58
+
59
+ Parameters
60
+ ----------
61
+ n_points : int
62
+ Number of points to generate.
63
+
64
+ Returns
65
+ -------
66
+ ndarray, shape (n_points, 3)
67
+ Unit vectors on the sphere surface.
68
+
69
+ Examples
70
+ --------
71
+ >>> points = fibonacci_sphere_points(100)
72
+ >>> points.shape
73
+ (100, 3)
74
+ >>> np.allclose(np.linalg.norm(points, axis=1), 1.0)
75
+ True
76
+ """
77
+ golden_ratio = (1 + np.sqrt(5)) / 2
78
+
79
+ indices = np.arange(n_points)
80
+
81
+ # Polar angle: arccos(1 - 2*(i + 0.5)/n)
82
+ phi = np.arccos(1 - 2 * (indices + 0.5) / n_points)
83
+
84
+ # Azimuthal angle: 2*pi*i/golden_ratio
85
+ theta = 2 * np.pi * indices / golden_ratio
86
+
87
+ # Convert to Cartesian coordinates
88
+ x = np.sin(phi) * np.cos(theta)
89
+ y = np.sin(phi) * np.sin(theta)
90
+ z = np.cos(phi)
91
+
92
+ return np.column_stack([x, y, z])
93
+
94
+
95
+ def fibonacci_cone_points(
96
+ n_points: int,
97
+ cone_half_angle_deg: float,
98
+ cone_axis: tuple[float, float, float] = (0, 0, 1),
99
+ ) -> NDArray[np.float64]:
100
+ """
101
+ Generate uniformly distributed points within a cone around a specified axis.
102
+
103
+ Uses Fibonacci lattice on the full sphere, then filters to points within
104
+ the specified cone angle from the axis.
105
+
106
+ Parameters
107
+ ----------
108
+ n_points : int
109
+ Target number of points to return (may return fewer if over-generating).
110
+ cone_half_angle_deg : float
111
+ Half-angle of the cone in degrees (0-90).
112
+ cone_axis : tuple of float, optional
113
+ Unit vector for cone axis direction. Default is (0, 0, 1) = +z.
114
+
115
+ Returns
116
+ -------
117
+ ndarray, shape (M, 3)
118
+ Unit vectors within the cone, where M >= n_points (or all available).
119
+
120
+ Examples
121
+ --------
122
+ >>> # Points within 30° cone around +z axis
123
+ >>> points = fibonacci_cone_points(100, 30.0)
124
+ >>> points.shape[0] >= 100 or points.shape[0] > 0
125
+ True
126
+ >>> # All points should have z >= cos(30°)
127
+ >>> np.all(points[:, 2] >= np.cos(np.radians(30.0)) - 1e-6)
128
+ True
129
+ """
130
+ if cone_half_angle_deg <= 0 or cone_half_angle_deg > 90:
131
+ raise ValueError("Cone half-angle must be in (0, 90] degrees")
132
+
133
+ # Normalize cone axis
134
+ axis = np.array(cone_axis, dtype=np.float64)
135
+ axis = axis / np.linalg.norm(axis)
136
+
137
+ # Cosine threshold for cone membership
138
+ cos_limit = np.cos(np.radians(cone_half_angle_deg))
139
+
140
+ # Estimate how many points to generate on full sphere
141
+ # Cone solid angle fraction = (1 - cos(theta)) / 2
142
+ cone_fraction = (1 - cos_limit) / 2
143
+ n_generate = int(n_points / cone_fraction * 1.5) + 10
144
+
145
+ # Generate on full sphere (around +z axis initially)
146
+ all_points = fibonacci_sphere_points(n_generate)
147
+
148
+ # Filter to cone around +z axis
149
+ z_values = all_points[:, 2]
150
+ mask = z_values >= cos_limit
151
+ cone_points = all_points[mask]
152
+
153
+ # If cone axis is not +z, rotate points
154
+ if not np.allclose(axis, [0, 0, 1]):
155
+ cone_points = _rotate_points_to_axis(cone_points, axis)
156
+
157
+ # Return requested number of points
158
+ if len(cone_points) >= n_points:
159
+ return cone_points[:n_points]
160
+ return cone_points
161
+
162
+
163
+ def _rotate_points_to_axis(
164
+ points: NDArray[np.float64],
165
+ target_axis: NDArray[np.float64],
166
+ ) -> NDArray[np.float64]:
167
+ """
168
+ Rotate points from +z axis alignment to target axis alignment.
169
+
170
+ Uses Rodrigues' rotation formula.
171
+
172
+ Parameters
173
+ ----------
174
+ points : ndarray, shape (N, 3)
175
+ Points aligned with +z axis.
176
+ target_axis : ndarray, shape (3,)
177
+ Target axis unit vector.
178
+
179
+ Returns
180
+ -------
181
+ ndarray, shape (N, 3)
182
+ Rotated points aligned with target axis.
183
+ """
184
+ z_axis = np.array([0.0, 0.0, 1.0])
185
+
186
+ # Rotation axis = z × target (cross product)
187
+ k = np.cross(z_axis, target_axis)
188
+ k_norm = np.linalg.norm(k)
189
+
190
+ # If axes are parallel or anti-parallel
191
+ if k_norm < 1e-10:
192
+ if np.dot(z_axis, target_axis) > 0:
193
+ return points.copy() # Same direction
194
+ else:
195
+ return -points.copy() # Opposite direction (flip z)
196
+
197
+ k = k / k_norm
198
+
199
+ # Rotation angle
200
+ cos_angle = np.dot(z_axis, target_axis)
201
+ sin_angle = k_norm
202
+
203
+ # Rodrigues' rotation: v' = v*cos(θ) + (k×v)*sin(θ) + k*(k·v)*(1-cos(θ))
204
+ # For each point
205
+ rotated = np.zeros_like(points)
206
+ for i, p in enumerate(points):
207
+ cross_kp = np.cross(k, p)
208
+ dot_kp = np.dot(k, p)
209
+ rotated[i] = p * cos_angle + cross_kp * sin_angle + k * dot_kp * (1 - cos_angle)
210
+
211
+ return rotated
212
+
213
+
214
+ def create_planar_detector_array(
215
+ n_detectors: int,
216
+ altitude: float,
217
+ edge_length: float,
218
+ earth_center: tuple[float, float, float] = (0, 0, -6.371e6),
219
+ earth_radius: float = 6.371e6,
220
+ target_point: tuple[float, float, float] = (0, 0, 0),
221
+ cone_half_angle_deg: float | None = None,
222
+ cone_axis: tuple[float, float, float] | None = None,
223
+ name_prefix: str = "detector",
224
+ ) -> list[BoundedPlaneSurface]:
225
+ """
226
+ Create array of bounded planar detectors at specified altitude.
227
+
228
+ Detectors are arranged in a Fibonacci lattice pattern on a sphere at the
229
+ specified altitude above Earth's surface. Optionally limited to a cone
230
+ around a specified direction.
231
+
232
+ All detector normals point toward the target point (typically the origin
233
+ where ray reflections occur).
234
+
235
+ Parameters
236
+ ----------
237
+ n_detectors : int
238
+ Number of detectors to create.
239
+ altitude : float
240
+ Altitude above Earth surface (meters).
241
+ edge_length : float
242
+ Edge length of square detectors (meters).
243
+ earth_center : tuple of float, optional
244
+ Earth center position in simulation coordinates.
245
+ Default is (0, 0, -6.371e6).
246
+ earth_radius : float, optional
247
+ Earth radius (meters). Default is 6.371e6.
248
+ target_point : tuple of float, optional
249
+ Point that all detector normals should point toward.
250
+ Default is (0, 0, 0).
251
+ cone_half_angle_deg : float or None, optional
252
+ If provided, limit detectors to a cone of this half-angle.
253
+ Cone is centered on cone_axis direction.
254
+ cone_axis : tuple of float or None, optional
255
+ Direction for cone axis. If None, automatically computed as
256
+ (target_point - earth_center) normalized. This is typically
257
+ the "up" direction at the target point.
258
+ name_prefix : str, optional
259
+ Prefix for detector names. Default is "detector".
260
+
261
+ Returns
262
+ -------
263
+ list of BoundedPlaneSurface
264
+ Configured detector surfaces ready for GeometryBuilder.
265
+
266
+ Examples
267
+ --------
268
+ >>> # Create 100 detectors in 30° cone at 33km altitude
269
+ >>> detectors = create_planar_detector_array(
270
+ ... n_detectors=100,
271
+ ... altitude=33000.0,
272
+ ... edge_length=100.0,
273
+ ... cone_half_angle_deg=30.0,
274
+ ... )
275
+ >>> len(detectors)
276
+ 100
277
+ >>> detectors[0].role == SurfaceRole.DETECTOR
278
+ True
279
+ """
280
+ earth_center = np.array(earth_center, dtype=np.float64)
281
+ target = np.array(target_point, dtype=np.float64)
282
+
283
+ # Sphere radius for detector placement
284
+ detection_radius = earth_radius + altitude
285
+
286
+ # Determine cone axis if not specified
287
+ if cone_axis is None:
288
+ # Default: "up" direction at target point (radially outward from Earth center)
289
+ radial = target - earth_center
290
+ radial_norm = np.linalg.norm(radial)
291
+ if radial_norm < 1e-6:
292
+ cone_axis_vec = np.array([0.0, 0.0, 1.0])
293
+ else:
294
+ cone_axis_vec = radial / radial_norm
295
+ else:
296
+ cone_axis_vec = np.array(cone_axis, dtype=np.float64)
297
+ cone_axis_vec = cone_axis_vec / np.linalg.norm(cone_axis_vec)
298
+
299
+ # Generate unit vectors for detector positions
300
+ if cone_half_angle_deg is not None:
301
+ unit_vectors = fibonacci_cone_points(
302
+ n_detectors, cone_half_angle_deg, tuple(cone_axis_vec)
303
+ )
304
+ else:
305
+ unit_vectors = fibonacci_sphere_points(n_detectors)
306
+
307
+ # Create detectors
308
+ detectors = []
309
+ for i, unit_vec in enumerate(unit_vectors):
310
+ # Detector center position on the sphere
311
+ detector_center = earth_center + detection_radius * unit_vec
312
+
313
+ # Normal pointing toward target point
314
+ to_target = target - detector_center
315
+ to_target_norm = np.linalg.norm(to_target)
316
+ if to_target_norm < 1e-6:
317
+ # Detector at target point - use radial direction
318
+ normal = -unit_vec
319
+ else:
320
+ normal = to_target / to_target_norm
321
+
322
+ # Create bounded plane detector
323
+ detector = BoundedPlaneSurface(
324
+ point=tuple(detector_center.tolist()),
325
+ normal=tuple(normal.tolist()),
326
+ width=edge_length,
327
+ height=edge_length,
328
+ role=SurfaceRole.DETECTOR,
329
+ name=f"{name_prefix}_{i:04d}",
330
+ )
331
+ detectors.append(detector)
332
+
333
+ return detectors
334
+
335
+
336
+ def create_optimized_detector_grid(
337
+ ray_positions: NDArray[np.float64],
338
+ detector_edge: float,
339
+ n_detectors_x: int = 10,
340
+ n_detectors_y: int = 10,
341
+ altitude: float = 33000.0,
342
+ earth_center: tuple[float, float, float] = (0, 0, -6.371e6),
343
+ earth_radius: float = 6.371e6,
344
+ ray_intensities: NDArray[np.float64] | None = None,
345
+ target_point: tuple[float, float, float] = (0, 0, 0),
346
+ name_prefix: str = "grid",
347
+ ) -> list[BoundedPlaneSurface]:
348
+ """
349
+ Create a dense detector grid centered on the peak intensity location.
350
+
351
+ Finds the maximum intensity ray position and creates a fixed-size grid
352
+ of non-overlapping planar detectors around it at the specified altitude.
353
+
354
+ Parameters
355
+ ----------
356
+ ray_positions : ndarray, shape (N, 3)
357
+ Detected ray positions from Phase 1 simulation.
358
+ detector_edge : float
359
+ Edge length of each square detector (meters).
360
+ n_detectors_x : int, optional
361
+ Number of detectors in x (East) direction. Default 10.
362
+ n_detectors_y : int, optional
363
+ Number of detectors in y (North) direction. Default 10.
364
+ altitude : float, optional
365
+ Altitude above Earth surface for all detectors (meters). Default 33000.
366
+ earth_center : tuple of float, optional
367
+ Earth center position. Default (0, 0, -6.371e6).
368
+ earth_radius : float, optional
369
+ Earth radius (meters). Default 6.371e6.
370
+ ray_intensities : ndarray, shape (N,), optional
371
+ Ray intensities for finding peak location. If None, uses centroid.
372
+ target_point : tuple of float, optional
373
+ Point that all detector normals should point toward. Default (0, 0, 0).
374
+ name_prefix : str, optional
375
+ Prefix for detector names. Default is "grid".
376
+
377
+ Returns
378
+ -------
379
+ list of BoundedPlaneSurface
380
+ Grid of n_detectors_x * n_detectors_y detector surfaces.
381
+
382
+ Examples
383
+ --------
384
+ >>> detector_grid = create_optimized_detector_grid(
385
+ ... ray_positions=discovery_rays.positions,
386
+ ... ray_intensities=discovery_rays.intensities,
387
+ ... detector_edge=100.0,
388
+ ... n_detectors_x=10,
389
+ ... n_detectors_y=10,
390
+ ... )
391
+ >>> print(f"Created {len(detector_grid)} detectors") # 100 detectors
392
+ """
393
+ if len(ray_positions) == 0:
394
+ return []
395
+
396
+ ray_positions = np.asarray(ray_positions, dtype=np.float64)
397
+ earth_center_arr = np.array(earth_center, dtype=np.float64)
398
+ target = np.array(target_point, dtype=np.float64)
399
+
400
+ # Find peak intensity location (or centroid if no intensities)
401
+ if ray_intensities is not None and len(ray_intensities) > 0:
402
+ peak_idx = np.argmax(ray_intensities)
403
+ peak_position = ray_positions[peak_idx]
404
+ else:
405
+ peak_position = np.mean(ray_positions, axis=0)
406
+
407
+ # Project peak position to correct altitude sphere
408
+ radial = peak_position - earth_center_arr
409
+ radial_norm = np.linalg.norm(radial)
410
+ radial_unit = radial / radial_norm
411
+
412
+ # Center point at correct altitude
413
+ center_3d = earth_center_arr + (earth_radius + altitude) * radial_unit
414
+
415
+ # Establish local tangent plane frame (East-North-Up)
416
+ global_z = np.array([0.0, 0.0, 1.0])
417
+
418
+ if np.abs(np.dot(radial_unit, global_z)) > 0.999:
419
+ # Near pole - use global X as reference
420
+ east = np.cross(radial_unit, np.array([1.0, 0.0, 0.0]))
421
+ else:
422
+ east = np.cross(global_z, radial_unit)
423
+
424
+ east = east / np.linalg.norm(east)
425
+ north = np.cross(radial_unit, east)
426
+
427
+ # Generate detector grid centered on peak
428
+ detectors = []
429
+ for i in range(n_detectors_x):
430
+ for j in range(n_detectors_y):
431
+ # Offset from center (grid centered on peak)
432
+ east_offset = (i - (n_detectors_x - 1) / 2) * detector_edge
433
+ north_offset = (j - (n_detectors_y - 1) / 2) * detector_edge
434
+
435
+ # 3D position in tangent plane at altitude
436
+ offset_3d = east_offset * east + north_offset * north
437
+ det_center = center_3d + offset_3d
438
+
439
+ # Normal pointing toward target
440
+ to_target = target - det_center
441
+ normal = to_target / np.linalg.norm(to_target)
442
+
443
+ det = BoundedPlaneSurface(
444
+ point=tuple(det_center.tolist()),
445
+ normal=tuple(normal.tolist()),
446
+ width=detector_edge,
447
+ height=detector_edge,
448
+ role=SurfaceRole.DETECTOR,
449
+ name=f"{name_prefix}_{i:02d}_{j:02d}",
450
+ )
451
+ detectors.append(det)
452
+
453
+ return detectors
454
+
455
+
456
+ def compute_footprint_statistics(
457
+ ray_positions: NDArray[np.float64],
458
+ ray_intensities: NDArray[np.float64] | None = None,
459
+ earth_center: tuple[float, float, float] = (0, 0, -6.371e6),
460
+ ) -> dict:
461
+ """
462
+ Compute statistics about the spatial footprint of detected rays.
463
+
464
+ Useful for Phase 1 analysis before creating optimized detector grid.
465
+
466
+ Parameters
467
+ ----------
468
+ ray_positions : ndarray, shape (N, 3)
469
+ Detected ray positions.
470
+ ray_intensities : ndarray, shape (N,), optional
471
+ Ray intensities for weighted centroid calculation.
472
+ earth_center : tuple of float, optional
473
+ Earth center position.
474
+
475
+ Returns
476
+ -------
477
+ dict
478
+ Dictionary containing:
479
+ - 'centroid': Mean position (3,)
480
+ - 'intensity_weighted_centroid': Intensity-weighted mean position (3,)
481
+ - 'east_extent': Footprint extent in East direction (meters)
482
+ - 'north_extent': Footprint extent in North direction (meters)
483
+ - 'bounding_box_area': Area of bounding box (m²)
484
+ - 'aspect_ratio': Ratio of larger to smaller extent
485
+ - 'extent_95th': 95th percentile extents (east, north)
486
+ - 'local_frame': Dict with 'east', 'north', 'up' unit vectors
487
+ """
488
+ if len(ray_positions) == 0:
489
+ return {
490
+ "centroid": np.zeros(3),
491
+ "intensity_weighted_centroid": np.zeros(3),
492
+ "east_extent": 0.0,
493
+ "north_extent": 0.0,
494
+ "bounding_box_area": 0.0,
495
+ "aspect_ratio": 1.0,
496
+ "extent_95th": (0.0, 0.0),
497
+ "local_frame": {
498
+ "east": np.zeros(3),
499
+ "north": np.zeros(3),
500
+ "up": np.zeros(3),
501
+ },
502
+ }
503
+
504
+ ray_positions = np.asarray(ray_positions, dtype=np.float64)
505
+ earth_center_arr = np.array(earth_center, dtype=np.float64)
506
+
507
+ # Centroid
508
+ centroid = np.mean(ray_positions, axis=0)
509
+
510
+ # Intensity-weighted centroid
511
+ if ray_intensities is not None and len(ray_intensities) > 0:
512
+ weights = np.asarray(ray_intensities, dtype=np.float64)
513
+ total_weight = np.sum(weights)
514
+ if total_weight > 0:
515
+ intensity_centroid = np.average(ray_positions, axis=0, weights=weights)
516
+ else:
517
+ intensity_centroid = centroid.copy()
518
+ else:
519
+ intensity_centroid = centroid.copy()
520
+
521
+ # Local frame at centroid
522
+ radial = centroid - earth_center_arr
523
+ radial = radial / np.linalg.norm(radial)
524
+
525
+ global_z = np.array([0.0, 0.0, 1.0])
526
+ if np.abs(np.dot(radial, global_z)) > 0.999:
527
+ global_ref = np.array([1.0, 0.0, 0.0])
528
+ east = np.cross(radial, global_ref)
529
+ else:
530
+ east = np.cross(global_z, radial)
531
+ east = east / np.linalg.norm(east)
532
+ north = np.cross(radial, east)
533
+
534
+ # Project onto tangent plane
535
+ rel_pos = ray_positions - centroid
536
+ east_coords = np.dot(rel_pos, east)
537
+ north_coords = np.dot(rel_pos, north)
538
+
539
+ # Extents
540
+ east_extent = east_coords.max() - east_coords.min()
541
+ north_extent = north_coords.max() - north_coords.min()
542
+
543
+ # 95th percentile extents (robust to outliers)
544
+ if len(east_coords) > 10:
545
+ east_95 = np.percentile(np.abs(east_coords), 95) * 2
546
+ north_95 = np.percentile(np.abs(north_coords), 95) * 2
547
+ else:
548
+ east_95 = east_extent
549
+ north_95 = north_extent
550
+
551
+ # Aspect ratio
552
+ if min(east_extent, north_extent) > 0:
553
+ aspect_ratio = max(east_extent, north_extent) / min(east_extent, north_extent)
554
+ else:
555
+ aspect_ratio = 1.0
556
+
557
+ return {
558
+ "centroid": centroid,
559
+ "intensity_weighted_centroid": intensity_centroid,
560
+ "east_extent": east_extent,
561
+ "north_extent": north_extent,
562
+ "bounding_box_area": east_extent * north_extent,
563
+ "aspect_ratio": aspect_ratio,
564
+ "extent_95th": (east_95, north_95),
565
+ "local_frame": {"east": east, "north": north, "up": radial},
566
+ }
567
+
568
+
569
+ def create_grid_detector_array(
570
+ n_rows: int,
571
+ n_cols: int,
572
+ center: tuple[float, float, float],
573
+ normal: tuple[float, float, float],
574
+ spacing: float,
575
+ edge_length: float,
576
+ name_prefix: str = "detector",
577
+ ) -> list[BoundedPlaneSurface]:
578
+ """
579
+ Create a rectangular grid of planar detectors.
580
+
581
+ Detectors are arranged in a flat grid pattern, useful for near-field
582
+ detection or planar detector arrays.
583
+
584
+ Parameters
585
+ ----------
586
+ n_rows : int
587
+ Number of rows in the grid.
588
+ n_cols : int
589
+ Number of columns in the grid.
590
+ center : tuple of float
591
+ Center position of the entire grid.
592
+ normal : tuple of float
593
+ Normal direction for all detectors (perpendicular to grid).
594
+ spacing : float
595
+ Spacing between detector centers (meters).
596
+ edge_length : float
597
+ Edge length of each square detector (meters).
598
+ name_prefix : str, optional
599
+ Prefix for detector names. Default is "detector".
600
+
601
+ Returns
602
+ -------
603
+ list of BoundedPlaneSurface
604
+ Grid of detector surfaces.
605
+
606
+ Examples
607
+ --------
608
+ >>> # Create 10x10 grid of detectors
609
+ >>> detectors = create_grid_detector_array(
610
+ ... n_rows=10,
611
+ ... n_cols=10,
612
+ ... center=(0, 0, 1000),
613
+ ... normal=(0, 0, -1),
614
+ ... spacing=100.0,
615
+ ... edge_length=50.0,
616
+ ... )
617
+ >>> len(detectors)
618
+ 100
619
+ """
620
+ center = np.array(center, dtype=np.float64)
621
+ normal = np.array(normal, dtype=np.float64)
622
+ normal = normal / np.linalg.norm(normal)
623
+
624
+ # Compute local axes for the grid
625
+ # U axis: perpendicular to normal, in a "horizontal" direction
626
+ if abs(normal[2]) < 0.9:
627
+ ref = np.array([0.0, 0.0, 1.0])
628
+ else:
629
+ ref = np.array([1.0, 0.0, 0.0])
630
+
631
+ u_axis = ref - np.dot(ref, normal) * normal
632
+ u_axis = u_axis / np.linalg.norm(u_axis)
633
+ v_axis = np.cross(normal, u_axis)
634
+
635
+ # Grid offsets centered at (0, 0)
636
+ row_offsets = (np.arange(n_rows) - (n_rows - 1) / 2) * spacing
637
+ col_offsets = (np.arange(n_cols) - (n_cols - 1) / 2) * spacing
638
+
639
+ detectors = []
640
+ idx = 0
641
+ for row_off in row_offsets:
642
+ for col_off in col_offsets:
643
+ # Detector position
644
+ pos = center + col_off * u_axis + row_off * v_axis
645
+
646
+ detector = BoundedPlaneSurface(
647
+ point=tuple(pos.tolist()),
648
+ normal=tuple(normal.tolist()),
649
+ width=edge_length,
650
+ height=edge_length,
651
+ role=SurfaceRole.DETECTOR,
652
+ name=f"{name_prefix}_{idx:04d}",
653
+ )
654
+ detectors.append(detector)
655
+ idx += 1
656
+
657
+ return detectors
658
+
659
+
660
+ # =============================================================================
661
+ # Ring Detector Arrays
662
+ # =============================================================================
663
+
664
+
665
+ def create_ring_detector_array(
666
+ n_rings: int,
667
+ max_radius: float,
668
+ center: tuple[float, float, float] | None = None,
669
+ altitude: float = 33000.0,
670
+ target_point: tuple[float, float, float] = (0.0, 0.0, 0.0),
671
+ name_prefix: str = "ring",
672
+ ) -> list[AnnularPlaneSurface]:
673
+ """
674
+ Create concentric ring detectors in a flat plane.
675
+
676
+ All rings share the same center and normal (toward target_point).
677
+ This is useful for analyzing radial distribution of detected rays around
678
+ a specular reflection point.
679
+
680
+ Parameters
681
+ ----------
682
+ n_rings : int
683
+ Number of concentric rings to create.
684
+ max_radius : float
685
+ Linear distance from center to outer edge of outermost ring (meters).
686
+ center : tuple of float, optional
687
+ Center point of the ring array (x, y, z). If None, uses (0, 0, altitude).
688
+ altitude : float, optional
689
+ Only used if center is None. Default 33000 m.
690
+ target_point : tuple of float, optional
691
+ Point that all ring normals should point toward. Default (0, 0, 0).
692
+ name_prefix : str, optional
693
+ Prefix for ring names. Default "ring".
694
+
695
+ Returns
696
+ -------
697
+ list of AnnularPlaneSurface
698
+ N annular ring detectors, from innermost to outermost.
699
+
700
+ Examples
701
+ --------
702
+ >>> # Rings centered at footprint location
703
+ >>> rings = create_ring_detector_array(
704
+ ... n_rings=20,
705
+ ... max_radius=50000.0,
706
+ ... center=(191857.0, 320.6, 29441.9), # footprint centroid
707
+ ... target_point=(0, 0, 0),
708
+ ... )
709
+ >>> len(rings)
710
+ 20
711
+
712
+ Notes
713
+ -----
714
+ Ring widths are uniform: ring_width = max_radius / n_rings.
715
+ Ring i has inner_radius = i * ring_width and outer_radius = (i+1) * ring_width.
716
+ """
717
+ if n_rings <= 0:
718
+ raise ValueError("n_rings must be positive")
719
+ if max_radius <= 0:
720
+ raise ValueError("max_radius must be positive")
721
+
722
+ # Ring center: use provided center or default to (0, 0, altitude)
723
+ if center is not None:
724
+ ring_center = np.array(center, dtype=np.float64)
725
+ else:
726
+ ring_center = np.array([0.0, 0.0, altitude], dtype=np.float64)
727
+ target = np.array(target_point, dtype=np.float64)
728
+
729
+ # Compute normal pointing toward target
730
+ to_target = target - ring_center
731
+ to_target_norm = np.linalg.norm(to_target)
732
+ if to_target_norm < 1e-6:
733
+ # Center at target - use default downward normal
734
+ normal = np.array([0.0, 0.0, -1.0])
735
+ else:
736
+ normal = to_target / to_target_norm
737
+
738
+ # Ring width is uniform
739
+ ring_width = max_radius / n_rings
740
+
741
+ rings = []
742
+ for i in range(n_rings):
743
+ inner_radius = i * ring_width
744
+ outer_radius = (i + 1) * ring_width
745
+
746
+ ring = AnnularPlaneSurface(
747
+ center=tuple(ring_center.tolist()),
748
+ normal=tuple(normal.tolist()),
749
+ inner_radius=inner_radius,
750
+ outer_radius=outer_radius,
751
+ role=SurfaceRole.DETECTOR,
752
+ name=f"{name_prefix}_{i:03d}",
753
+ )
754
+ rings.append(ring)
755
+
756
+ return rings
757
+
758
+
759
+ def create_sphere_patch_detectors(
760
+ n_patches: int,
761
+ sphere_radius: float = 33000.0,
762
+ patch_size: float = 10000.0,
763
+ target_point: tuple[float, float, float] = (0.0, 0.0, 0.0),
764
+ zenith_limit_deg: float = 90.0,
765
+ name_prefix: str = "patch",
766
+ ) -> list[BoundedPlaneSurface]:
767
+ """
768
+ Create detector patches on a sphere centered at origin.
769
+
770
+ Each patch is a bounded planar detector tangent to the sphere surface,
771
+ with its normal pointing toward the origin. This provides constant
772
+ path length from origin to all detectors.
773
+
774
+ Parameters
775
+ ----------
776
+ n_patches : int
777
+ Number of patches to create.
778
+ sphere_radius : float, optional
779
+ Distance from origin to patch centers (meters). Default 33000 m.
780
+ patch_size : float, optional
781
+ Edge length of each square patch (meters). Default 10000 m.
782
+ target_point : tuple of float, optional
783
+ Sphere center (patches face toward this point). Default (0, 0, 0).
784
+ zenith_limit_deg : float, optional
785
+ Only create patches within this angle from +z axis (degrees).
786
+ Use 90 for upper hemisphere only, 180 for full sphere. Default 90.
787
+ name_prefix : str, optional
788
+ Prefix for patch names. Default "patch".
789
+
790
+ Returns
791
+ -------
792
+ list of BoundedPlaneSurface
793
+ N bounded plane detectors tangent to the origin-centered sphere.
794
+
795
+ Examples
796
+ --------
797
+ >>> patches = create_sphere_patch_detectors(
798
+ ... n_patches=100,
799
+ ... sphere_radius=33000.0,
800
+ ... patch_size=5000.0,
801
+ ... zenith_limit_deg=60.0, # Within 60 deg of +z
802
+ ... )
803
+ >>> len(patches)
804
+ 100
805
+
806
+ Notes
807
+ -----
808
+ Patches are distributed using Fibonacci spiral for uniform coverage.
809
+ All patch normals point toward the target_point (origin by default).
810
+ """
811
+ if n_patches <= 0:
812
+ raise ValueError("n_patches must be positive")
813
+ if sphere_radius <= 0:
814
+ raise ValueError("sphere_radius must be positive")
815
+ if patch_size <= 0:
816
+ raise ValueError("patch_size must be positive")
817
+ if zenith_limit_deg <= 0 or zenith_limit_deg > 180:
818
+ raise ValueError("zenith_limit_deg must be in (0, 180]")
819
+
820
+ target = np.array(target_point, dtype=np.float64)
821
+
822
+ # Generate unit vectors using Fibonacci spiral, filtered by zenith angle
823
+ if zenith_limit_deg < 180.0:
824
+ # Use cone points around +z axis
825
+ unit_vectors = fibonacci_cone_points(
826
+ n_patches, zenith_limit_deg, cone_axis=(0.0, 0.0, 1.0)
827
+ )
828
+ else:
829
+ # Full sphere
830
+ unit_vectors = fibonacci_sphere_points(n_patches)
831
+
832
+ patches = []
833
+ for i, unit_vec in enumerate(unit_vectors):
834
+ # Patch center on the sphere
835
+ patch_center = target + sphere_radius * unit_vec
836
+
837
+ # Normal points toward target (origin)
838
+ # Normal = -unit_vec (since unit_vec points from target to patch)
839
+ normal = -unit_vec
840
+
841
+ patch = BoundedPlaneSurface(
842
+ point=tuple(patch_center.tolist()),
843
+ normal=tuple(normal.tolist()),
844
+ width=patch_size,
845
+ height=patch_size,
846
+ role=SurfaceRole.DETECTOR,
847
+ name=f"{name_prefix}_{i:04d}",
848
+ )
849
+ patches.append(patch)
850
+
851
+ return patches
852
+
853
+
854
+ # =============================================================================
855
+ # Ray Binning Functions
856
+ # =============================================================================
857
+
858
+
859
+ @dataclass
860
+ class RingSegmentStats:
861
+ """Statistics for a single ring-azimuth segment."""
862
+
863
+ ring_index: int
864
+ azimuth_bin: int
865
+ azimuth_center_deg: float
866
+ inner_radius: float
867
+ outer_radius: float
868
+ segment_area: float
869
+ ray_count: int
870
+ total_intensity: float
871
+ irradiance: float
872
+ mean_time: float
873
+ time_std: float
874
+ positions: NDArray[np.float64] | None = None
875
+ times: NDArray[np.float64] | None = None
876
+ intensities: NDArray[np.float64] | None = None
877
+
878
+
879
+ def bin_rays_by_ring_and_azimuth(
880
+ ray_positions: NDArray[np.float64],
881
+ ray_times: NDArray[np.float64],
882
+ ray_intensities: NDArray[np.float64],
883
+ ring_detectors: list[AnnularPlaneSurface],
884
+ n_azimuth_bins: int = 36,
885
+ store_raw_data: bool = False,
886
+ ) -> list[RingSegmentStats]:
887
+ """
888
+ Bin detected rays into (ring, azimuth) segments.
889
+
890
+ After simulation with ring detectors, use this function to subdivide
891
+ each ring into azimuthal segments and compute per-segment statistics.
892
+
893
+ Parameters
894
+ ----------
895
+ ray_positions : ndarray, shape (N, 3)
896
+ Detected ray positions.
897
+ ray_times : ndarray, shape (N,)
898
+ Ray arrival times (seconds).
899
+ ray_intensities : ndarray, shape (N,)
900
+ Ray intensities.
901
+ ring_detectors : list of AnnularPlaneSurface
902
+ The ring detectors used in the simulation.
903
+ n_azimuth_bins : int, optional
904
+ Number of azimuthal bins (e.g., 36 for 10 degree bins). Default 36.
905
+ store_raw_data : bool, optional
906
+ If True, store positions/times/intensities arrays in each segment.
907
+ Default False (saves memory).
908
+
909
+ Returns
910
+ -------
911
+ list of RingSegmentStats
912
+ Statistics for each segment with ray_count > 0.
913
+
914
+ Examples
915
+ --------
916
+ >>> segment_stats = bin_rays_by_ring_and_azimuth(
917
+ ... ray_positions=result.detected.positions,
918
+ ... ray_times=result.detected.times,
919
+ ... ray_intensities=result.detected.intensities,
920
+ ... ring_detectors=rings,
921
+ ... n_azimuth_bins=36, # 10 deg bins
922
+ ... )
923
+ >>> for seg in segment_stats[:5]:
924
+ ... print(f"Ring {seg.ring_index}, Az {seg.azimuth_center_deg:.0f}deg: "
925
+ ... f"{seg.ray_count} rays, irradiance={seg.irradiance:.2e} W/m2")
926
+ """
927
+ if len(ray_positions) == 0:
928
+ return []
929
+
930
+ ray_positions = np.asarray(ray_positions, dtype=np.float64)
931
+ ray_times = np.asarray(ray_times, dtype=np.float64)
932
+ ray_intensities = np.asarray(ray_intensities, dtype=np.float64)
933
+
934
+ # Get ring center and axes from first detector (all share same center/normal)
935
+ if not ring_detectors:
936
+ return []
937
+
938
+ ref_ring = ring_detectors[0]
939
+ center = np.array(ref_ring.center, dtype=np.float64)
940
+ u_axis = np.array(ref_ring._u_axis, dtype=np.float64)
941
+ v_axis = np.array(ref_ring._v_axis, dtype=np.float64)
942
+
943
+ # Compute local coordinates for all rays
944
+ rel = ray_positions - center
945
+ u_coords = np.dot(rel, u_axis)
946
+ v_coords = np.dot(rel, v_axis)
947
+ radii = np.sqrt(u_coords**2 + v_coords**2)
948
+ azimuths = np.arctan2(v_coords, u_coords) # -pi to pi
949
+
950
+ # Azimuth bin edges
951
+ azimuth_bin_width = 2 * np.pi / n_azimuth_bins
952
+ # Shift azimuths to [0, 2*pi) for binning
953
+ azimuths_shifted = azimuths + np.pi # Now [0, 2*pi)
954
+ azimuth_bin_indices = np.floor(azimuths_shifted / azimuth_bin_width).astype(int)
955
+ azimuth_bin_indices = np.clip(azimuth_bin_indices, 0, n_azimuth_bins - 1)
956
+
957
+ segments = []
958
+
959
+ for ring_idx, ring in enumerate(ring_detectors):
960
+ inner_r = ring.inner_radius
961
+ outer_r = ring.outer_radius
962
+
963
+ # Find rays in this ring
964
+ in_ring = (radii >= inner_r) & (radii < outer_r)
965
+
966
+ for az_bin in range(n_azimuth_bins):
967
+ # Find rays in this azimuth bin
968
+ in_az = azimuth_bin_indices == az_bin
969
+ mask = in_ring & in_az
970
+
971
+ if not np.any(mask):
972
+ continue
973
+
974
+ # Compute segment area
975
+ # Segment = annular wedge = (R_out^2 - R_in^2) * delta_theta / 2
976
+ segment_area = (outer_r**2 - inner_r**2) * np.pi / n_azimuth_bins
977
+
978
+ # Extract data for this segment
979
+ seg_times = ray_times[mask]
980
+ seg_intensities = ray_intensities[mask]
981
+ seg_positions = ray_positions[mask] if store_raw_data else None
982
+
983
+ # Compute statistics
984
+ ray_count = int(np.sum(mask))
985
+ total_intensity = float(np.sum(seg_intensities))
986
+ irradiance = total_intensity / segment_area if segment_area > 0 else 0.0
987
+
988
+ if total_intensity > 0:
989
+ mean_time = float(np.average(seg_times, weights=seg_intensities))
990
+ time_variance = float(
991
+ np.average((seg_times - mean_time) ** 2, weights=seg_intensities)
992
+ )
993
+ time_std = float(np.sqrt(time_variance))
994
+ else:
995
+ mean_time = float(np.mean(seg_times))
996
+ time_std = float(np.std(seg_times))
997
+
998
+ # Azimuth center in degrees
999
+ az_center_rad = (az_bin + 0.5) * azimuth_bin_width - np.pi
1000
+ az_center_deg = float(np.degrees(az_center_rad))
1001
+
1002
+ seg_stats = RingSegmentStats(
1003
+ ring_index=ring_idx,
1004
+ azimuth_bin=az_bin,
1005
+ azimuth_center_deg=az_center_deg,
1006
+ inner_radius=inner_r,
1007
+ outer_radius=outer_r,
1008
+ segment_area=segment_area,
1009
+ ray_count=ray_count,
1010
+ total_intensity=total_intensity,
1011
+ irradiance=irradiance,
1012
+ mean_time=mean_time,
1013
+ time_std=time_std,
1014
+ positions=seg_positions,
1015
+ times=seg_times if store_raw_data else None,
1016
+ intensities=seg_intensities if store_raw_data else None,
1017
+ )
1018
+ segments.append(seg_stats)
1019
+
1020
+ return segments
1021
+
1022
+
1023
+ @dataclass
1024
+ class PatchStats:
1025
+ """Statistics for a single detector patch."""
1026
+
1027
+ patch_index: int
1028
+ patch_name: str
1029
+ center: tuple[float, float, float]
1030
+ normal: tuple[float, float, float]
1031
+ area: float
1032
+ ray_count: int
1033
+ total_intensity: float
1034
+ irradiance: float
1035
+ mean_time: float
1036
+ time_std: float
1037
+ positions: NDArray[np.float64] | None = None
1038
+ times: NDArray[np.float64] | None = None
1039
+ intensities: NDArray[np.float64] | None = None
1040
+
1041
+
1042
+ def bin_rays_by_patch(
1043
+ ray_positions: NDArray[np.float64],
1044
+ ray_times: NDArray[np.float64],
1045
+ ray_intensities: NDArray[np.float64],
1046
+ patch_detectors: list[BoundedPlaneSurface],
1047
+ store_raw_data: bool = False,
1048
+ ) -> list[PatchStats]:
1049
+ """
1050
+ Bin detected rays by which patch detector they hit.
1051
+
1052
+ After simulation with sphere patch detectors, use this function to
1053
+ compute per-patch statistics.
1054
+
1055
+ Parameters
1056
+ ----------
1057
+ ray_positions : ndarray, shape (N, 3)
1058
+ Detected ray positions.
1059
+ ray_times : ndarray, shape (N,)
1060
+ Ray arrival times (seconds).
1061
+ ray_intensities : ndarray, shape (N,)
1062
+ Ray intensities.
1063
+ patch_detectors : list of BoundedPlaneSurface
1064
+ The patch detectors used in the simulation.
1065
+ store_raw_data : bool, optional
1066
+ If True, store positions/times/intensities arrays in each patch.
1067
+ Default False (saves memory).
1068
+
1069
+ Returns
1070
+ -------
1071
+ list of PatchStats
1072
+ Statistics for each patch with ray_count > 0.
1073
+
1074
+ Examples
1075
+ --------
1076
+ >>> patch_stats = bin_rays_by_patch(
1077
+ ... ray_positions=result.detected.positions,
1078
+ ... ray_times=result.detected.times,
1079
+ ... ray_intensities=result.detected.intensities,
1080
+ ... patch_detectors=patches,
1081
+ ... )
1082
+ >>> for p in sorted(patch_stats, key=lambda x: -x.irradiance)[:5]:
1083
+ ... print(f"{p.patch_name}: {p.ray_count} rays, "
1084
+ ... f"irradiance={p.irradiance:.2e} W/m2")
1085
+ """
1086
+ if len(ray_positions) == 0:
1087
+ return []
1088
+
1089
+ ray_positions = np.asarray(ray_positions, dtype=np.float64)
1090
+ ray_times = np.asarray(ray_times, dtype=np.float64)
1091
+ ray_intensities = np.asarray(ray_intensities, dtype=np.float64)
1092
+
1093
+ patch_stats_list = []
1094
+
1095
+ for patch_idx, patch in enumerate(patch_detectors):
1096
+ patch_center = np.array(patch.point, dtype=np.float64)
1097
+ patch_normal = np.array(patch._normal, dtype=np.float64)
1098
+ patch_u = np.array(patch._u_axis, dtype=np.float64)
1099
+ patch_v = np.array(patch._v_axis, dtype=np.float64)
1100
+
1101
+ # Vector from patch center to each ray position
1102
+ rel_pos = ray_positions - patch_center
1103
+
1104
+ # Check if ray is on this patch plane (within tolerance)
1105
+ dist_to_plane = np.abs(np.dot(rel_pos, patch_normal))
1106
+ on_plane = dist_to_plane < 1.0 # 1m tolerance
1107
+
1108
+ # Check if within bounds
1109
+ u_coord = np.dot(rel_pos, patch_u)
1110
+ v_coord = np.dot(rel_pos, patch_v)
1111
+ in_bounds = (np.abs(u_coord) <= patch.half_width + 0.1) & (
1112
+ np.abs(v_coord) <= patch.half_height + 0.1
1113
+ )
1114
+
1115
+ mask = on_plane & in_bounds
1116
+
1117
+ if not np.any(mask):
1118
+ continue
1119
+
1120
+ # Extract data for this patch
1121
+ seg_times = ray_times[mask]
1122
+ seg_intensities = ray_intensities[mask]
1123
+ seg_positions = ray_positions[mask] if store_raw_data else None
1124
+
1125
+ # Compute statistics
1126
+ ray_count = int(np.sum(mask))
1127
+ total_intensity = float(np.sum(seg_intensities))
1128
+ area = patch.width * patch.height
1129
+ irradiance = total_intensity / area if area > 0 else 0.0
1130
+
1131
+ if total_intensity > 0:
1132
+ mean_time = float(np.average(seg_times, weights=seg_intensities))
1133
+ time_variance = float(
1134
+ np.average((seg_times - mean_time) ** 2, weights=seg_intensities)
1135
+ )
1136
+ time_std = float(np.sqrt(time_variance))
1137
+ else:
1138
+ mean_time = float(np.mean(seg_times))
1139
+ time_std = float(np.std(seg_times))
1140
+
1141
+ stats = PatchStats(
1142
+ patch_index=patch_idx,
1143
+ patch_name=patch.name,
1144
+ center=patch.point,
1145
+ normal=patch._normal,
1146
+ area=area,
1147
+ ray_count=ray_count,
1148
+ total_intensity=total_intensity,
1149
+ irradiance=irradiance,
1150
+ mean_time=mean_time,
1151
+ time_std=time_std,
1152
+ positions=seg_positions,
1153
+ times=seg_times if store_raw_data else None,
1154
+ intensities=seg_intensities if store_raw_data else None,
1155
+ )
1156
+ patch_stats_list.append(stats)
1157
+
1158
+ return patch_stats_list
1159
+
1160
+
1161
+ @dataclass
1162
+ class SphericalRingSegmentStats:
1163
+ """Statistics for a single spherical arc ring segment."""
1164
+
1165
+ ring_index: int
1166
+ azimuth_bin: int
1167
+ azimuth_center_deg: float
1168
+ inner_arc_distance: float # Arc distance from zenith to inner edge (meters)
1169
+ outer_arc_distance: float # Arc distance from zenith to outer edge (meters)
1170
+ inner_zenith_angle_deg: float # Zenith angle at inner edge (degrees)
1171
+ outer_zenith_angle_deg: float # Zenith angle at outer edge (degrees)
1172
+ segment_area: float # Area on sphere surface (m^2)
1173
+ ray_count: int
1174
+ total_intensity: float
1175
+ irradiance: float # W/m^2
1176
+ mean_time: float
1177
+ time_std: float
1178
+ mean_height: float # Mean height of rays in this segment (meters)
1179
+ positions: NDArray[np.float64] | None = None
1180
+ times: NDArray[np.float64] | None = None
1181
+ intensities: NDArray[np.float64] | None = None
1182
+
1183
+
1184
+ def bin_rays_by_spherical_arc_rings(
1185
+ ray_positions: NDArray[np.float64],
1186
+ ray_times: NDArray[np.float64],
1187
+ ray_intensities: NDArray[np.float64],
1188
+ sphere_radius: float,
1189
+ ring_arc_width: float = 10000.0,
1190
+ n_rings: int | None = None,
1191
+ n_azimuth_bins: int = 36,
1192
+ zenith_direction: tuple[float, float, float] = (0.0, 0.0, 1.0),
1193
+ sphere_center: tuple[float, float, float] = (0.0, 0.0, 0.0),
1194
+ store_raw_data: bool = False,
1195
+ earth_radius: float | None = None,
1196
+ ) -> list[SphericalRingSegmentStats]:
1197
+ """
1198
+ Bin rays on a sphere into rings by arc distance from zenith.
1199
+
1200
+ Rays detected on a sphere are binned into concentric rings defined
1201
+ by arc distance from the zenith point. Each ring is further subdivided
1202
+ into azimuthal segments.
1203
+
1204
+ This is the correct geometry for spherical detectors where rings follow
1205
+ the sphere surface rather than being flat planes.
1206
+
1207
+ Parameters
1208
+ ----------
1209
+ ray_positions : ndarray, shape (N, 3)
1210
+ Detected ray positions on the sphere.
1211
+ ray_times : ndarray, shape (N,)
1212
+ Ray arrival times (seconds).
1213
+ ray_intensities : ndarray, shape (N,)
1214
+ Ray intensities.
1215
+ sphere_radius : float
1216
+ Radius of the detection sphere (meters).
1217
+ ring_arc_width : float, optional
1218
+ Arc length width of each ring (meters). Default 10000 m (10 km).
1219
+ n_rings : int, optional
1220
+ Number of rings. If None, computed from sphere_radius / ring_arc_width.
1221
+ n_azimuth_bins : int, optional
1222
+ Number of azimuthal bins (e.g., 36 for 10 degree bins). Default 36.
1223
+ zenith_direction : tuple of float, optional
1224
+ Unit vector defining the zenith direction (pole of the rings).
1225
+ Default (0, 0, 1) = +z.
1226
+ sphere_center : tuple of float, optional
1227
+ Center of the sphere. Default (0, 0, 0).
1228
+ store_raw_data : bool, optional
1229
+ If True, store positions/times/intensities arrays in each segment.
1230
+ Default False (saves memory).
1231
+ earth_radius : float, optional
1232
+ If provided, compute mean_height as altitude above Earth's surface
1233
+ (radial distance from sphere_center minus earth_radius).
1234
+ If None, mean_height is the z-projection onto the zenith axis.
1235
+
1236
+ Returns
1237
+ -------
1238
+ list of SphericalRingSegmentStats
1239
+ Statistics for each segment with ray_count > 0.
1240
+
1241
+ Examples
1242
+ --------
1243
+ >>> # Bin rays into 10km arc-width rings on 33km sphere
1244
+ >>> segment_stats = bin_rays_by_spherical_arc_rings(
1245
+ ... ray_positions=result.detected.positions,
1246
+ ... ray_times=result.detected.times,
1247
+ ... ray_intensities=result.detected.intensities,
1248
+ ... sphere_radius=33000.0,
1249
+ ... ring_arc_width=10000.0, # 10 km rings
1250
+ ... n_azimuth_bins=36, # 10 deg bins
1251
+ ... )
1252
+ >>> for seg in segment_stats[:5]:
1253
+ ... print(f"Ring {seg.ring_index} (arc {seg.inner_arc_distance/1000:.1f}-"
1254
+ ... f"{seg.outer_arc_distance/1000:.1f} km), "
1255
+ ... f"Az {seg.azimuth_center_deg:.0f}deg: {seg.ray_count} rays")
1256
+
1257
+ Notes
1258
+ -----
1259
+ Ring geometry:
1260
+ - Ring 0: arc distance 0 to ring_arc_width from zenith
1261
+ - Ring i: arc distance i*ring_arc_width to (i+1)*ring_arc_width
1262
+ - The zenith point is at (0, 0, sphere_radius) if sphere_center=(0,0,0)
1263
+ and zenith_direction=(0,0,1)
1264
+
1265
+ Arc distance s and zenith angle θ are related by: s = R * θ
1266
+ where R is the sphere radius and θ is in radians.
1267
+
1268
+ The area of a spherical ring segment (annular wedge on sphere) is:
1269
+ Area = R² * (cos(θ_inner) - cos(θ_outer)) * Δφ
1270
+ where Δφ = 2π / n_azimuth_bins is the azimuthal width.
1271
+ """
1272
+ if len(ray_positions) == 0:
1273
+ return []
1274
+
1275
+ ray_positions = np.asarray(ray_positions, dtype=np.float64)
1276
+ ray_times = np.asarray(ray_times, dtype=np.float64)
1277
+ ray_intensities = np.asarray(ray_intensities, dtype=np.float64)
1278
+
1279
+ center = np.array(sphere_center, dtype=np.float64)
1280
+ zenith = np.array(zenith_direction, dtype=np.float64)
1281
+ zenith = zenith / np.linalg.norm(zenith)
1282
+
1283
+ # Compute number of rings if not specified
1284
+ if n_rings is None:
1285
+ # Cover up to 90 degrees from zenith (half sphere)
1286
+ max_arc = sphere_radius * np.pi / 2 # Quarter circumference
1287
+ n_rings = int(np.ceil(max_arc / ring_arc_width))
1288
+
1289
+ # Compute local coordinate system
1290
+ # x-axis: perpendicular to zenith, in a "horizontal" direction
1291
+ if abs(zenith[2]) < 0.9:
1292
+ ref = np.array([0.0, 0.0, 1.0])
1293
+ else:
1294
+ ref = np.array([1.0, 0.0, 0.0])
1295
+
1296
+ x_axis = ref - np.dot(ref, zenith) * zenith
1297
+ x_axis = x_axis / np.linalg.norm(x_axis)
1298
+ y_axis = np.cross(zenith, x_axis)
1299
+
1300
+ # For each ray, compute:
1301
+ # 1. Vector from sphere center to ray position
1302
+ rel_pos = ray_positions - center
1303
+
1304
+ # 2. Radial distance (should be close to sphere_radius)
1305
+ radii = np.linalg.norm(rel_pos, axis=1)
1306
+
1307
+ # 3. Zenith angle (angle from zenith direction)
1308
+ cos_zenith = np.dot(rel_pos, zenith) / radii
1309
+ cos_zenith = np.clip(cos_zenith, -1.0, 1.0)
1310
+ zenith_angles = np.arccos(cos_zenith) # 0 at zenith, pi at nadir
1311
+
1312
+ # 4. Arc distance from zenith
1313
+ arc_distances = sphere_radius * zenith_angles
1314
+
1315
+ # 5. Azimuth angle (around zenith axis)
1316
+ # Project onto plane perpendicular to zenith
1317
+ x_coords = np.dot(rel_pos, x_axis)
1318
+ y_coords = np.dot(rel_pos, y_axis)
1319
+ azimuths = np.arctan2(y_coords, x_coords) # -pi to pi
1320
+
1321
+ # 6. Height computation
1322
+ if earth_radius is not None:
1323
+ # Compute altitude above Earth's surface
1324
+ # (distance from sphere center = radii, altitude = radii - earth_radius)
1325
+ heights = radii - earth_radius
1326
+ else:
1327
+ # Compute z-component relative to sphere center (projection onto zenith)
1328
+ heights = np.dot(rel_pos, zenith)
1329
+
1330
+ # Ring bin edges (by arc distance)
1331
+ ring_bin_indices = np.floor(arc_distances / ring_arc_width).astype(int)
1332
+ ring_bin_indices = np.clip(ring_bin_indices, 0, n_rings - 1)
1333
+
1334
+ # Azimuth bin edges
1335
+ azimuth_bin_width = 2 * np.pi / n_azimuth_bins
1336
+ azimuths_shifted = azimuths + np.pi # [0, 2*pi)
1337
+ azimuth_bin_indices = np.floor(azimuths_shifted / azimuth_bin_width).astype(int)
1338
+ azimuth_bin_indices = np.clip(azimuth_bin_indices, 0, n_azimuth_bins - 1)
1339
+
1340
+ segments = []
1341
+
1342
+ for ring_idx in range(n_rings):
1343
+ inner_arc = ring_idx * ring_arc_width
1344
+ outer_arc = (ring_idx + 1) * ring_arc_width
1345
+
1346
+ # Convert arc distances to zenith angles
1347
+ inner_theta = inner_arc / sphere_radius
1348
+ outer_theta = outer_arc / sphere_radius
1349
+
1350
+ # Find rays in this ring
1351
+ in_ring = ring_bin_indices == ring_idx
1352
+
1353
+ for az_bin in range(n_azimuth_bins):
1354
+ in_az = azimuth_bin_indices == az_bin
1355
+ mask = in_ring & in_az
1356
+
1357
+ if not np.any(mask):
1358
+ continue
1359
+
1360
+ # Compute segment area on sphere
1361
+ # Area = R² * (cos(θ_inner) - cos(θ_outer)) * Δφ
1362
+ delta_phi = 2 * np.pi / n_azimuth_bins
1363
+ segment_area = (
1364
+ sphere_radius**2
1365
+ * (np.cos(inner_theta) - np.cos(outer_theta))
1366
+ * delta_phi
1367
+ )
1368
+
1369
+ # Extract data for this segment
1370
+ seg_times = ray_times[mask]
1371
+ seg_intensities = ray_intensities[mask]
1372
+ seg_positions = ray_positions[mask] if store_raw_data else None
1373
+ seg_heights = heights[mask]
1374
+
1375
+ # Compute statistics
1376
+ ray_count = int(np.sum(mask))
1377
+ total_intensity = float(np.sum(seg_intensities))
1378
+ irradiance = total_intensity / segment_area if segment_area > 0 else 0.0
1379
+
1380
+ if total_intensity > 0:
1381
+ mean_time = float(np.average(seg_times, weights=seg_intensities))
1382
+ time_variance = float(
1383
+ np.average((seg_times - mean_time) ** 2, weights=seg_intensities)
1384
+ )
1385
+ time_std = float(np.sqrt(time_variance))
1386
+ mean_height = float(np.average(seg_heights, weights=seg_intensities))
1387
+ else:
1388
+ mean_time = float(np.mean(seg_times))
1389
+ time_std = float(np.std(seg_times))
1390
+ mean_height = float(np.mean(seg_heights))
1391
+
1392
+ # Azimuth center in degrees
1393
+ az_center_rad = (az_bin + 0.5) * azimuth_bin_width - np.pi
1394
+ az_center_deg = float(np.degrees(az_center_rad))
1395
+
1396
+ seg_stats = SphericalRingSegmentStats(
1397
+ ring_index=ring_idx,
1398
+ azimuth_bin=az_bin,
1399
+ azimuth_center_deg=az_center_deg,
1400
+ inner_arc_distance=inner_arc,
1401
+ outer_arc_distance=outer_arc,
1402
+ inner_zenith_angle_deg=float(np.degrees(inner_theta)),
1403
+ outer_zenith_angle_deg=float(np.degrees(outer_theta)),
1404
+ segment_area=segment_area,
1405
+ ray_count=ray_count,
1406
+ total_intensity=total_intensity,
1407
+ irradiance=irradiance,
1408
+ mean_time=mean_time,
1409
+ time_std=time_std,
1410
+ mean_height=mean_height,
1411
+ positions=seg_positions,
1412
+ times=seg_times if store_raw_data else None,
1413
+ intensities=seg_intensities if store_raw_data else None,
1414
+ )
1415
+ segments.append(seg_stats)
1416
+
1417
+ return segments
1418
+
1419
+
1420
+ def compute_spherical_ring_summary(
1421
+ segment_stats: list[SphericalRingSegmentStats],
1422
+ ) -> dict[str, Any]:
1423
+ """
1424
+ Compute summary statistics across all spherical ring segments.
1425
+
1426
+ Parameters
1427
+ ----------
1428
+ segment_stats : list of SphericalRingSegmentStats
1429
+ Output from bin_rays_by_spherical_arc_rings.
1430
+
1431
+ Returns
1432
+ -------
1433
+ dict
1434
+ Summary containing:
1435
+ - 'total_rays': Total ray count
1436
+ - 'total_intensity': Total detected intensity
1437
+ - 'n_segments': Number of segments with hits
1438
+ - 'peak_irradiance': Maximum segment irradiance
1439
+ - 'peak_segment': SphericalRingSegmentStats for peak irradiance segment
1440
+ - 'per_ring': List of per-ring summaries
1441
+ """
1442
+ if not segment_stats:
1443
+ return {
1444
+ "total_rays": 0,
1445
+ "total_intensity": 0.0,
1446
+ "n_segments": 0,
1447
+ "peak_irradiance": 0.0,
1448
+ "peak_segment": None,
1449
+ "per_ring": [],
1450
+ }
1451
+
1452
+ total_rays = sum(s.ray_count for s in segment_stats)
1453
+ total_intensity = sum(s.total_intensity for s in segment_stats)
1454
+ peak_segment = max(segment_stats, key=lambda s: s.irradiance)
1455
+
1456
+ # Per-ring summaries
1457
+ ring_indices = sorted(set(s.ring_index for s in segment_stats))
1458
+ per_ring = []
1459
+ for ring_idx in ring_indices:
1460
+ ring_segs = [s for s in segment_stats if s.ring_index == ring_idx]
1461
+ if ring_segs:
1462
+ inner_arc = ring_segs[0].inner_arc_distance
1463
+ outer_arc = ring_segs[0].outer_arc_distance
1464
+ mid_arc = (inner_arc + outer_arc) / 2
1465
+ inner_zenith = ring_segs[0].inner_zenith_angle_deg
1466
+ outer_zenith = ring_segs[0].outer_zenith_angle_deg
1467
+ else:
1468
+ inner_arc = outer_arc = mid_arc = inner_zenith = outer_zenith = 0.0
1469
+
1470
+ per_ring.append(
1471
+ {
1472
+ "ring_index": ring_idx,
1473
+ "inner_arc_km": inner_arc / 1000,
1474
+ "outer_arc_km": outer_arc / 1000,
1475
+ "mid_arc_km": mid_arc / 1000,
1476
+ "inner_zenith_deg": inner_zenith,
1477
+ "outer_zenith_deg": outer_zenith,
1478
+ "n_segments": len(ring_segs),
1479
+ "total_rays": sum(s.ray_count for s in ring_segs),
1480
+ "total_intensity": sum(s.total_intensity for s in ring_segs),
1481
+ "mean_irradiance": (
1482
+ np.mean([s.irradiance for s in ring_segs]) if ring_segs else 0.0
1483
+ ),
1484
+ "mean_height_km": (
1485
+ np.mean([s.mean_height for s in ring_segs]) / 1000
1486
+ if ring_segs
1487
+ else 0.0
1488
+ ),
1489
+ }
1490
+ )
1491
+
1492
+ return {
1493
+ "total_rays": total_rays,
1494
+ "total_intensity": total_intensity,
1495
+ "n_segments": len(segment_stats),
1496
+ "peak_irradiance": peak_segment.irradiance,
1497
+ "peak_segment": peak_segment,
1498
+ "per_ring": per_ring,
1499
+ }
1500
+
1501
+
1502
+ def bin_rays_by_elevation_rings(
1503
+ ray_positions: NDArray[np.float64],
1504
+ ray_times: NDArray[np.float64],
1505
+ ray_intensities: NDArray[np.float64],
1506
+ sphere_radius: float,
1507
+ earth_radius: float,
1508
+ elevation_bin_width_deg: float | None = 0.1,
1509
+ max_elevation_deg: float = 90.0,
1510
+ min_elevation_deg: float = -2.0,
1511
+ n_azimuth_bins: int = 36,
1512
+ origin: tuple[float, float, float] = (0.0, 0.0, 0.0),
1513
+ store_raw_data: bool = False,
1514
+ ring_boundaries_deg: NDArray[np.float64] | None = None,
1515
+ ) -> list[SphericalRingSegmentStats]:
1516
+ """
1517
+ Bin rays into rings defined by elevation angle from origin (no shadowing).
1518
+
1519
+ Unlike bin_rays_by_spherical_arc_rings which uses arc distance on the sphere,
1520
+ this function defines rings by elevation angle as seen from the origin.
1521
+ This ensures no ring shadows another when viewed from the origin.
1522
+
1523
+ Can use either fixed elevation bin width OR custom ring boundaries for
1524
+ variable-width rings (e.g., constant physical size detectors).
1525
+
1526
+ Parameters
1527
+ ----------
1528
+ ray_positions : ndarray, shape (N, 3)
1529
+ Detected ray positions.
1530
+ ray_times : ndarray, shape (N,)
1531
+ Ray arrival times (seconds).
1532
+ ray_intensities : ndarray, shape (N,)
1533
+ Ray intensities.
1534
+ sphere_radius : float
1535
+ Radius of detection sphere from Earth center (meters).
1536
+ earth_radius : float
1537
+ Radius of Earth (meters). Used to compute altitude.
1538
+ elevation_bin_width_deg : float or None, optional
1539
+ Width of each elevation bin in degrees. Default 0.1 deg.
1540
+ Ignored if ring_boundaries_deg is provided.
1541
+ max_elevation_deg : float, optional
1542
+ Maximum elevation angle (at zenith). Default 90 deg.
1543
+ Ignored if ring_boundaries_deg is provided.
1544
+ min_elevation_deg : float, optional
1545
+ Minimum elevation angle (below horizontal). Default -2 deg.
1546
+ Ignored if ring_boundaries_deg is provided.
1547
+ n_azimuth_bins : int, optional
1548
+ Number of azimuthal bins. Default 36 (10 deg each).
1549
+ origin : tuple of float, optional
1550
+ Origin point for elevation angle computation. Default (0, 0, 0).
1551
+ store_raw_data : bool, optional
1552
+ If True, store raw data arrays in each segment. Default False.
1553
+ ring_boundaries_deg : ndarray, optional
1554
+ Custom ring boundary elevation angles in degrees, sorted descending
1555
+ (from zenith to horizon). If provided, overrides elevation_bin_width_deg.
1556
+ Example: [90, 81.4, 73.2, ...] for variable-width rings.
1557
+
1558
+ Returns
1559
+ -------
1560
+ list of SphericalRingSegmentStats
1561
+ Statistics for each segment with ray_count > 0.
1562
+
1563
+ Notes
1564
+ -----
1565
+ Ring boundaries are defined by elevation angle from origin, not arc distance.
1566
+ This ensures rays from origin see non-overlapping rings (no shadowing).
1567
+ The arc distances in the returned stats are computed from the elevation angles.
1568
+ """
1569
+ if len(ray_positions) == 0:
1570
+ return []
1571
+
1572
+ ray_positions = np.asarray(ray_positions, dtype=np.float64)
1573
+ ray_times = np.asarray(ray_times, dtype=np.float64)
1574
+ ray_intensities = np.asarray(ray_intensities, dtype=np.float64)
1575
+ origin_arr = np.array(origin, dtype=np.float64)
1576
+
1577
+ # Compute position relative to origin
1578
+ rel_pos = ray_positions - origin_arr
1579
+
1580
+ # Compute elevation angle from origin (angle above horizontal)
1581
+ # horizontal distance and vertical distance
1582
+ horizontal_dist = np.sqrt(rel_pos[:, 0] ** 2 + rel_pos[:, 1] ** 2)
1583
+ vertical_dist = rel_pos[:, 2]
1584
+ elevation_rad = np.arctan2(vertical_dist, horizontal_dist)
1585
+ elevation_deg = np.degrees(elevation_rad)
1586
+
1587
+ # Compute azimuth from origin
1588
+ azimuths = np.arctan2(rel_pos[:, 1], rel_pos[:, 0]) # -pi to pi
1589
+
1590
+ # Compute altitude above Earth surface
1591
+ dist_from_earth_center = np.sqrt(
1592
+ rel_pos[:, 0] ** 2 + rel_pos[:, 1] ** 2 + (rel_pos[:, 2] + earth_radius) ** 2
1593
+ )
1594
+ heights = dist_from_earth_center - earth_radius
1595
+
1596
+ # Elevation bin edges - use custom boundaries if provided
1597
+ if ring_boundaries_deg is not None:
1598
+ elevation_bins = np.asarray(ring_boundaries_deg, dtype=np.float64)
1599
+ n_rings = len(elevation_bins) - 1
1600
+ # For custom boundaries, use searchsorted to find bin indices
1601
+ # Boundaries are sorted descending, so we need to handle this
1602
+ # Bin i contains elevations in [elevation_bins[i+1], elevation_bins[i])
1603
+ ring_bin_indices = (
1604
+ np.searchsorted(-elevation_bins[:-1], -elevation_deg, side="right") - 1
1605
+ )
1606
+ ring_bin_indices = np.clip(ring_bin_indices, 0, n_rings - 1)
1607
+ else:
1608
+ n_rings = int(
1609
+ np.ceil((max_elevation_deg - min_elevation_deg) / elevation_bin_width_deg)
1610
+ )
1611
+ elevation_bins = np.linspace(max_elevation_deg, min_elevation_deg, n_rings + 1)
1612
+ # Bin by elevation (ring 0 is at zenith = highest elevation)
1613
+ ring_bin_indices = np.floor(
1614
+ (max_elevation_deg - elevation_deg) / elevation_bin_width_deg
1615
+ ).astype(int)
1616
+ ring_bin_indices = np.clip(ring_bin_indices, 0, n_rings - 1)
1617
+
1618
+ # Azimuth bin edges
1619
+ azimuth_bin_width = 2 * np.pi / n_azimuth_bins
1620
+ azimuths_shifted = azimuths + np.pi # [0, 2*pi)
1621
+ azimuth_bin_indices = np.floor(azimuths_shifted / azimuth_bin_width).astype(int)
1622
+ azimuth_bin_indices = np.clip(azimuth_bin_indices, 0, n_azimuth_bins - 1)
1623
+
1624
+ segments = []
1625
+
1626
+ # Helper function to compute distance from origin to detector sphere at elevation angle
1627
+ def _distance_at_elevation(elev_deg: float) -> float:
1628
+ """Distance from origin to detector sphere at given elevation angle."""
1629
+ elev_rad = np.radians(elev_deg)
1630
+ cos_e, sin_e = np.cos(elev_rad), np.sin(elev_rad)
1631
+ discriminant = sphere_radius**2 - earth_radius**2 * cos_e**2
1632
+ if discriminant < 0:
1633
+ return 0.0
1634
+ return -sin_e * earth_radius + np.sqrt(discriminant)
1635
+
1636
+ for ring_idx in range(n_rings):
1637
+ # Elevation bounds for this ring
1638
+ inner_elev_deg = elevation_bins[ring_idx]
1639
+ outer_elev_deg = elevation_bins[ring_idx + 1]
1640
+ inner_elev_rad = np.radians(inner_elev_deg)
1641
+ outer_elev_rad = np.radians(outer_elev_deg)
1642
+
1643
+ # Compute horizontal distances from origin to inner/outer ring boundaries
1644
+ # These are more meaningful than arc distances for elevation-based rings
1645
+ inner_dist = _distance_at_elevation(inner_elev_deg)
1646
+ outer_dist = _distance_at_elevation(outer_elev_deg)
1647
+ inner_horiz = inner_dist * np.cos(inner_elev_rad)
1648
+ outer_horiz = outer_dist * np.cos(outer_elev_rad)
1649
+
1650
+ # Find rays in this ring
1651
+ in_ring = ring_bin_indices == ring_idx
1652
+
1653
+ for az_bin in range(n_azimuth_bins):
1654
+ in_az = azimuth_bin_indices == az_bin
1655
+ mask = in_ring & in_az
1656
+
1657
+ if not np.any(mask):
1658
+ continue
1659
+
1660
+ # Compute segment area (approximate as flat annular sector)
1661
+ # For elevation rings, use spherical cap area approximation
1662
+ delta_phi = 2 * np.pi / n_azimuth_bins
1663
+ # Area on sphere between two elevation angles
1664
+ # A = R² * (sin(e1) - sin(e2)) * delta_phi (for elevation angles)
1665
+ segment_area = (
1666
+ sphere_radius**2
1667
+ * abs(np.sin(inner_elev_rad) - np.sin(outer_elev_rad))
1668
+ * delta_phi
1669
+ )
1670
+
1671
+ # Extract data
1672
+ seg_times = ray_times[mask]
1673
+ seg_intensities = ray_intensities[mask]
1674
+ seg_positions = ray_positions[mask] if store_raw_data else None
1675
+ seg_heights = heights[mask]
1676
+
1677
+ # Compute statistics
1678
+ ray_count = int(np.sum(mask))
1679
+ total_intensity = float(np.sum(seg_intensities))
1680
+ irradiance = total_intensity / segment_area if segment_area > 0 else 0.0
1681
+
1682
+ if total_intensity > 0:
1683
+ mean_time = float(np.average(seg_times, weights=seg_intensities))
1684
+ time_variance = float(
1685
+ np.average((seg_times - mean_time) ** 2, weights=seg_intensities)
1686
+ )
1687
+ time_std = float(np.sqrt(time_variance))
1688
+ mean_height = float(np.average(seg_heights, weights=seg_intensities))
1689
+ else:
1690
+ mean_time = float(np.mean(seg_times))
1691
+ time_std = float(np.std(seg_times))
1692
+ mean_height = float(np.mean(seg_heights))
1693
+
1694
+ # Azimuth center in degrees
1695
+ az_center_rad = (az_bin + 0.5) * azimuth_bin_width - np.pi
1696
+ az_center_deg = float(np.degrees(az_center_rad))
1697
+
1698
+ seg_stats = SphericalRingSegmentStats(
1699
+ ring_index=ring_idx,
1700
+ azimuth_bin=az_bin,
1701
+ azimuth_center_deg=az_center_deg,
1702
+ inner_arc_distance=float(
1703
+ inner_horiz
1704
+ ), # Horizontal distance to inner edge
1705
+ outer_arc_distance=float(
1706
+ outer_horiz
1707
+ ), # Horizontal distance to outer edge
1708
+ inner_zenith_angle_deg=90.0
1709
+ - inner_elev_deg, # Convert elevation to zenith angle
1710
+ outer_zenith_angle_deg=90.0 - outer_elev_deg,
1711
+ segment_area=segment_area,
1712
+ ray_count=ray_count,
1713
+ total_intensity=total_intensity,
1714
+ irradiance=irradiance,
1715
+ mean_time=mean_time,
1716
+ time_std=time_std,
1717
+ mean_height=mean_height,
1718
+ positions=seg_positions,
1719
+ times=seg_times if store_raw_data else None,
1720
+ intensities=seg_intensities if store_raw_data else None,
1721
+ )
1722
+ segments.append(seg_stats)
1723
+
1724
+ return segments
1725
+
1726
+
1727
+ def compute_ring_summary(
1728
+ segment_stats: list[RingSegmentStats],
1729
+ ) -> dict[str, Any]:
1730
+ """
1731
+ Compute summary statistics across all ring segments.
1732
+
1733
+ Parameters
1734
+ ----------
1735
+ segment_stats : list of RingSegmentStats
1736
+ Output from bin_rays_by_ring_and_azimuth.
1737
+
1738
+ Returns
1739
+ -------
1740
+ dict
1741
+ Summary containing:
1742
+ - 'total_rays': Total ray count
1743
+ - 'total_intensity': Total detected intensity
1744
+ - 'n_segments': Number of segments with hits
1745
+ - 'peak_irradiance': Maximum segment irradiance
1746
+ - 'peak_segment': RingSegmentStats for peak irradiance segment
1747
+ - 'per_ring': List of per-ring summaries (ring_index, n_segments, total_intensity)
1748
+ """
1749
+ if not segment_stats:
1750
+ return {
1751
+ "total_rays": 0,
1752
+ "total_intensity": 0.0,
1753
+ "n_segments": 0,
1754
+ "peak_irradiance": 0.0,
1755
+ "peak_segment": None,
1756
+ "per_ring": [],
1757
+ }
1758
+
1759
+ total_rays = sum(s.ray_count for s in segment_stats)
1760
+ total_intensity = sum(s.total_intensity for s in segment_stats)
1761
+ peak_segment = max(segment_stats, key=lambda s: s.irradiance)
1762
+
1763
+ # Per-ring summaries
1764
+ ring_indices = sorted(set(s.ring_index for s in segment_stats))
1765
+ per_ring = []
1766
+ for ring_idx in ring_indices:
1767
+ ring_segs = [s for s in segment_stats if s.ring_index == ring_idx]
1768
+ per_ring.append(
1769
+ {
1770
+ "ring_index": ring_idx,
1771
+ "n_segments": len(ring_segs),
1772
+ "total_rays": sum(s.ray_count for s in ring_segs),
1773
+ "total_intensity": sum(s.total_intensity for s in ring_segs),
1774
+ "mean_irradiance": np.mean([s.irradiance for s in ring_segs]),
1775
+ }
1776
+ )
1777
+
1778
+ return {
1779
+ "total_rays": total_rays,
1780
+ "total_intensity": total_intensity,
1781
+ "n_segments": len(segment_stats),
1782
+ "peak_irradiance": peak_segment.irradiance,
1783
+ "peak_segment": peak_segment,
1784
+ "per_ring": per_ring,
1785
+ }