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.
- lsurf/__init__.py +471 -0
- lsurf/analysis/__init__.py +107 -0
- lsurf/analysis/healpix_utils.py +418 -0
- lsurf/analysis/sphere_viz.py +1280 -0
- lsurf/cli/__init__.py +48 -0
- lsurf/cli/build.py +398 -0
- lsurf/cli/config_schema.py +318 -0
- lsurf/cli/gui_cmd.py +76 -0
- lsurf/cli/interactive.py +850 -0
- lsurf/cli/main.py +81 -0
- lsurf/cli/run.py +806 -0
- lsurf/detectors/__init__.py +266 -0
- lsurf/detectors/analysis.py +289 -0
- lsurf/detectors/base.py +284 -0
- lsurf/detectors/constant_size_rings.py +485 -0
- lsurf/detectors/directional.py +45 -0
- lsurf/detectors/extended/__init__.py +73 -0
- lsurf/detectors/extended/local_sphere.py +353 -0
- lsurf/detectors/extended/recording_sphere.py +368 -0
- lsurf/detectors/planar.py +45 -0
- lsurf/detectors/protocol.py +187 -0
- lsurf/detectors/recording_spheres.py +63 -0
- lsurf/detectors/results.py +1140 -0
- lsurf/detectors/small/__init__.py +79 -0
- lsurf/detectors/small/directional.py +330 -0
- lsurf/detectors/small/planar.py +401 -0
- lsurf/detectors/small/spherical.py +450 -0
- lsurf/detectors/spherical.py +45 -0
- lsurf/geometry/__init__.py +199 -0
- lsurf/geometry/builder.py +478 -0
- lsurf/geometry/cell.py +228 -0
- lsurf/geometry/cell_geometry.py +247 -0
- lsurf/geometry/detector_arrays.py +1785 -0
- lsurf/geometry/geometry.py +222 -0
- lsurf/geometry/surface_analysis.py +375 -0
- lsurf/geometry/validation.py +91 -0
- lsurf/gui/__init__.py +51 -0
- lsurf/gui/app.py +903 -0
- lsurf/gui/core/__init__.py +39 -0
- lsurf/gui/core/scene.py +343 -0
- lsurf/gui/core/simulation.py +264 -0
- lsurf/gui/renderers/__init__.py +40 -0
- lsurf/gui/renderers/ray_renderer.py +353 -0
- lsurf/gui/renderers/source_renderer.py +505 -0
- lsurf/gui/renderers/surface_renderer.py +477 -0
- lsurf/gui/views/__init__.py +48 -0
- lsurf/gui/views/config_editor.py +3199 -0
- lsurf/gui/views/properties.py +257 -0
- lsurf/gui/views/results.py +291 -0
- lsurf/gui/views/scene_tree.py +180 -0
- lsurf/gui/views/viewport_3d.py +555 -0
- lsurf/gui/views/visualizations.py +712 -0
- lsurf/materials/__init__.py +169 -0
- lsurf/materials/base/__init__.py +64 -0
- lsurf/materials/base/full_inhomogeneous.py +208 -0
- lsurf/materials/base/grid_inhomogeneous.py +319 -0
- lsurf/materials/base/homogeneous.py +342 -0
- lsurf/materials/base/material_field.py +527 -0
- lsurf/materials/base/simple_inhomogeneous.py +418 -0
- lsurf/materials/base/spectral_inhomogeneous.py +497 -0
- lsurf/materials/implementations/__init__.py +120 -0
- lsurf/materials/implementations/data/alpha_values_typical_atmosphere_updated.txt +24 -0
- lsurf/materials/implementations/duct_atmosphere.py +390 -0
- lsurf/materials/implementations/exponential_atmosphere.py +435 -0
- lsurf/materials/implementations/gaussian_lens.py +120 -0
- lsurf/materials/implementations/interpolated_data.py +123 -0
- lsurf/materials/implementations/layered_atmosphere.py +134 -0
- lsurf/materials/implementations/linear_gradient.py +109 -0
- lsurf/materials/implementations/linsley_atmosphere.py +764 -0
- lsurf/materials/implementations/standard_materials.py +126 -0
- lsurf/materials/implementations/turbulent_atmosphere.py +135 -0
- lsurf/materials/implementations/us_standard_atmosphere.py +149 -0
- lsurf/materials/utils/__init__.py +77 -0
- lsurf/materials/utils/constants.py +45 -0
- lsurf/materials/utils/device_functions.py +117 -0
- lsurf/materials/utils/dispersion.py +160 -0
- lsurf/materials/utils/factories.py +142 -0
- lsurf/propagation/__init__.py +91 -0
- lsurf/propagation/detector_gpu.py +67 -0
- lsurf/propagation/gpu_device_rays.py +294 -0
- lsurf/propagation/kernels/__init__.py +175 -0
- lsurf/propagation/kernels/absorption/__init__.py +61 -0
- lsurf/propagation/kernels/absorption/grid.py +240 -0
- lsurf/propagation/kernels/absorption/simple.py +232 -0
- lsurf/propagation/kernels/absorption/spectral.py +410 -0
- lsurf/propagation/kernels/detection/__init__.py +64 -0
- lsurf/propagation/kernels/detection/protocol.py +102 -0
- lsurf/propagation/kernels/detection/spherical.py +255 -0
- lsurf/propagation/kernels/device_functions.py +790 -0
- lsurf/propagation/kernels/fresnel/__init__.py +64 -0
- lsurf/propagation/kernels/fresnel/protocol.py +97 -0
- lsurf/propagation/kernels/fresnel/standard.py +258 -0
- lsurf/propagation/kernels/intersection/__init__.py +79 -0
- lsurf/propagation/kernels/intersection/annular_plane.py +207 -0
- lsurf/propagation/kernels/intersection/bounded_plane.py +205 -0
- lsurf/propagation/kernels/intersection/plane.py +166 -0
- lsurf/propagation/kernels/intersection/protocol.py +95 -0
- lsurf/propagation/kernels/intersection/signed_distance.py +742 -0
- lsurf/propagation/kernels/intersection/sphere.py +190 -0
- lsurf/propagation/kernels/propagation/__init__.py +85 -0
- lsurf/propagation/kernels/propagation/grid.py +527 -0
- lsurf/propagation/kernels/propagation/protocol.py +105 -0
- lsurf/propagation/kernels/propagation/simple.py +460 -0
- lsurf/propagation/kernels/propagation/spectral.py +875 -0
- lsurf/propagation/kernels/registry.py +331 -0
- lsurf/propagation/kernels/surface/__init__.py +72 -0
- lsurf/propagation/kernels/surface/bisection.py +232 -0
- lsurf/propagation/kernels/surface/detection.py +402 -0
- lsurf/propagation/kernels/surface/reduction.py +166 -0
- lsurf/propagation/propagator_protocol.py +222 -0
- lsurf/propagation/propagators/__init__.py +101 -0
- lsurf/propagation/propagators/detector_handler.py +354 -0
- lsurf/propagation/propagators/factory.py +200 -0
- lsurf/propagation/propagators/fresnel_handler.py +305 -0
- lsurf/propagation/propagators/gpu_gradient.py +566 -0
- lsurf/propagation/propagators/gpu_surface_propagator.py +707 -0
- lsurf/propagation/propagators/gradient.py +429 -0
- lsurf/propagation/propagators/intersection_handler.py +327 -0
- lsurf/propagation/propagators/material_propagator.py +398 -0
- lsurf/propagation/propagators/signed_distance_handler.py +522 -0
- lsurf/propagation/propagators/spectral_gpu_gradient.py +553 -0
- lsurf/propagation/propagators/surface_interaction.py +616 -0
- lsurf/propagation/propagators/surface_propagator.py +719 -0
- lsurf/py.typed +1 -0
- lsurf/simulation/__init__.py +70 -0
- lsurf/simulation/config.py +164 -0
- lsurf/simulation/orchestrator.py +462 -0
- lsurf/simulation/result.py +299 -0
- lsurf/simulation/simulation.py +262 -0
- lsurf/sources/__init__.py +128 -0
- lsurf/sources/base.py +264 -0
- lsurf/sources/collimated.py +252 -0
- lsurf/sources/custom.py +409 -0
- lsurf/sources/diverging.py +228 -0
- lsurf/sources/gaussian.py +272 -0
- lsurf/sources/parallel_from_positions.py +197 -0
- lsurf/sources/point.py +172 -0
- lsurf/sources/uniform_diverging.py +258 -0
- lsurf/surfaces/__init__.py +184 -0
- lsurf/surfaces/cpu/__init__.py +50 -0
- lsurf/surfaces/cpu/curved_wave.py +463 -0
- lsurf/surfaces/cpu/gerstner_wave.py +381 -0
- lsurf/surfaces/cpu/wave_params.py +118 -0
- lsurf/surfaces/gpu/__init__.py +72 -0
- lsurf/surfaces/gpu/annular_plane.py +453 -0
- lsurf/surfaces/gpu/bounded_plane.py +390 -0
- lsurf/surfaces/gpu/curved_wave.py +483 -0
- lsurf/surfaces/gpu/gerstner_wave.py +377 -0
- lsurf/surfaces/gpu/multi_curved_wave.py +520 -0
- lsurf/surfaces/gpu/plane.py +299 -0
- lsurf/surfaces/gpu/recording_sphere.py +587 -0
- lsurf/surfaces/gpu/sphere.py +311 -0
- lsurf/surfaces/protocol.py +336 -0
- lsurf/surfaces/registry.py +373 -0
- lsurf/utilities/__init__.py +175 -0
- lsurf/utilities/detector_analysis.py +814 -0
- lsurf/utilities/fresnel.py +628 -0
- lsurf/utilities/interactions.py +1215 -0
- lsurf/utilities/propagation.py +602 -0
- lsurf/utilities/ray_data.py +532 -0
- lsurf/utilities/recording_sphere.py +745 -0
- lsurf/utilities/time_spread.py +463 -0
- lsurf/visualization/__init__.py +329 -0
- lsurf/visualization/absorption_plots.py +334 -0
- lsurf/visualization/atmospheric_plots.py +754 -0
- lsurf/visualization/common.py +348 -0
- lsurf/visualization/detector_plots.py +1350 -0
- lsurf/visualization/detector_sphere_plots.py +1173 -0
- lsurf/visualization/fresnel_plots.py +1061 -0
- lsurf/visualization/ocean_simulation_plots.py +999 -0
- lsurf/visualization/polarization_plots.py +916 -0
- lsurf/visualization/raytracing_plots.py +1521 -0
- lsurf/visualization/ring_detector_plots.py +1867 -0
- lsurf/visualization/time_spread_plots.py +531 -0
- lsurf-1.0.0.dist-info/METADATA +381 -0
- lsurf-1.0.0.dist-info/RECORD +180 -0
- lsurf-1.0.0.dist-info/WHEEL +5 -0
- lsurf-1.0.0.dist-info/entry_points.txt +2 -0
- lsurf-1.0.0.dist-info/licenses/LICENSE +32 -0
- 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
|
+
}
|