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,1867 @@
|
|
|
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
|
+
Ring Detector Visualization Module.
|
|
36
|
+
|
|
37
|
+
Provides plotting functions for constant-size detector ring analysis,
|
|
38
|
+
including geometry schematics, intensity heatmaps, timing distributions,
|
|
39
|
+
and spread analysis.
|
|
40
|
+
|
|
41
|
+
Functions are organized into:
|
|
42
|
+
- Geometry plots: Ring layout, side views, 3D visualization
|
|
43
|
+
- Analysis plots: Intensity/timing heatmaps, radial profiles, distributions
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import Optional, Union
|
|
48
|
+
|
|
49
|
+
import matplotlib.pyplot as plt
|
|
50
|
+
import numpy as np
|
|
51
|
+
from matplotlib.collections import PatchCollection
|
|
52
|
+
from matplotlib.patches import Wedge
|
|
53
|
+
|
|
54
|
+
from ..detectors.constant_size_rings import ConstantSizeDetectorRings
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def plot_geometry_analysis(
|
|
58
|
+
rings: ConstantSizeDetectorRings,
|
|
59
|
+
output_path: Optional[Union[str, Path]] = None,
|
|
60
|
+
figsize: tuple = (16, 14),
|
|
61
|
+
dpi: int = 150,
|
|
62
|
+
) -> plt.Figure:
|
|
63
|
+
"""
|
|
64
|
+
Create 4-panel geometry analysis figure.
|
|
65
|
+
|
|
66
|
+
Panels:
|
|
67
|
+
(a) Full geometry schematic (Earth + detector sphere + sample rings)
|
|
68
|
+
(b) Zoomed view showing no-shadowing geometry with sight lines
|
|
69
|
+
(c) Distance vs elevation curve
|
|
70
|
+
(d) Angular width and distance vs ring index
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
rings : ConstantSizeDetectorRings
|
|
75
|
+
Detector ring configuration
|
|
76
|
+
output_path : str or Path, optional
|
|
77
|
+
If provided, save figure to this path
|
|
78
|
+
figsize : tuple
|
|
79
|
+
Figure size in inches
|
|
80
|
+
dpi : int
|
|
81
|
+
Resolution for saved figure
|
|
82
|
+
|
|
83
|
+
Returns
|
|
84
|
+
-------
|
|
85
|
+
Figure
|
|
86
|
+
Matplotlib figure object
|
|
87
|
+
"""
|
|
88
|
+
fig, axes = plt.subplots(2, 2, figsize=figsize)
|
|
89
|
+
|
|
90
|
+
earth_radius = rings.earth_radius
|
|
91
|
+
detector_sphere_radius = rings.detector_sphere_radius
|
|
92
|
+
detector_altitude = rings.detector_altitude
|
|
93
|
+
n_rings = rings.n_rings
|
|
94
|
+
ring_centers_deg = rings.ring_centers_deg
|
|
95
|
+
ring_distances = rings.ring_distances
|
|
96
|
+
ring_boundaries_deg = rings.ring_boundaries_deg
|
|
97
|
+
detector_half_width = rings.detector_half_width
|
|
98
|
+
|
|
99
|
+
# Compute max horizontal distance
|
|
100
|
+
ring_horiz_dists = rings.get_ring_horizontal_distances() / 1000 # km
|
|
101
|
+
max_horiz_dist = max(ring_horiz_dists)
|
|
102
|
+
|
|
103
|
+
# Panel (a): Full geometry schematic
|
|
104
|
+
ax_full = axes[0, 0]
|
|
105
|
+
|
|
106
|
+
theta_arc = np.linspace(-10, 100, 200)
|
|
107
|
+
earth_x = earth_radius * np.sin(np.radians(theta_arc)) / 1000
|
|
108
|
+
earth_z = earth_radius * np.cos(np.radians(theta_arc)) / 1000 - earth_radius / 1000
|
|
109
|
+
ax_full.plot(earth_x, earth_z, "b-", linewidth=2, label="Earth surface")
|
|
110
|
+
ax_full.fill_between(earth_x, earth_z, -7000, alpha=0.1, color="blue")
|
|
111
|
+
|
|
112
|
+
det_x = detector_sphere_radius * np.sin(np.radians(theta_arc)) / 1000
|
|
113
|
+
det_z = (
|
|
114
|
+
detector_sphere_radius * np.cos(np.radians(theta_arc)) / 1000
|
|
115
|
+
- earth_radius / 1000
|
|
116
|
+
)
|
|
117
|
+
ax_full.plot(
|
|
118
|
+
det_x,
|
|
119
|
+
det_z,
|
|
120
|
+
"g-",
|
|
121
|
+
linewidth=1.5,
|
|
122
|
+
label=f"Detector sphere ({detector_altitude/1000:.0f} km)",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Draw sample detector rings (every 2nd ring)
|
|
126
|
+
for i in range(0, min(n_rings, 12), 2):
|
|
127
|
+
theta_c = ring_centers_deg[i]
|
|
128
|
+
dist_c = ring_distances[i]
|
|
129
|
+
|
|
130
|
+
x_c = dist_c * np.cos(np.radians(theta_c)) / 1000
|
|
131
|
+
z_c = dist_c * np.sin(np.radians(theta_c)) / 1000
|
|
132
|
+
|
|
133
|
+
tx = -np.sin(np.radians(theta_c))
|
|
134
|
+
tz = np.cos(np.radians(theta_c))
|
|
135
|
+
|
|
136
|
+
hw_km = detector_half_width / 1000
|
|
137
|
+
x1, z1 = x_c - hw_km * tx, z_c - hw_km * tz
|
|
138
|
+
x2, z2 = x_c + hw_km * tx, z_c + hw_km * tz
|
|
139
|
+
|
|
140
|
+
color = plt.cm.viridis(i / min(n_rings, 12))
|
|
141
|
+
ax_full.plot([x1, x2], [z1, z2], "-", color=color, linewidth=3)
|
|
142
|
+
|
|
143
|
+
if i % 4 == 0:
|
|
144
|
+
ax_full.text(x_c + 5, z_c + 2, f"R{i}", fontsize=8, color=color)
|
|
145
|
+
|
|
146
|
+
# Sight lines
|
|
147
|
+
for i in range(0, min(4, len(ring_boundaries_deg)), 2):
|
|
148
|
+
elev = ring_boundaries_deg[i]
|
|
149
|
+
dist = rings.distance_at_elevation(elev)
|
|
150
|
+
x_end = dist * np.cos(np.radians(elev)) / 1000
|
|
151
|
+
z_end = dist * np.sin(np.radians(elev)) / 1000
|
|
152
|
+
ax_full.plot([0, x_end], [0, z_end], "r--", alpha=0.4, linewidth=0.8)
|
|
153
|
+
|
|
154
|
+
ax_full.scatter([0], [0], c="red", s=100, marker="*", zorder=10, label="Origin")
|
|
155
|
+
ax_full.set_xlabel("Horizontal Distance (km)", fontsize=11)
|
|
156
|
+
ax_full.set_ylabel("Altitude (km)", fontsize=11)
|
|
157
|
+
ax_full.set_title(
|
|
158
|
+
"(a) Full Geometry: Earth + Detector Sphere", fontsize=12, fontweight="bold"
|
|
159
|
+
)
|
|
160
|
+
ax_full.legend(loc="upper right", fontsize=9)
|
|
161
|
+
ax_full.set_xlim(-50, max_horiz_dist * 0.3)
|
|
162
|
+
ax_full.set_ylim(-50, 150)
|
|
163
|
+
ax_full.set_aspect("equal")
|
|
164
|
+
ax_full.grid(True, alpha=0.3)
|
|
165
|
+
|
|
166
|
+
# Panel (b): Zoomed view showing no-shadowing geometry
|
|
167
|
+
ax_zoom = axes[0, 1]
|
|
168
|
+
|
|
169
|
+
for i in range(min(5, n_rings)):
|
|
170
|
+
theta_c = ring_centers_deg[i]
|
|
171
|
+
dist_c = ring_distances[i]
|
|
172
|
+
|
|
173
|
+
x_c = dist_c * np.cos(np.radians(theta_c)) / 1000
|
|
174
|
+
z_c = dist_c * np.sin(np.radians(theta_c)) / 1000
|
|
175
|
+
tx = -np.sin(np.radians(theta_c))
|
|
176
|
+
tz = np.cos(np.radians(theta_c))
|
|
177
|
+
hw_km = detector_half_width / 1000
|
|
178
|
+
|
|
179
|
+
x1, z1 = x_c - hw_km * tx, z_c - hw_km * tz
|
|
180
|
+
x2, z2 = x_c + hw_km * tx, z_c + hw_km * tz
|
|
181
|
+
|
|
182
|
+
color = plt.cm.tab10(i)
|
|
183
|
+
ax_zoom.plot(
|
|
184
|
+
[x1, x2], [z1, z2], "-", color=color, linewidth=4, label=f"Ring {i}"
|
|
185
|
+
)
|
|
186
|
+
ax_zoom.plot([0, x1], [0, z1], "--", color=color, alpha=0.5, linewidth=1)
|
|
187
|
+
ax_zoom.plot([0, x2], [0, z2], "--", color=color, alpha=0.5, linewidth=1)
|
|
188
|
+
|
|
189
|
+
# Normal arrow (pointing toward origin)
|
|
190
|
+
arrow_len = 3
|
|
191
|
+
dist_to_origin = np.sqrt(x_c**2 + z_c**2)
|
|
192
|
+
if dist_to_origin > 0:
|
|
193
|
+
nx, nz = -x_c / dist_to_origin, -z_c / dist_to_origin
|
|
194
|
+
else:
|
|
195
|
+
nx, nz = 0, -1
|
|
196
|
+
ax_zoom.annotate(
|
|
197
|
+
"",
|
|
198
|
+
xy=(x_c + nx * arrow_len, z_c + nz * arrow_len),
|
|
199
|
+
xytext=(x_c, z_c),
|
|
200
|
+
arrowprops=dict(arrowstyle="->", color="darkgreen", lw=1.5),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
ax_zoom.scatter([0], [0], c="red", s=150, marker="*", zorder=10, label="Origin")
|
|
204
|
+
ax_zoom.set_xlabel("Horizontal Distance (km)", fontsize=11)
|
|
205
|
+
ax_zoom.set_ylabel("Altitude (km)", fontsize=11)
|
|
206
|
+
ax_zoom.set_title(
|
|
207
|
+
"(b) No-Shadowing Geometry: Adjacent Rings Touch",
|
|
208
|
+
fontsize=12,
|
|
209
|
+
fontweight="bold",
|
|
210
|
+
)
|
|
211
|
+
ax_zoom.legend(loc="upper right", fontsize=9)
|
|
212
|
+
ax_zoom.set_xlim(-5, 80)
|
|
213
|
+
ax_zoom.set_ylim(-5, 50)
|
|
214
|
+
ax_zoom.set_aspect("equal")
|
|
215
|
+
ax_zoom.grid(True, alpha=0.3)
|
|
216
|
+
|
|
217
|
+
# Panel (c): Distance vs elevation curve
|
|
218
|
+
ax_dist = axes[1, 0]
|
|
219
|
+
|
|
220
|
+
elev_range = np.linspace(90, ring_boundaries_deg[-1], 200)
|
|
221
|
+
distances = [rings.distance_at_elevation(e) / 1000 for e in elev_range]
|
|
222
|
+
|
|
223
|
+
ax_dist.plot(elev_range, distances, "b-", linewidth=2)
|
|
224
|
+
ax_dist.scatter(
|
|
225
|
+
ring_centers_deg,
|
|
226
|
+
ring_distances / 1000,
|
|
227
|
+
c="red",
|
|
228
|
+
s=30,
|
|
229
|
+
zorder=5,
|
|
230
|
+
label="Ring centers",
|
|
231
|
+
)
|
|
232
|
+
ax_dist.set_xlabel("Elevation Angle (degrees)", fontsize=11)
|
|
233
|
+
ax_dist.set_ylabel("Distance from Origin (km)", fontsize=11)
|
|
234
|
+
ax_dist.set_title(
|
|
235
|
+
"(c) Distance to Detector Sphere vs Elevation", fontsize=12, fontweight="bold"
|
|
236
|
+
)
|
|
237
|
+
ax_dist.axhline(
|
|
238
|
+
y=detector_altitude / 1000,
|
|
239
|
+
color="green",
|
|
240
|
+
linestyle="--",
|
|
241
|
+
alpha=0.7,
|
|
242
|
+
label=f"Min distance ({detector_altitude/1000:.0f} km)",
|
|
243
|
+
)
|
|
244
|
+
ax_dist.legend(fontsize=9)
|
|
245
|
+
ax_dist.grid(True, alpha=0.3)
|
|
246
|
+
ax_dist.invert_xaxis()
|
|
247
|
+
|
|
248
|
+
# Panel (d): Angular width and distance vs ring index
|
|
249
|
+
ax_ring = axes[1, 1]
|
|
250
|
+
|
|
251
|
+
angular_widths = np.array([rings.angular_width_at_ring(i) for i in range(n_rings)])
|
|
252
|
+
|
|
253
|
+
ax_ring.bar(
|
|
254
|
+
np.arange(n_rings),
|
|
255
|
+
angular_widths,
|
|
256
|
+
color="steelblue",
|
|
257
|
+
alpha=0.7,
|
|
258
|
+
label="Angular width",
|
|
259
|
+
)
|
|
260
|
+
ax_ring.set_xlabel("Ring Index", fontsize=11)
|
|
261
|
+
ax_ring.set_ylabel("Angular Width (degrees)", fontsize=11, color="steelblue")
|
|
262
|
+
ax_ring.tick_params(axis="y", labelcolor="steelblue")
|
|
263
|
+
|
|
264
|
+
ax_ring2 = ax_ring.twinx()
|
|
265
|
+
ax_ring2.plot(
|
|
266
|
+
np.arange(n_rings), ring_distances / 1000, "r-o", markersize=4, label="Distance"
|
|
267
|
+
)
|
|
268
|
+
ax_ring2.set_ylabel("Distance from Origin (km)", fontsize=11, color="red")
|
|
269
|
+
ax_ring2.tick_params(axis="y", labelcolor="red")
|
|
270
|
+
|
|
271
|
+
ax_ring.set_title(
|
|
272
|
+
f"(d) Ring Properties ({n_rings} rings, {rings.detector_radial_size/1000:.0f} km each)",
|
|
273
|
+
fontsize=12,
|
|
274
|
+
fontweight="bold",
|
|
275
|
+
)
|
|
276
|
+
ax_ring.grid(True, alpha=0.3)
|
|
277
|
+
|
|
278
|
+
lines1, labels1 = ax_ring.get_legend_handles_labels()
|
|
279
|
+
lines2, labels2 = ax_ring2.get_legend_handles_labels()
|
|
280
|
+
ax_ring.legend(lines1 + lines2, labels1 + labels2, loc="upper right", fontsize=9)
|
|
281
|
+
|
|
282
|
+
fig.suptitle(
|
|
283
|
+
f"Constant-Size Detector Ring Geometry Analysis\n"
|
|
284
|
+
f"({rings.detector_radial_size/1000:.0f} km detectors, {detector_altitude/1000:.0f} km altitude, "
|
|
285
|
+
f"{n_rings} rings from {ring_boundaries_deg[0]:.1f}\u00b0 to {ring_boundaries_deg[-1]:.1f}\u00b0)",
|
|
286
|
+
fontsize=13,
|
|
287
|
+
fontweight="bold",
|
|
288
|
+
)
|
|
289
|
+
fig.tight_layout()
|
|
290
|
+
|
|
291
|
+
if output_path:
|
|
292
|
+
fig.savefig(output_path, dpi=dpi, bbox_inches="tight")
|
|
293
|
+
|
|
294
|
+
return fig
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def plot_ring_side_view(
|
|
298
|
+
rings: ConstantSizeDetectorRings,
|
|
299
|
+
output_path: Optional[Union[str, Path]] = None,
|
|
300
|
+
figsize: tuple = (16, 8),
|
|
301
|
+
dpi: int = 150,
|
|
302
|
+
) -> plt.Figure:
|
|
303
|
+
"""
|
|
304
|
+
Create side view cross-section of detector rings.
|
|
305
|
+
|
|
306
|
+
Shows all rings with constant physical size and normals pointing toward origin.
|
|
307
|
+
|
|
308
|
+
Parameters
|
|
309
|
+
----------
|
|
310
|
+
rings : ConstantSizeDetectorRings
|
|
311
|
+
Detector ring configuration
|
|
312
|
+
output_path : str or Path, optional
|
|
313
|
+
If provided, save figure to this path
|
|
314
|
+
figsize : tuple
|
|
315
|
+
Figure size in inches
|
|
316
|
+
dpi : int
|
|
317
|
+
Resolution for saved figure
|
|
318
|
+
|
|
319
|
+
Returns
|
|
320
|
+
-------
|
|
321
|
+
Figure
|
|
322
|
+
Matplotlib figure object
|
|
323
|
+
"""
|
|
324
|
+
fig, (ax_side1, ax_side2) = plt.subplots(1, 2, figsize=figsize)
|
|
325
|
+
|
|
326
|
+
n_rings = rings.n_rings
|
|
327
|
+
ring_centers_deg = rings.ring_centers_deg
|
|
328
|
+
ring_distances = rings.ring_distances
|
|
329
|
+
ring_boundaries_deg = rings.ring_boundaries_deg
|
|
330
|
+
detector_half_width = rings.detector_half_width
|
|
331
|
+
|
|
332
|
+
for ax_side in [ax_side1, ax_side2]:
|
|
333
|
+
for ring_idx in range(n_rings):
|
|
334
|
+
theta_c = ring_centers_deg[ring_idx]
|
|
335
|
+
dist_c = ring_distances[ring_idx]
|
|
336
|
+
|
|
337
|
+
x_center = dist_c * np.cos(np.radians(theta_c)) / 1000
|
|
338
|
+
z_center = dist_c * np.sin(np.radians(theta_c)) / 1000
|
|
339
|
+
|
|
340
|
+
dist_to_origin = np.sqrt(x_center**2 + z_center**2)
|
|
341
|
+
if dist_to_origin > 0:
|
|
342
|
+
nx, nz = -x_center / dist_to_origin, -z_center / dist_to_origin
|
|
343
|
+
else:
|
|
344
|
+
nx, nz = 0, -1
|
|
345
|
+
|
|
346
|
+
tx, tz = -nz, nx
|
|
347
|
+
hw_km = detector_half_width / 1000
|
|
348
|
+
|
|
349
|
+
p1x = x_center - hw_km * tx
|
|
350
|
+
p1z = z_center - hw_km * tz
|
|
351
|
+
p2x = x_center + hw_km * tx
|
|
352
|
+
p2z = z_center + hw_km * tz
|
|
353
|
+
ax_side.plot([p1x, p2x], [p1z, p2z], "b-", linewidth=2)
|
|
354
|
+
|
|
355
|
+
line_freq = max(1, n_rings // 10)
|
|
356
|
+
if ring_idx % line_freq == 0:
|
|
357
|
+
ax_side.plot(
|
|
358
|
+
[0, x_center], [0, z_center], "g--", linewidth=0.5, alpha=0.5
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
arrow_freq = max(1, n_rings // 5)
|
|
362
|
+
if ring_idx % arrow_freq == arrow_freq // 2:
|
|
363
|
+
arrow_len = 3
|
|
364
|
+
ax_side.annotate(
|
|
365
|
+
"",
|
|
366
|
+
xy=(x_center + nx * arrow_len, z_center + nz * arrow_len),
|
|
367
|
+
xytext=(x_center, z_center),
|
|
368
|
+
arrowprops=dict(arrowstyle="->", color="darkgreen", lw=1.5),
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
# Draw detector sphere arc for reference
|
|
372
|
+
arc_elev = np.linspace(
|
|
373
|
+
ring_boundaries_deg[-1] - 5, ring_boundaries_deg[0] + 5, 100
|
|
374
|
+
)
|
|
375
|
+
arc_x_plot = [
|
|
376
|
+
rings.distance_at_elevation(e) * np.cos(np.radians(e)) / 1000
|
|
377
|
+
for e in arc_elev
|
|
378
|
+
]
|
|
379
|
+
arc_z_plot = [
|
|
380
|
+
rings.distance_at_elevation(e) * np.sin(np.radians(e)) / 1000
|
|
381
|
+
for e in arc_elev
|
|
382
|
+
]
|
|
383
|
+
ax_side.plot(
|
|
384
|
+
arc_x_plot,
|
|
385
|
+
arc_z_plot,
|
|
386
|
+
"c-",
|
|
387
|
+
linewidth=0.5,
|
|
388
|
+
alpha=0.3,
|
|
389
|
+
label="Detector sphere",
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
ax_side.axhline(y=0, color="k", linestyle="-", linewidth=0.5)
|
|
393
|
+
ax_side.scatter([0], [0], c="red", s=80, zorder=10, label="Origin (0,0)")
|
|
394
|
+
ax_side.set_xlabel("Horizontal Distance (km)", fontsize=11)
|
|
395
|
+
ax_side.set_ylabel("Altitude (km)", fontsize=11)
|
|
396
|
+
ax_side.grid(True, alpha=0.3)
|
|
397
|
+
ax_side.legend(loc="upper right")
|
|
398
|
+
|
|
399
|
+
ax_side1.set_title(f"Full View: All {n_rings} Rings", fontsize=12)
|
|
400
|
+
ax_side2.set_title("Equal Aspect Ratio (First 10 Rings)", fontsize=12)
|
|
401
|
+
ax_side2.set_aspect("equal")
|
|
402
|
+
|
|
403
|
+
zoom_rings = min(10, n_rings)
|
|
404
|
+
max_x_zoom = max(
|
|
405
|
+
rings.distance_at_elevation(ring_boundaries_deg[zoom_rings])
|
|
406
|
+
* np.cos(np.radians(ring_boundaries_deg[zoom_rings]))
|
|
407
|
+
/ 1000,
|
|
408
|
+
100,
|
|
409
|
+
)
|
|
410
|
+
max_z_zoom = max(
|
|
411
|
+
rings.distance_at_elevation(ring_centers_deg[0])
|
|
412
|
+
* np.sin(np.radians(ring_centers_deg[0]))
|
|
413
|
+
/ 1000,
|
|
414
|
+
50,
|
|
415
|
+
)
|
|
416
|
+
ax_side2.set_xlim(-5, max_x_zoom * 1.1)
|
|
417
|
+
ax_side2.set_ylim(-5, max_z_zoom * 1.2)
|
|
418
|
+
|
|
419
|
+
fig.suptitle(
|
|
420
|
+
f"Vertical Cross-Section: Constant {rings.detector_radial_size/1000:.0f} km Detectors (No Shadowing)",
|
|
421
|
+
fontsize=13,
|
|
422
|
+
fontweight="bold",
|
|
423
|
+
)
|
|
424
|
+
fig.tight_layout()
|
|
425
|
+
|
|
426
|
+
if output_path:
|
|
427
|
+
fig.savefig(output_path, dpi=dpi, bbox_inches="tight")
|
|
428
|
+
|
|
429
|
+
return fig
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def plot_ring_overview(
|
|
433
|
+
rings: ConstantSizeDetectorRings,
|
|
434
|
+
ring_intensities: np.ndarray,
|
|
435
|
+
detected_positions: Optional[np.ndarray] = None,
|
|
436
|
+
output_path: Optional[Union[str, Path]] = None,
|
|
437
|
+
figsize: tuple = (10, 10),
|
|
438
|
+
dpi: int = 150,
|
|
439
|
+
) -> plt.Figure:
|
|
440
|
+
"""
|
|
441
|
+
Create top-down ring overview with intensity coloring.
|
|
442
|
+
|
|
443
|
+
Parameters
|
|
444
|
+
----------
|
|
445
|
+
rings : ConstantSizeDetectorRings
|
|
446
|
+
Detector ring configuration
|
|
447
|
+
ring_intensities : ndarray
|
|
448
|
+
Total intensity per ring (shape: n_rings)
|
|
449
|
+
detected_positions : ndarray, optional
|
|
450
|
+
Ray detection positions (N, 3) for scatter overlay
|
|
451
|
+
output_path : str or Path, optional
|
|
452
|
+
If provided, save figure to this path
|
|
453
|
+
figsize : tuple
|
|
454
|
+
Figure size in inches
|
|
455
|
+
dpi : int
|
|
456
|
+
Resolution for saved figure
|
|
457
|
+
|
|
458
|
+
Returns
|
|
459
|
+
-------
|
|
460
|
+
Figure
|
|
461
|
+
Matplotlib figure object
|
|
462
|
+
"""
|
|
463
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
464
|
+
|
|
465
|
+
ring_horiz_dists = rings.get_ring_horizontal_distances() / 1000 # km
|
|
466
|
+
max_horiz_dist = max(ring_horiz_dists)
|
|
467
|
+
n_rings = rings.n_rings
|
|
468
|
+
|
|
469
|
+
ring_patches = []
|
|
470
|
+
ring_colors = []
|
|
471
|
+
|
|
472
|
+
for ring_idx in range(n_rings):
|
|
473
|
+
inner_r = ring_horiz_dists[ring_idx]
|
|
474
|
+
outer_r = ring_horiz_dists[ring_idx + 1]
|
|
475
|
+
wedge = Wedge((0, 0), outer_r, 0, 360, width=(outer_r - inner_r))
|
|
476
|
+
ring_patches.append(wedge)
|
|
477
|
+
ring_colors.append(
|
|
478
|
+
ring_intensities[ring_idx] if ring_idx < len(ring_intensities) else 0
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
collection = PatchCollection(ring_patches, cmap="hot", alpha=0.8)
|
|
482
|
+
collection.set_array(np.array(ring_colors))
|
|
483
|
+
collection.set_edgecolor("gray")
|
|
484
|
+
collection.set_linewidth(0.5)
|
|
485
|
+
ax.add_collection(collection)
|
|
486
|
+
plt.colorbar(collection, ax=ax, label="Total Intensity per Ring")
|
|
487
|
+
|
|
488
|
+
ax.scatter(
|
|
489
|
+
0, 0, c="blue", s=100, marker="x", linewidths=2, label="Zenith", zorder=10
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
if detected_positions is not None:
|
|
493
|
+
ax.scatter(
|
|
494
|
+
detected_positions[:, 0] / 1000,
|
|
495
|
+
detected_positions[:, 1] / 1000,
|
|
496
|
+
c="cyan",
|
|
497
|
+
s=1,
|
|
498
|
+
alpha=0.3,
|
|
499
|
+
label=f"Detected rays ({len(detected_positions)})",
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
max_plot_radius = max_horiz_dist * 1.1
|
|
503
|
+
ax.set_xlim(-max_plot_radius, max_plot_radius)
|
|
504
|
+
ax.set_ylim(-max_plot_radius, max_plot_radius)
|
|
505
|
+
ax.set_xlabel("X (km)", fontsize=11)
|
|
506
|
+
ax.set_ylabel("Y (km)", fontsize=11)
|
|
507
|
+
ax.set_title(
|
|
508
|
+
f"Elevation Ring Overview\n(Constant {rings.detector_radial_size/1000:.0f} km detector size, {n_rings} rings)",
|
|
509
|
+
fontsize=12,
|
|
510
|
+
fontweight="bold",
|
|
511
|
+
)
|
|
512
|
+
ax.set_aspect("equal")
|
|
513
|
+
ax.legend(loc="upper right")
|
|
514
|
+
ax.grid(True, alpha=0.3)
|
|
515
|
+
|
|
516
|
+
if output_path:
|
|
517
|
+
fig.savefig(output_path, dpi=dpi, bbox_inches="tight")
|
|
518
|
+
|
|
519
|
+
return fig
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def plot_ring_azimuth_heatmap(
|
|
523
|
+
rings: ConstantSizeDetectorRings,
|
|
524
|
+
intensity_map: np.ndarray,
|
|
525
|
+
ray_count_map: np.ndarray,
|
|
526
|
+
az_min: float = -10.0,
|
|
527
|
+
az_max: float = 10.0,
|
|
528
|
+
output_path: Optional[Union[str, Path]] = None,
|
|
529
|
+
figsize: tuple = (14, 6),
|
|
530
|
+
dpi: int = 150,
|
|
531
|
+
) -> plt.Figure:
|
|
532
|
+
"""
|
|
533
|
+
Create ring-azimuth heatmaps for intensity and ray count.
|
|
534
|
+
|
|
535
|
+
Parameters
|
|
536
|
+
----------
|
|
537
|
+
rings : ConstantSizeDetectorRings
|
|
538
|
+
Detector ring configuration
|
|
539
|
+
intensity_map : ndarray
|
|
540
|
+
Intensity per (ring, azimuth_bin), shape (n_rings, n_az_bins)
|
|
541
|
+
ray_count_map : ndarray
|
|
542
|
+
Ray count per (ring, azimuth_bin), shape (n_rings, n_az_bins)
|
|
543
|
+
az_min, az_max : float
|
|
544
|
+
Azimuth range in degrees
|
|
545
|
+
output_path : str or Path, optional
|
|
546
|
+
If provided, save figure to this path
|
|
547
|
+
figsize : tuple
|
|
548
|
+
Figure size in inches
|
|
549
|
+
dpi : int
|
|
550
|
+
Resolution for saved figure
|
|
551
|
+
|
|
552
|
+
Returns
|
|
553
|
+
-------
|
|
554
|
+
Figure
|
|
555
|
+
Matplotlib figure object
|
|
556
|
+
"""
|
|
557
|
+
fig, (ax_int, ax_count) = plt.subplots(1, 2, figsize=figsize)
|
|
558
|
+
|
|
559
|
+
n_rings = rings.n_rings
|
|
560
|
+
ring_centers_deg = rings.ring_centers_deg
|
|
561
|
+
|
|
562
|
+
def ring_to_elev(ring_idx):
|
|
563
|
+
idx = np.clip(np.asarray(ring_idx), 0, n_rings - 1).astype(int)
|
|
564
|
+
return ring_centers_deg[idx]
|
|
565
|
+
|
|
566
|
+
def elev_to_ring(elev):
|
|
567
|
+
return np.interp(elev, ring_centers_deg[::-1], np.arange(n_rings)[::-1])
|
|
568
|
+
|
|
569
|
+
# Intensity heatmap
|
|
570
|
+
im1 = ax_int.imshow(
|
|
571
|
+
intensity_map,
|
|
572
|
+
aspect="auto",
|
|
573
|
+
origin="lower",
|
|
574
|
+
cmap="hot",
|
|
575
|
+
extent=[az_min, az_max, 0, n_rings],
|
|
576
|
+
)
|
|
577
|
+
ax_int.axvline(x=0, color="cyan", linestyle="--", linewidth=1, alpha=0.7)
|
|
578
|
+
ax_int.set_xlabel("Azimuth (deg) - 0\u00b0 = beam direction", fontsize=11)
|
|
579
|
+
ax_int.set_ylabel("Ring Index", fontsize=11)
|
|
580
|
+
ax_int.set_title("Intensity by Ring and Azimuth", fontsize=12, fontweight="bold")
|
|
581
|
+
plt.colorbar(im1, ax=ax_int, label="Total Intensity")
|
|
582
|
+
|
|
583
|
+
ax_int_elev = ax_int.secondary_yaxis(
|
|
584
|
+
"right", functions=(ring_to_elev, elev_to_ring)
|
|
585
|
+
)
|
|
586
|
+
ax_int_elev.set_ylabel("Elevation (deg)", fontsize=11)
|
|
587
|
+
|
|
588
|
+
# Ray count heatmap
|
|
589
|
+
im2 = ax_count.imshow(
|
|
590
|
+
ray_count_map,
|
|
591
|
+
aspect="auto",
|
|
592
|
+
origin="lower",
|
|
593
|
+
cmap="viridis",
|
|
594
|
+
extent=[az_min, az_max, 0, n_rings],
|
|
595
|
+
)
|
|
596
|
+
ax_count.axvline(x=0, color="red", linestyle="--", linewidth=1, alpha=0.7)
|
|
597
|
+
ax_count.set_xlabel("Azimuth (deg) - 0\u00b0 = beam direction", fontsize=11)
|
|
598
|
+
ax_count.set_ylabel("Ring Index", fontsize=11)
|
|
599
|
+
ax_count.set_title("Ray Count by Ring and Azimuth", fontsize=12, fontweight="bold")
|
|
600
|
+
plt.colorbar(im2, ax=ax_count, label="Ray Count")
|
|
601
|
+
|
|
602
|
+
ax_count_elev = ax_count.secondary_yaxis(
|
|
603
|
+
"right", functions=(ring_to_elev, elev_to_ring)
|
|
604
|
+
)
|
|
605
|
+
ax_count_elev.set_ylabel("Elevation (deg)", fontsize=11)
|
|
606
|
+
|
|
607
|
+
fig.suptitle(
|
|
608
|
+
f"Ring-Azimuth Distribution (\u00b1{int(az_max)}\u00b0 around beam direction)",
|
|
609
|
+
fontsize=14,
|
|
610
|
+
fontweight="bold",
|
|
611
|
+
)
|
|
612
|
+
fig.tight_layout()
|
|
613
|
+
|
|
614
|
+
if output_path:
|
|
615
|
+
fig.savefig(output_path, dpi=dpi, bbox_inches="tight")
|
|
616
|
+
|
|
617
|
+
return fig
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def plot_timing_heatmap(
|
|
621
|
+
rings: ConstantSizeDetectorRings,
|
|
622
|
+
first_arrival_map: np.ndarray,
|
|
623
|
+
time_spread_map: np.ndarray,
|
|
624
|
+
global_first_ns: float,
|
|
625
|
+
az_min: float = -10.0,
|
|
626
|
+
az_max: float = 10.0,
|
|
627
|
+
output_path: Optional[Union[str, Path]] = None,
|
|
628
|
+
figsize: tuple = (14, 6),
|
|
629
|
+
dpi: int = 150,
|
|
630
|
+
) -> plt.Figure:
|
|
631
|
+
"""
|
|
632
|
+
Create timing heatmaps (first arrival and time spread).
|
|
633
|
+
|
|
634
|
+
Parameters
|
|
635
|
+
----------
|
|
636
|
+
rings : ConstantSizeDetectorRings
|
|
637
|
+
Detector ring configuration
|
|
638
|
+
first_arrival_map : ndarray
|
|
639
|
+
First arrival time per bin in ns, shape (n_rings, n_az_bins)
|
|
640
|
+
time_spread_map : ndarray
|
|
641
|
+
Time spread (10-90%) per bin in ns, shape (n_rings, n_az_bins)
|
|
642
|
+
global_first_ns : float
|
|
643
|
+
Global first arrival time in ns (for relative timing)
|
|
644
|
+
az_min, az_max : float
|
|
645
|
+
Azimuth range in degrees
|
|
646
|
+
output_path : str or Path, optional
|
|
647
|
+
If provided, save figure to this path
|
|
648
|
+
figsize : tuple
|
|
649
|
+
Figure size in inches
|
|
650
|
+
dpi : int
|
|
651
|
+
Resolution for saved figure
|
|
652
|
+
|
|
653
|
+
Returns
|
|
654
|
+
-------
|
|
655
|
+
Figure
|
|
656
|
+
Matplotlib figure object
|
|
657
|
+
"""
|
|
658
|
+
fig, (ax_first, ax_spread) = plt.subplots(1, 2, figsize=figsize)
|
|
659
|
+
|
|
660
|
+
n_rings = rings.n_rings
|
|
661
|
+
ring_centers_deg = rings.ring_centers_deg
|
|
662
|
+
|
|
663
|
+
def ring_to_elev(ring_idx):
|
|
664
|
+
idx = np.clip(np.asarray(ring_idx), 0, n_rings - 1).astype(int)
|
|
665
|
+
return ring_centers_deg[idx]
|
|
666
|
+
|
|
667
|
+
def elev_to_ring(elev):
|
|
668
|
+
return np.interp(elev, ring_centers_deg[::-1], np.arange(n_rings)[::-1])
|
|
669
|
+
|
|
670
|
+
# First arrival time (relative to global first)
|
|
671
|
+
first_arrival_rel = first_arrival_map - global_first_ns
|
|
672
|
+
|
|
673
|
+
im1 = ax_first.imshow(
|
|
674
|
+
first_arrival_rel,
|
|
675
|
+
aspect="auto",
|
|
676
|
+
origin="lower",
|
|
677
|
+
cmap="plasma",
|
|
678
|
+
extent=[az_min, az_max, 0, n_rings],
|
|
679
|
+
)
|
|
680
|
+
ax_first.axvline(x=0, color="cyan", linestyle="--", linewidth=1, alpha=0.7)
|
|
681
|
+
ax_first.set_xlabel("Azimuth (deg) - 0\u00b0 = beam direction", fontsize=11)
|
|
682
|
+
ax_first.set_ylabel("Ring Index", fontsize=11)
|
|
683
|
+
ax_first.set_title("First Arrival Time per Bin", fontsize=12, fontweight="bold")
|
|
684
|
+
plt.colorbar(im1, ax=ax_first, label="First Arrival (ns from global first)")
|
|
685
|
+
|
|
686
|
+
ax_first_elev = ax_first.secondary_yaxis(
|
|
687
|
+
"right", functions=(ring_to_elev, elev_to_ring)
|
|
688
|
+
)
|
|
689
|
+
ax_first_elev.set_ylabel("Elevation (deg)", fontsize=11)
|
|
690
|
+
|
|
691
|
+
# Time spread
|
|
692
|
+
im2 = ax_spread.imshow(
|
|
693
|
+
time_spread_map,
|
|
694
|
+
aspect="auto",
|
|
695
|
+
origin="lower",
|
|
696
|
+
cmap="viridis",
|
|
697
|
+
extent=[az_min, az_max, 0, n_rings],
|
|
698
|
+
)
|
|
699
|
+
ax_spread.axvline(x=0, color="red", linestyle="--", linewidth=1, alpha=0.7)
|
|
700
|
+
ax_spread.set_xlabel("Azimuth (deg) - 0\u00b0 = beam direction", fontsize=11)
|
|
701
|
+
ax_spread.set_ylabel("Ring Index", fontsize=11)
|
|
702
|
+
ax_spread.set_title(
|
|
703
|
+
"Time Spread (10-90%) by Ring and Azimuth", fontsize=12, fontweight="bold"
|
|
704
|
+
)
|
|
705
|
+
plt.colorbar(im2, ax=ax_spread, label="Time Spread (ns)")
|
|
706
|
+
|
|
707
|
+
ax_spread_elev = ax_spread.secondary_yaxis(
|
|
708
|
+
"right", functions=(ring_to_elev, elev_to_ring)
|
|
709
|
+
)
|
|
710
|
+
ax_spread_elev.set_ylabel("Elevation (deg)", fontsize=11)
|
|
711
|
+
|
|
712
|
+
fig.suptitle(
|
|
713
|
+
f"Timing Distribution (\u00b1{int(az_max)}\u00b0 around beam direction)",
|
|
714
|
+
fontsize=14,
|
|
715
|
+
fontweight="bold",
|
|
716
|
+
)
|
|
717
|
+
fig.tight_layout()
|
|
718
|
+
|
|
719
|
+
if output_path:
|
|
720
|
+
fig.savefig(output_path, dpi=dpi, bbox_inches="tight")
|
|
721
|
+
|
|
722
|
+
return fig
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def plot_per_ring_timing_distributions(
|
|
726
|
+
rings: ConstantSizeDetectorRings,
|
|
727
|
+
ring_data: list,
|
|
728
|
+
az_limit: float = 10.0,
|
|
729
|
+
output_path: Optional[Union[str, Path]] = None,
|
|
730
|
+
figsize_per_col: float = 4.0,
|
|
731
|
+
figsize_per_row: float = 3.5,
|
|
732
|
+
dpi: int = 150,
|
|
733
|
+
) -> Optional[plt.Figure]:
|
|
734
|
+
"""
|
|
735
|
+
Create per-ring timing distribution histograms.
|
|
736
|
+
|
|
737
|
+
Parameters
|
|
738
|
+
----------
|
|
739
|
+
rings : ConstantSizeDetectorRings
|
|
740
|
+
Detector ring configuration
|
|
741
|
+
ring_data : list of dict
|
|
742
|
+
List of dicts with keys:
|
|
743
|
+
- ring_idx: int
|
|
744
|
+
- az_center: float (azimuth center in degrees)
|
|
745
|
+
- times_rel_ns: ndarray (times relative to bin first arrival, ns)
|
|
746
|
+
- intensities: ndarray (ray intensities)
|
|
747
|
+
- dist_km: float
|
|
748
|
+
az_limit : float
|
|
749
|
+
Azimuth limit for title
|
|
750
|
+
output_path : str or Path, optional
|
|
751
|
+
If provided, save figure to this path
|
|
752
|
+
figsize_per_col, figsize_per_row : float
|
|
753
|
+
Figure size multipliers
|
|
754
|
+
dpi : int
|
|
755
|
+
Resolution for saved figure
|
|
756
|
+
|
|
757
|
+
Returns
|
|
758
|
+
-------
|
|
759
|
+
Figure or None
|
|
760
|
+
Matplotlib figure object, or None if no data
|
|
761
|
+
"""
|
|
762
|
+
if not ring_data:
|
|
763
|
+
return None
|
|
764
|
+
|
|
765
|
+
n_plots = len(ring_data)
|
|
766
|
+
n_cols = min(4, n_plots)
|
|
767
|
+
n_rows = (n_plots + n_cols - 1) // n_cols
|
|
768
|
+
fig, axes = plt.subplots(
|
|
769
|
+
n_rows, n_cols, figsize=(figsize_per_col * n_cols, figsize_per_row * n_rows)
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
if n_plots == 1:
|
|
773
|
+
axes = np.array([[axes]])
|
|
774
|
+
elif n_rows == 1:
|
|
775
|
+
axes = axes.reshape(1, -1)
|
|
776
|
+
|
|
777
|
+
for i, info in enumerate(ring_data):
|
|
778
|
+
row, col = divmod(i, n_cols)
|
|
779
|
+
ax = axes[row, col]
|
|
780
|
+
|
|
781
|
+
times_rel = info["times_rel_ns"]
|
|
782
|
+
intensities = info["intensities"]
|
|
783
|
+
n_rays = len(times_rel)
|
|
784
|
+
|
|
785
|
+
if n_rays > 0 and np.sum(intensities) > 0:
|
|
786
|
+
counts, edges = np.histogram(times_rel, bins=30, weights=intensities)
|
|
787
|
+
centers = (edges[:-1] + edges[1:]) / 2
|
|
788
|
+
|
|
789
|
+
ax.fill_between(centers, counts, alpha=0.6, color="steelblue", step="mid")
|
|
790
|
+
ax.step(centers, counts, where="mid", color="steelblue", linewidth=1.5)
|
|
791
|
+
|
|
792
|
+
# Compute percentiles
|
|
793
|
+
sorted_idx = np.argsort(times_rel)
|
|
794
|
+
sorted_t = times_rel[sorted_idx]
|
|
795
|
+
sorted_w = intensities[sorted_idx]
|
|
796
|
+
cumsum = np.cumsum(sorted_w)
|
|
797
|
+
cumsum_norm = cumsum / cumsum[-1]
|
|
798
|
+
t10 = sorted_t[np.searchsorted(cumsum_norm, 0.10)]
|
|
799
|
+
t90 = sorted_t[np.searchsorted(cumsum_norm, 0.90)]
|
|
800
|
+
spread = t90 - t10
|
|
801
|
+
|
|
802
|
+
ax.axvline(t10, color="red", linestyle="--", linewidth=1.5, alpha=0.8)
|
|
803
|
+
ax.axvline(t90, color="red", linestyle="--", linewidth=1.5, alpha=0.8)
|
|
804
|
+
ax.axvspan(t10, t90, alpha=0.15, color="red")
|
|
805
|
+
|
|
806
|
+
ax.set_title(
|
|
807
|
+
f'Ring {info["ring_idx"]}, az={info["az_center"]:.1f}\u00b0\n'
|
|
808
|
+
f'd={info["dist_km"]:.0f}km, n={n_rays}, \u0394t={spread:.1f}ns',
|
|
809
|
+
fontsize=10,
|
|
810
|
+
)
|
|
811
|
+
else:
|
|
812
|
+
ax.set_title(f'Ring {info["ring_idx"]} (no data)', fontsize=10)
|
|
813
|
+
|
|
814
|
+
ax.set_xlabel("Time (ns)", fontsize=9)
|
|
815
|
+
ax.set_ylabel("Intensity", fontsize=9)
|
|
816
|
+
ax.grid(True, alpha=0.3)
|
|
817
|
+
ax.set_xlim(left=0)
|
|
818
|
+
|
|
819
|
+
# Hide unused axes
|
|
820
|
+
for i in range(n_plots, n_rows * n_cols):
|
|
821
|
+
row, col = divmod(i, n_cols)
|
|
822
|
+
axes[row, col].axis("off")
|
|
823
|
+
|
|
824
|
+
fig.suptitle(
|
|
825
|
+
f"Per-Ring Timing Distributions (best azimuth bin, \u00b1{az_limit:.0f}\u00b0)",
|
|
826
|
+
fontsize=14,
|
|
827
|
+
fontweight="bold",
|
|
828
|
+
)
|
|
829
|
+
fig.tight_layout()
|
|
830
|
+
|
|
831
|
+
if output_path:
|
|
832
|
+
fig.savefig(output_path, dpi=dpi, bbox_inches="tight")
|
|
833
|
+
|
|
834
|
+
return fig
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def plot_spread_analysis(
|
|
838
|
+
rings: ConstantSizeDetectorRings,
|
|
839
|
+
ring_stats: list,
|
|
840
|
+
output_path: Optional[Union[str, Path]] = None,
|
|
841
|
+
figsize: tuple = (14, 5),
|
|
842
|
+
dpi: int = 150,
|
|
843
|
+
) -> Optional[plt.Figure]:
|
|
844
|
+
"""
|
|
845
|
+
Create per-ring spread analysis (azimuthal and timing).
|
|
846
|
+
|
|
847
|
+
Parameters
|
|
848
|
+
----------
|
|
849
|
+
rings : ConstantSizeDetectorRings
|
|
850
|
+
Detector ring configuration
|
|
851
|
+
ring_stats : list of dict
|
|
852
|
+
List of dicts with keys:
|
|
853
|
+
- ring_idx: int
|
|
854
|
+
- n_rays: int
|
|
855
|
+
- az_std: float (azimuth std in degrees)
|
|
856
|
+
- time_spread_ns: float
|
|
857
|
+
- dist_km: float
|
|
858
|
+
output_path : str or Path, optional
|
|
859
|
+
If provided, save figure to this path
|
|
860
|
+
figsize : tuple
|
|
861
|
+
Figure size in inches
|
|
862
|
+
dpi : int
|
|
863
|
+
Resolution for saved figure
|
|
864
|
+
|
|
865
|
+
Returns
|
|
866
|
+
-------
|
|
867
|
+
Figure or None
|
|
868
|
+
Matplotlib figure object, or None if no data
|
|
869
|
+
"""
|
|
870
|
+
if not ring_stats:
|
|
871
|
+
return None
|
|
872
|
+
|
|
873
|
+
fig, (ax_az, ax_time) = plt.subplots(1, 2, figsize=figsize)
|
|
874
|
+
|
|
875
|
+
ring_indices = np.array([s["ring_idx"] for s in ring_stats])
|
|
876
|
+
az_spreads = [s["az_std"] for s in ring_stats]
|
|
877
|
+
time_spreads = [s["time_spread_ns"] for s in ring_stats]
|
|
878
|
+
ray_counts = [s["n_rays"] for s in ring_stats]
|
|
879
|
+
|
|
880
|
+
colors = np.log10(np.array(ray_counts) + 1)
|
|
881
|
+
|
|
882
|
+
# Azimuthal spread vs ring index
|
|
883
|
+
sc1 = ax_az.scatter(
|
|
884
|
+
ring_indices, az_spreads, c=colors, cmap="viridis", s=80, edgecolors="black"
|
|
885
|
+
)
|
|
886
|
+
ax_az.plot(ring_indices, az_spreads, "b-", alpha=0.3)
|
|
887
|
+
ax_az.set_xlabel("Ring Index", fontsize=11)
|
|
888
|
+
ax_az.set_ylabel("Azimuthal Spread (std, degrees)", fontsize=11)
|
|
889
|
+
ax_az.set_title("Azimuthal Spread vs Ring", fontsize=12, fontweight="bold")
|
|
890
|
+
ax_az.grid(True, alpha=0.3)
|
|
891
|
+
cbar1 = plt.colorbar(sc1, ax=ax_az)
|
|
892
|
+
cbar1.set_label("log\u2081\u2080(ray count)")
|
|
893
|
+
|
|
894
|
+
# Add elevation axis on top for ax_az
|
|
895
|
+
ax_az_top = ax_az.twiny()
|
|
896
|
+
ax_az_top.set_xlim(ax_az.get_xlim())
|
|
897
|
+
tick_indices = ring_indices[:: max(1, len(ring_indices) // 5)]
|
|
898
|
+
tick_elevs = [
|
|
899
|
+
rings.ring_centers_deg[int(i)] for i in tick_indices if i < rings.n_rings
|
|
900
|
+
]
|
|
901
|
+
ax_az_top.set_xticks(tick_indices[: len(tick_elevs)])
|
|
902
|
+
ax_az_top.set_xticklabels([f"{e:.1f}°" for e in tick_elevs])
|
|
903
|
+
ax_az_top.set_xlabel("Elevation", fontsize=10)
|
|
904
|
+
|
|
905
|
+
# Time spread vs ring index
|
|
906
|
+
sc2 = ax_time.scatter(
|
|
907
|
+
ring_indices, time_spreads, c=colors, cmap="viridis", s=80, edgecolors="black"
|
|
908
|
+
)
|
|
909
|
+
ax_time.plot(ring_indices, time_spreads, "r-", alpha=0.3)
|
|
910
|
+
ax_time.set_xlabel("Ring Index", fontsize=11)
|
|
911
|
+
ax_time.set_ylabel("Time Spread (10-90%, ns)", fontsize=11)
|
|
912
|
+
ax_time.set_title(
|
|
913
|
+
"Per-Bin Time Spread vs Ring (median over azimuth)",
|
|
914
|
+
fontsize=12,
|
|
915
|
+
fontweight="bold",
|
|
916
|
+
)
|
|
917
|
+
ax_time.grid(True, alpha=0.3)
|
|
918
|
+
cbar2 = plt.colorbar(sc2, ax=ax_time)
|
|
919
|
+
cbar2.set_label("log\u2081\u2080(ray count)")
|
|
920
|
+
|
|
921
|
+
# Add elevation axis on top for ax_time
|
|
922
|
+
ax_time_top = ax_time.twiny()
|
|
923
|
+
ax_time_top.set_xlim(ax_time.get_xlim())
|
|
924
|
+
ax_time_top.set_xticks(tick_indices[: len(tick_elevs)])
|
|
925
|
+
ax_time_top.set_xticklabels([f"{e:.1f}°" for e in tick_elevs])
|
|
926
|
+
ax_time_top.set_xlabel("Elevation", fontsize=10)
|
|
927
|
+
|
|
928
|
+
fig.suptitle("Per-Ring Spread Analysis", fontsize=14, fontweight="bold")
|
|
929
|
+
fig.tight_layout()
|
|
930
|
+
|
|
931
|
+
if output_path:
|
|
932
|
+
fig.savefig(output_path, dpi=dpi, bbox_inches="tight")
|
|
933
|
+
|
|
934
|
+
return fig
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def plot_polar_irradiance(
|
|
938
|
+
rings: ConstantSizeDetectorRings,
|
|
939
|
+
irradiance_map: np.ndarray,
|
|
940
|
+
n_azimuth_bins: int,
|
|
941
|
+
output_path: Optional[Union[str, Path]] = None,
|
|
942
|
+
figsize: tuple = (10, 10),
|
|
943
|
+
dpi: int = 150,
|
|
944
|
+
) -> plt.Figure:
|
|
945
|
+
"""
|
|
946
|
+
Create polar heatmap of irradiance.
|
|
947
|
+
|
|
948
|
+
Parameters
|
|
949
|
+
----------
|
|
950
|
+
rings : ConstantSizeDetectorRings
|
|
951
|
+
Detector ring configuration
|
|
952
|
+
irradiance_map : ndarray
|
|
953
|
+
Irradiance per (ring, azimuth_bin), shape (n_rings, n_azimuth_bins)
|
|
954
|
+
n_azimuth_bins : int
|
|
955
|
+
Number of azimuth bins
|
|
956
|
+
output_path : str or Path, optional
|
|
957
|
+
If provided, save figure to this path
|
|
958
|
+
figsize : tuple
|
|
959
|
+
Figure size in inches
|
|
960
|
+
dpi : int
|
|
961
|
+
Resolution for saved figure
|
|
962
|
+
|
|
963
|
+
Returns
|
|
964
|
+
-------
|
|
965
|
+
Figure
|
|
966
|
+
Matplotlib figure object
|
|
967
|
+
"""
|
|
968
|
+
fig = plt.figure(figsize=figsize)
|
|
969
|
+
ax = fig.add_subplot(111, projection="polar")
|
|
970
|
+
|
|
971
|
+
ring_horiz_dists = rings.get_ring_horizontal_distances() / 1000 # km
|
|
972
|
+
n_rings = rings.n_rings
|
|
973
|
+
|
|
974
|
+
theta = np.linspace(-np.pi, np.pi, n_azimuth_bins + 1)
|
|
975
|
+
r = np.array(list(ring_horiz_dists[: n_rings + 1]))
|
|
976
|
+
R, Theta = np.meshgrid(r, theta)
|
|
977
|
+
|
|
978
|
+
c = ax.pcolormesh(Theta, R, irradiance_map.T, cmap="hot", shading="auto")
|
|
979
|
+
plt.colorbar(
|
|
980
|
+
c, ax=ax, label="Irradiance (W/m\u00b2)", orientation="horizontal", pad=0.08
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
ax.set_theta_zero_location("E")
|
|
984
|
+
ax.set_theta_direction(1)
|
|
985
|
+
ax.set_title(
|
|
986
|
+
"Irradiance vs Horizontal Distance and Azimuth\n(0\u00b0 = beam direction \u2192)",
|
|
987
|
+
fontsize=12,
|
|
988
|
+
fontweight="bold",
|
|
989
|
+
pad=20,
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
if output_path:
|
|
993
|
+
fig.savefig(output_path, dpi=dpi, bbox_inches="tight")
|
|
994
|
+
|
|
995
|
+
return fig
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def compute_ring_azimuth_maps(
|
|
999
|
+
ray_positions: np.ndarray,
|
|
1000
|
+
ray_times: np.ndarray,
|
|
1001
|
+
ray_intensities: np.ndarray,
|
|
1002
|
+
rings: ConstantSizeDetectorRings,
|
|
1003
|
+
az_min: float = -10.0,
|
|
1004
|
+
az_max: float = 10.0,
|
|
1005
|
+
n_az_bins: int = 40,
|
|
1006
|
+
) -> dict:
|
|
1007
|
+
"""
|
|
1008
|
+
Compute 2D maps of intensity, ray count, and timing by ring and azimuth.
|
|
1009
|
+
|
|
1010
|
+
Parameters
|
|
1011
|
+
----------
|
|
1012
|
+
ray_positions : ndarray
|
|
1013
|
+
Ray detection positions (N, 3)
|
|
1014
|
+
ray_times : ndarray
|
|
1015
|
+
Ray detection times (N,)
|
|
1016
|
+
ray_intensities : ndarray
|
|
1017
|
+
Ray intensities (N,)
|
|
1018
|
+
rings : ConstantSizeDetectorRings
|
|
1019
|
+
Detector ring configuration
|
|
1020
|
+
az_min, az_max : float
|
|
1021
|
+
Azimuth range in degrees
|
|
1022
|
+
n_az_bins : int
|
|
1023
|
+
Number of azimuth bins
|
|
1024
|
+
|
|
1025
|
+
Returns
|
|
1026
|
+
-------
|
|
1027
|
+
dict
|
|
1028
|
+
Dictionary with keys:
|
|
1029
|
+
- intensity_map: (n_rings, n_az_bins) total intensity
|
|
1030
|
+
- ray_count_map: (n_rings, n_az_bins) ray count
|
|
1031
|
+
- first_arrival_map: (n_rings, n_az_bins) first arrival time in ns
|
|
1032
|
+
- time_spread_map: (n_rings, n_az_bins) time spread (10-90%) in ns
|
|
1033
|
+
- ray_ring_indices: (N,) ring index per ray
|
|
1034
|
+
- ray_azimuth_deg: (N,) azimuth per ray in degrees
|
|
1035
|
+
"""
|
|
1036
|
+
n_rings = rings.n_rings
|
|
1037
|
+
ring_boundaries_deg = rings.ring_boundaries_deg
|
|
1038
|
+
|
|
1039
|
+
# Compute elevation and azimuth for each ray
|
|
1040
|
+
horizontal_dist = np.sqrt(ray_positions[:, 0] ** 2 + ray_positions[:, 1] ** 2)
|
|
1041
|
+
vertical_dist = ray_positions[:, 2]
|
|
1042
|
+
ray_elevation_deg = np.degrees(np.arctan2(vertical_dist, horizontal_dist))
|
|
1043
|
+
ray_azimuth_deg = np.degrees(np.arctan2(ray_positions[:, 1], ray_positions[:, 0]))
|
|
1044
|
+
|
|
1045
|
+
# Assign rays to rings
|
|
1046
|
+
ray_ring_indices = (
|
|
1047
|
+
np.searchsorted(-ring_boundaries_deg[:-1], -ray_elevation_deg, side="right") - 1
|
|
1048
|
+
)
|
|
1049
|
+
ray_ring_indices = np.clip(ray_ring_indices, 0, n_rings - 1)
|
|
1050
|
+
|
|
1051
|
+
# Initialize maps
|
|
1052
|
+
intensity_map = np.zeros((n_rings, n_az_bins))
|
|
1053
|
+
ray_count_map = np.zeros((n_rings, n_az_bins))
|
|
1054
|
+
first_arrival_map = np.full((n_rings, n_az_bins), np.nan)
|
|
1055
|
+
time_spread_map = np.full((n_rings, n_az_bins), np.nan)
|
|
1056
|
+
|
|
1057
|
+
az_bin_width = (az_max - az_min) / n_az_bins
|
|
1058
|
+
|
|
1059
|
+
for ring_idx in range(n_rings):
|
|
1060
|
+
for az_bin in range(n_az_bins):
|
|
1061
|
+
az_lo = az_min + az_bin * az_bin_width
|
|
1062
|
+
az_hi = az_lo + az_bin_width
|
|
1063
|
+
|
|
1064
|
+
mask = (
|
|
1065
|
+
(ray_ring_indices == ring_idx)
|
|
1066
|
+
& (ray_azimuth_deg >= az_lo)
|
|
1067
|
+
& (ray_azimuth_deg < az_hi)
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
n_in_cell = np.sum(mask)
|
|
1071
|
+
if n_in_cell > 0:
|
|
1072
|
+
cell_times = ray_times[mask]
|
|
1073
|
+
cell_int = ray_intensities[mask]
|
|
1074
|
+
|
|
1075
|
+
intensity_map[ring_idx, az_bin] = np.sum(cell_int)
|
|
1076
|
+
ray_count_map[ring_idx, az_bin] = n_in_cell
|
|
1077
|
+
|
|
1078
|
+
bin_first = np.min(cell_times)
|
|
1079
|
+
first_arrival_map[ring_idx, az_bin] = bin_first * 1e9 # ns
|
|
1080
|
+
|
|
1081
|
+
if n_in_cell >= 3 and np.sum(cell_int) > 0:
|
|
1082
|
+
cell_times_rel = (cell_times - bin_first) * 1e9
|
|
1083
|
+
sorted_idx = np.argsort(cell_times_rel)
|
|
1084
|
+
sorted_t = cell_times_rel[sorted_idx]
|
|
1085
|
+
sorted_w = cell_int[sorted_idx]
|
|
1086
|
+
cumsum = np.cumsum(sorted_w)
|
|
1087
|
+
cumsum_norm = cumsum / cumsum[-1]
|
|
1088
|
+
t10 = sorted_t[np.searchsorted(cumsum_norm, 0.10)]
|
|
1089
|
+
t90 = sorted_t[np.searchsorted(cumsum_norm, 0.90)]
|
|
1090
|
+
time_spread_map[ring_idx, az_bin] = t90 - t10
|
|
1091
|
+
|
|
1092
|
+
return {
|
|
1093
|
+
"intensity_map": intensity_map,
|
|
1094
|
+
"ray_count_map": ray_count_map,
|
|
1095
|
+
"first_arrival_map": first_arrival_map,
|
|
1096
|
+
"time_spread_map": time_spread_map,
|
|
1097
|
+
"ray_ring_indices": ray_ring_indices,
|
|
1098
|
+
"ray_azimuth_deg": ray_azimuth_deg,
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def plot_time_spread_comparison(
|
|
1103
|
+
rings: ConstantSizeDetectorRings,
|
|
1104
|
+
ring_stats: list,
|
|
1105
|
+
source_position: tuple,
|
|
1106
|
+
beam_direction: tuple,
|
|
1107
|
+
divergence_angle_rad: float,
|
|
1108
|
+
surface=None,
|
|
1109
|
+
output_path: Optional[Union[str, Path]] = None,
|
|
1110
|
+
figsize: tuple = (14, 5),
|
|
1111
|
+
dpi: int = 150,
|
|
1112
|
+
) -> Optional[plt.Figure]:
|
|
1113
|
+
"""
|
|
1114
|
+
Compare ray-traced time spread with geometric estimate.
|
|
1115
|
+
|
|
1116
|
+
The geometric estimate computes the path difference for rays at the edge
|
|
1117
|
+
of the divergence cone reflecting through the beam footprint on the surface.
|
|
1118
|
+
|
|
1119
|
+
Parameters
|
|
1120
|
+
----------
|
|
1121
|
+
rings : ConstantSizeDetectorRings
|
|
1122
|
+
Detector ring configuration
|
|
1123
|
+
ring_stats : list of dict
|
|
1124
|
+
Per-ring statistics with time_spread_ns and dist_km
|
|
1125
|
+
source_position : tuple
|
|
1126
|
+
Source (x, y, z) position in meters
|
|
1127
|
+
beam_direction : tuple
|
|
1128
|
+
Beam direction unit vector
|
|
1129
|
+
divergence_angle_rad : float
|
|
1130
|
+
Beam half-angle divergence in radians
|
|
1131
|
+
surface : Surface, optional
|
|
1132
|
+
Ocean surface for geometric estimate. If None, uses flat surface at z=0.
|
|
1133
|
+
output_path : str or Path, optional
|
|
1134
|
+
If provided, save figure to this path
|
|
1135
|
+
figsize : tuple
|
|
1136
|
+
Figure size in inches
|
|
1137
|
+
dpi : int
|
|
1138
|
+
Resolution for saved figure
|
|
1139
|
+
|
|
1140
|
+
Returns
|
|
1141
|
+
-------
|
|
1142
|
+
Figure or None
|
|
1143
|
+
Matplotlib figure object, or None if no data
|
|
1144
|
+
"""
|
|
1145
|
+
from ..utilities import estimate_time_spread
|
|
1146
|
+
|
|
1147
|
+
if not ring_stats:
|
|
1148
|
+
return None
|
|
1149
|
+
|
|
1150
|
+
fig, (ax_abs, ax_ratio) = plt.subplots(1, 2, figsize=figsize)
|
|
1151
|
+
|
|
1152
|
+
# Collect data
|
|
1153
|
+
distances = []
|
|
1154
|
+
raytraced_spreads = []
|
|
1155
|
+
geometric_spreads = []
|
|
1156
|
+
ring_indices = []
|
|
1157
|
+
|
|
1158
|
+
print("\n Time Spread Comparison Debug:")
|
|
1159
|
+
print(f" Source: {source_position}")
|
|
1160
|
+
print(f" Beam dir: {beam_direction}")
|
|
1161
|
+
print(f" Divergence: {np.degrees(divergence_angle_rad):.2f} deg")
|
|
1162
|
+
|
|
1163
|
+
for stats in ring_stats:
|
|
1164
|
+
ring_idx = stats["ring_idx"]
|
|
1165
|
+
dist_km = stats["dist_km"]
|
|
1166
|
+
raytraced_ns = stats["time_spread_ns"]
|
|
1167
|
+
|
|
1168
|
+
# Skip rings with zero or invalid time spread
|
|
1169
|
+
if raytraced_ns <= 0 or ring_idx >= rings.n_rings:
|
|
1170
|
+
continue
|
|
1171
|
+
|
|
1172
|
+
# Get detector position for this ring center
|
|
1173
|
+
theta_c = rings.ring_centers_deg[ring_idx]
|
|
1174
|
+
dist_m = rings.ring_distances[ring_idx]
|
|
1175
|
+
|
|
1176
|
+
# Detector position (azimuth = 0)
|
|
1177
|
+
det_x = dist_m * np.cos(np.radians(theta_c))
|
|
1178
|
+
det_y = 0.0
|
|
1179
|
+
det_z = dist_m * np.sin(np.radians(theta_c))
|
|
1180
|
+
detector_position = (det_x, det_y, det_z)
|
|
1181
|
+
|
|
1182
|
+
# Compute geometric estimate
|
|
1183
|
+
result = estimate_time_spread(
|
|
1184
|
+
source_position=source_position,
|
|
1185
|
+
beam_direction=beam_direction,
|
|
1186
|
+
divergence_angle=divergence_angle_rad,
|
|
1187
|
+
detector_position=detector_position,
|
|
1188
|
+
surface=surface,
|
|
1189
|
+
)
|
|
1190
|
+
|
|
1191
|
+
# Debug output for first few rings
|
|
1192
|
+
if len(distances) < 5:
|
|
1193
|
+
print(
|
|
1194
|
+
f" Ring {ring_idx}: dist={dist_km:.0f}km, "
|
|
1195
|
+
f"raytraced={raytraced_ns:.2f}ns, geometric={result.time_spread_ns:.2f}ns, "
|
|
1196
|
+
f"path_spread={result.path_spread:.1f}m"
|
|
1197
|
+
)
|
|
1198
|
+
|
|
1199
|
+
distances.append(dist_km)
|
|
1200
|
+
raytraced_spreads.append(raytraced_ns)
|
|
1201
|
+
geometric_spreads.append(result.time_spread_ns)
|
|
1202
|
+
ring_indices.append(ring_idx)
|
|
1203
|
+
|
|
1204
|
+
if not distances:
|
|
1205
|
+
return None
|
|
1206
|
+
|
|
1207
|
+
distances = np.array(distances)
|
|
1208
|
+
raytraced_spreads = np.array(raytraced_spreads)
|
|
1209
|
+
geometric_spreads = np.array(geometric_spreads)
|
|
1210
|
+
ring_indices = np.array(ring_indices)
|
|
1211
|
+
|
|
1212
|
+
# Left panel: Absolute comparison
|
|
1213
|
+
ax_abs.scatter(
|
|
1214
|
+
ring_indices,
|
|
1215
|
+
raytraced_spreads,
|
|
1216
|
+
c="blue",
|
|
1217
|
+
s=60,
|
|
1218
|
+
label="Ray-traced (10-90%)",
|
|
1219
|
+
alpha=0.7,
|
|
1220
|
+
)
|
|
1221
|
+
ax_abs.scatter(
|
|
1222
|
+
ring_indices,
|
|
1223
|
+
geometric_spreads,
|
|
1224
|
+
c="red",
|
|
1225
|
+
s=60,
|
|
1226
|
+
marker="^",
|
|
1227
|
+
label="Geometric estimate",
|
|
1228
|
+
alpha=0.7,
|
|
1229
|
+
)
|
|
1230
|
+
ax_abs.plot(ring_indices, geometric_spreads, "r--", alpha=0.5)
|
|
1231
|
+
|
|
1232
|
+
ax_abs.set_xlabel("Ring Index", fontsize=11)
|
|
1233
|
+
ax_abs.set_ylabel("Time Spread (ns)", fontsize=11)
|
|
1234
|
+
ax_abs.set_title(
|
|
1235
|
+
"Time Spread: Ray-Traced vs Geometric", fontsize=12, fontweight="bold"
|
|
1236
|
+
)
|
|
1237
|
+
ax_abs.legend(loc="upper left", fontsize=9)
|
|
1238
|
+
ax_abs.grid(True, alpha=0.3)
|
|
1239
|
+
ax_abs.set_yscale("log")
|
|
1240
|
+
|
|
1241
|
+
# Add elevation axis on top for ax_abs
|
|
1242
|
+
ax_abs_top = ax_abs.twiny()
|
|
1243
|
+
ax_abs_top.set_xlim(ax_abs.get_xlim())
|
|
1244
|
+
tick_indices = ring_indices[:: max(1, len(ring_indices) // 5)]
|
|
1245
|
+
tick_elevs = [
|
|
1246
|
+
rings.ring_centers_deg[int(i)] for i in tick_indices if i < rings.n_rings
|
|
1247
|
+
]
|
|
1248
|
+
ax_abs_top.set_xticks(tick_indices[: len(tick_elevs)])
|
|
1249
|
+
ax_abs_top.set_xticklabels([f"{e:.1f}°" for e in tick_elevs])
|
|
1250
|
+
ax_abs_top.set_xlabel("Elevation", fontsize=10)
|
|
1251
|
+
|
|
1252
|
+
# Right panel: Ratio
|
|
1253
|
+
ratio = raytraced_spreads / geometric_spreads
|
|
1254
|
+
valid_mask = geometric_spreads > 0
|
|
1255
|
+
|
|
1256
|
+
if np.any(valid_mask):
|
|
1257
|
+
sc = ax_ratio.scatter(
|
|
1258
|
+
ring_indices[valid_mask],
|
|
1259
|
+
ratio[valid_mask],
|
|
1260
|
+
c=distances[valid_mask],
|
|
1261
|
+
cmap="viridis",
|
|
1262
|
+
s=60,
|
|
1263
|
+
alpha=0.7,
|
|
1264
|
+
)
|
|
1265
|
+
ax_ratio.axhline(
|
|
1266
|
+
y=1.0, color="red", linestyle="--", linewidth=1.5, label="Perfect agreement"
|
|
1267
|
+
)
|
|
1268
|
+
|
|
1269
|
+
# Add colorbar for distance
|
|
1270
|
+
cbar = plt.colorbar(sc, ax=ax_ratio)
|
|
1271
|
+
cbar.set_label("Distance (km)")
|
|
1272
|
+
|
|
1273
|
+
ax_ratio.set_xlabel("Ring Index", fontsize=11)
|
|
1274
|
+
ax_ratio.set_ylabel("Ray-Traced / Geometric", fontsize=11)
|
|
1275
|
+
ax_ratio.set_title("Ratio of Time Spreads", fontsize=12, fontweight="bold")
|
|
1276
|
+
ax_ratio.legend(loc="upper right", fontsize=9)
|
|
1277
|
+
ax_ratio.grid(True, alpha=0.3)
|
|
1278
|
+
|
|
1279
|
+
# Add elevation axis on top for ax_ratio
|
|
1280
|
+
ax_ratio_top = ax_ratio.twiny()
|
|
1281
|
+
ax_ratio_top.set_xlim(ax_ratio.get_xlim())
|
|
1282
|
+
ax_ratio_top.set_xticks(tick_indices[: len(tick_elevs)])
|
|
1283
|
+
ax_ratio_top.set_xticklabels([f"{e:.1f}°" for e in tick_elevs])
|
|
1284
|
+
ax_ratio_top.set_xlabel("Elevation", fontsize=10)
|
|
1285
|
+
|
|
1286
|
+
fig.suptitle(
|
|
1287
|
+
"Time Spread Comparison: Ray-Traced vs Single-Bounce Geometric Estimate",
|
|
1288
|
+
fontsize=14,
|
|
1289
|
+
fontweight="bold",
|
|
1290
|
+
)
|
|
1291
|
+
fig.tight_layout()
|
|
1292
|
+
|
|
1293
|
+
if output_path:
|
|
1294
|
+
fig.savefig(output_path, dpi=dpi, bbox_inches="tight")
|
|
1295
|
+
|
|
1296
|
+
return fig
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
def compute_constant_size_bin_stats(
|
|
1300
|
+
ray_positions: np.ndarray,
|
|
1301
|
+
ray_times: np.ndarray,
|
|
1302
|
+
ray_intensities: np.ndarray,
|
|
1303
|
+
rings: ConstantSizeDetectorRings,
|
|
1304
|
+
az_bin_size_m: float = 10000.0,
|
|
1305
|
+
az_range_deg: float = 10.0,
|
|
1306
|
+
min_rays_per_bin: int = 3,
|
|
1307
|
+
) -> list[dict]:
|
|
1308
|
+
"""
|
|
1309
|
+
Compute timing statistics for constant physical size bins.
|
|
1310
|
+
|
|
1311
|
+
Each bin has approximately the same physical size:
|
|
1312
|
+
- Radial: detector_radial_size (from rings configuration)
|
|
1313
|
+
- Azimuthal: az_bin_size_m (varies in angular size with distance)
|
|
1314
|
+
|
|
1315
|
+
Parameters
|
|
1316
|
+
----------
|
|
1317
|
+
ray_positions : ndarray
|
|
1318
|
+
Ray detection positions (N, 3)
|
|
1319
|
+
ray_times : ndarray
|
|
1320
|
+
Ray detection times (N,)
|
|
1321
|
+
ray_intensities : ndarray
|
|
1322
|
+
Ray intensities (N,)
|
|
1323
|
+
rings : ConstantSizeDetectorRings
|
|
1324
|
+
Detector ring configuration
|
|
1325
|
+
az_bin_size_m : float
|
|
1326
|
+
Physical azimuthal bin size in meters (default: 10 km)
|
|
1327
|
+
az_range_deg : float
|
|
1328
|
+
Azimuth range in degrees (±this value)
|
|
1329
|
+
min_rays_per_bin : int
|
|
1330
|
+
Minimum rays required to compute statistics
|
|
1331
|
+
|
|
1332
|
+
Returns
|
|
1333
|
+
-------
|
|
1334
|
+
list of dict
|
|
1335
|
+
Per-bin statistics with keys:
|
|
1336
|
+
- ring_idx, az_bin_idx, n_az_bins
|
|
1337
|
+
- az_center_deg, elev_center_deg
|
|
1338
|
+
- distance_km
|
|
1339
|
+
- n_rays, total_intensity
|
|
1340
|
+
- first_arrival_ns (relative to global first)
|
|
1341
|
+
- time_spread_ns (10-90 percentile)
|
|
1342
|
+
- bin_area_m2
|
|
1343
|
+
"""
|
|
1344
|
+
n_rings = rings.n_rings
|
|
1345
|
+
ring_boundaries_deg = rings.ring_boundaries_deg
|
|
1346
|
+
|
|
1347
|
+
# Compute elevation and azimuth for each ray
|
|
1348
|
+
horizontal_dist = np.sqrt(ray_positions[:, 0] ** 2 + ray_positions[:, 1] ** 2)
|
|
1349
|
+
vertical_dist = ray_positions[:, 2]
|
|
1350
|
+
ray_elevation_deg = np.degrees(np.arctan2(vertical_dist, horizontal_dist))
|
|
1351
|
+
ray_azimuth_deg = np.degrees(np.arctan2(ray_positions[:, 1], ray_positions[:, 0]))
|
|
1352
|
+
|
|
1353
|
+
# Assign rays to rings
|
|
1354
|
+
ray_ring_indices = (
|
|
1355
|
+
np.searchsorted(-ring_boundaries_deg[:-1], -ray_elevation_deg, side="right") - 1
|
|
1356
|
+
)
|
|
1357
|
+
ray_ring_indices = np.clip(ray_ring_indices, 0, n_rings - 1)
|
|
1358
|
+
|
|
1359
|
+
# Global first arrival for reference
|
|
1360
|
+
global_first = np.min(ray_times)
|
|
1361
|
+
|
|
1362
|
+
# Get constant-size grid
|
|
1363
|
+
grid = rings.get_constant_size_grid(az_bin_size_m, az_range_deg)
|
|
1364
|
+
|
|
1365
|
+
results = []
|
|
1366
|
+
|
|
1367
|
+
for bin_spec in grid:
|
|
1368
|
+
ring_idx = bin_spec["ring_idx"]
|
|
1369
|
+
az_lo = bin_spec["az_lo_deg"]
|
|
1370
|
+
az_hi = bin_spec["az_hi_deg"]
|
|
1371
|
+
|
|
1372
|
+
# Find rays in this bin
|
|
1373
|
+
mask = (
|
|
1374
|
+
(ray_ring_indices == ring_idx)
|
|
1375
|
+
& (ray_azimuth_deg >= az_lo)
|
|
1376
|
+
& (ray_azimuth_deg < az_hi)
|
|
1377
|
+
)
|
|
1378
|
+
|
|
1379
|
+
n_rays = np.sum(mask)
|
|
1380
|
+
|
|
1381
|
+
if n_rays >= min_rays_per_bin:
|
|
1382
|
+
bin_times = ray_times[mask]
|
|
1383
|
+
bin_int = ray_intensities[mask]
|
|
1384
|
+
|
|
1385
|
+
# First arrival relative to global first
|
|
1386
|
+
bin_first = np.min(bin_times)
|
|
1387
|
+
first_arrival_ns = (bin_first - global_first) * 1e9
|
|
1388
|
+
|
|
1389
|
+
# Time spread within bin (10-90 percentile, intensity-weighted)
|
|
1390
|
+
bin_times_rel = (bin_times - bin_first) * 1e9 # ns from bin's first
|
|
1391
|
+
|
|
1392
|
+
time_spread_ns = 0.0
|
|
1393
|
+
if np.sum(bin_int) > 0 and n_rays >= 3:
|
|
1394
|
+
sorted_idx = np.argsort(bin_times_rel)
|
|
1395
|
+
sorted_t = bin_times_rel[sorted_idx]
|
|
1396
|
+
sorted_w = bin_int[sorted_idx]
|
|
1397
|
+
cumsum = np.cumsum(sorted_w)
|
|
1398
|
+
cumsum_norm = cumsum / cumsum[-1]
|
|
1399
|
+
t10 = sorted_t[np.searchsorted(cumsum_norm, 0.10)]
|
|
1400
|
+
t90 = sorted_t[np.searchsorted(cumsum_norm, 0.90)]
|
|
1401
|
+
time_spread_ns = t90 - t10
|
|
1402
|
+
|
|
1403
|
+
results.append(
|
|
1404
|
+
{
|
|
1405
|
+
"ring_idx": ring_idx,
|
|
1406
|
+
"az_bin_idx": bin_spec["az_bin_idx"],
|
|
1407
|
+
"n_az_bins": bin_spec["n_az_bins"],
|
|
1408
|
+
"az_center_deg": bin_spec["az_center_deg"],
|
|
1409
|
+
"elev_center_deg": bin_spec["elev_center_deg"],
|
|
1410
|
+
"distance_km": bin_spec["distance_m"] / 1000,
|
|
1411
|
+
"n_rays": n_rays,
|
|
1412
|
+
"total_intensity": np.sum(bin_int),
|
|
1413
|
+
"first_arrival_ns": first_arrival_ns,
|
|
1414
|
+
"time_spread_ns": time_spread_ns,
|
|
1415
|
+
"bin_area_m2": bin_spec["bin_area_m2"],
|
|
1416
|
+
}
|
|
1417
|
+
)
|
|
1418
|
+
|
|
1419
|
+
return results
|
|
1420
|
+
|
|
1421
|
+
|
|
1422
|
+
def plot_constant_size_timing_analysis(
|
|
1423
|
+
rings: ConstantSizeDetectorRings,
|
|
1424
|
+
bin_stats: list[dict],
|
|
1425
|
+
output_path: Optional[Union[str, Path]] = None,
|
|
1426
|
+
figsize: tuple = (16, 10),
|
|
1427
|
+
dpi: int = 150,
|
|
1428
|
+
) -> Optional[plt.Figure]:
|
|
1429
|
+
"""
|
|
1430
|
+
Plot timing analysis for constant physical size bins.
|
|
1431
|
+
|
|
1432
|
+
Creates a 2x2 figure:
|
|
1433
|
+
- (a) Time spread vs distance (each point is one bin)
|
|
1434
|
+
- (b) First arrival vs distance
|
|
1435
|
+
- (c) Time spread heatmap (ring vs azimuth, scaled to show structure)
|
|
1436
|
+
- (d) Number of azimuth bins per ring (shows scaling with distance)
|
|
1437
|
+
|
|
1438
|
+
Parameters
|
|
1439
|
+
----------
|
|
1440
|
+
rings : ConstantSizeDetectorRings
|
|
1441
|
+
Detector ring configuration
|
|
1442
|
+
bin_stats : list of dict
|
|
1443
|
+
Results from compute_constant_size_bin_stats
|
|
1444
|
+
output_path : str or Path, optional
|
|
1445
|
+
If provided, save figure to this path
|
|
1446
|
+
figsize : tuple
|
|
1447
|
+
Figure size in inches
|
|
1448
|
+
dpi : int
|
|
1449
|
+
Resolution for saved figure
|
|
1450
|
+
|
|
1451
|
+
Returns
|
|
1452
|
+
-------
|
|
1453
|
+
Figure or None
|
|
1454
|
+
Matplotlib figure object, or None if no data
|
|
1455
|
+
"""
|
|
1456
|
+
if not bin_stats:
|
|
1457
|
+
return None
|
|
1458
|
+
|
|
1459
|
+
fig, axes = plt.subplots(2, 2, figsize=figsize)
|
|
1460
|
+
|
|
1461
|
+
# Extract data
|
|
1462
|
+
_distances = np.array([s["distance_km"] for s in bin_stats]) # noqa: F841
|
|
1463
|
+
time_spreads = np.array([s["time_spread_ns"] for s in bin_stats])
|
|
1464
|
+
first_arrivals = np.array([s["first_arrival_ns"] for s in bin_stats])
|
|
1465
|
+
n_rays = np.array([s["n_rays"] for s in bin_stats])
|
|
1466
|
+
ring_indices = np.array([s["ring_idx"] for s in bin_stats])
|
|
1467
|
+
az_centers = np.array([s["az_center_deg"] for s in bin_stats])
|
|
1468
|
+
|
|
1469
|
+
# Color by log ray count
|
|
1470
|
+
colors = np.log10(n_rays + 1)
|
|
1471
|
+
|
|
1472
|
+
# Get tick positions for elevation axis (use unique rings)
|
|
1473
|
+
unique_ring_indices = np.unique(ring_indices)
|
|
1474
|
+
tick_ring_indices = unique_ring_indices[:: max(1, len(unique_ring_indices) // 5)]
|
|
1475
|
+
tick_elevs = [
|
|
1476
|
+
rings.ring_centers_deg[int(i)] for i in tick_ring_indices if i < rings.n_rings
|
|
1477
|
+
]
|
|
1478
|
+
|
|
1479
|
+
# (a) Time spread vs ring index
|
|
1480
|
+
ax = axes[0, 0]
|
|
1481
|
+
sc = ax.scatter(
|
|
1482
|
+
ring_indices, time_spreads, c=colors, cmap="viridis", s=20, alpha=0.7
|
|
1483
|
+
)
|
|
1484
|
+
ax.set_xlabel("Ring Index", fontsize=11)
|
|
1485
|
+
ax.set_ylabel("Time Spread (10-90%, ns)", fontsize=11)
|
|
1486
|
+
ax.set_title("(a) Time Spread vs Ring", fontsize=12, fontweight="bold")
|
|
1487
|
+
ax.grid(True, alpha=0.3)
|
|
1488
|
+
cbar = plt.colorbar(sc, ax=ax)
|
|
1489
|
+
cbar.set_label("log\u2081\u2080(ray count)")
|
|
1490
|
+
|
|
1491
|
+
# Add elevation axis on top
|
|
1492
|
+
ax_top = ax.twiny()
|
|
1493
|
+
ax_top.set_xlim(ax.get_xlim())
|
|
1494
|
+
ax_top.set_xticks(tick_ring_indices[: len(tick_elevs)])
|
|
1495
|
+
ax_top.set_xticklabels([f"{e:.1f}°" for e in tick_elevs])
|
|
1496
|
+
ax_top.set_xlabel("Elevation", fontsize=10)
|
|
1497
|
+
|
|
1498
|
+
# (b) First arrival vs ring index
|
|
1499
|
+
ax = axes[0, 1]
|
|
1500
|
+
sc = ax.scatter(
|
|
1501
|
+
ring_indices, first_arrivals, c=colors, cmap="viridis", s=20, alpha=0.7
|
|
1502
|
+
)
|
|
1503
|
+
ax.set_xlabel("Ring Index", fontsize=11)
|
|
1504
|
+
ax.set_ylabel("First Arrival (ns from global first)", fontsize=11)
|
|
1505
|
+
ax.set_title("(b) First Arrival Time vs Ring", fontsize=12, fontweight="bold")
|
|
1506
|
+
ax.grid(True, alpha=0.3)
|
|
1507
|
+
cbar = plt.colorbar(sc, ax=ax)
|
|
1508
|
+
cbar.set_label("log\u2081\u2080(ray count)")
|
|
1509
|
+
|
|
1510
|
+
# Add elevation axis on top
|
|
1511
|
+
ax_top = ax.twiny()
|
|
1512
|
+
ax_top.set_xlim(ax.get_xlim())
|
|
1513
|
+
ax_top.set_xticks(tick_ring_indices[: len(tick_elevs)])
|
|
1514
|
+
ax_top.set_xticklabels([f"{e:.1f}°" for e in tick_elevs])
|
|
1515
|
+
ax_top.set_xlabel("Elevation", fontsize=10)
|
|
1516
|
+
|
|
1517
|
+
# (c) Time spread as scatter (ring_idx vs az_center, colored by time spread)
|
|
1518
|
+
ax = axes[1, 0]
|
|
1519
|
+
valid_mask = time_spreads > 0
|
|
1520
|
+
if np.any(valid_mask):
|
|
1521
|
+
sc = ax.scatter(
|
|
1522
|
+
az_centers[valid_mask],
|
|
1523
|
+
ring_indices[valid_mask],
|
|
1524
|
+
c=time_spreads[valid_mask],
|
|
1525
|
+
cmap="plasma",
|
|
1526
|
+
s=30,
|
|
1527
|
+
alpha=0.7,
|
|
1528
|
+
)
|
|
1529
|
+
ax.set_xlabel("Azimuth (deg) - 0\u00b0 = beam direction", fontsize=11)
|
|
1530
|
+
ax.set_ylabel("Ring Index", fontsize=11)
|
|
1531
|
+
ax.set_title("(c) Time Spread by Position", fontsize=12, fontweight="bold")
|
|
1532
|
+
ax.grid(True, alpha=0.3)
|
|
1533
|
+
cbar = plt.colorbar(sc, ax=ax)
|
|
1534
|
+
cbar.set_label("Time Spread (ns)")
|
|
1535
|
+
|
|
1536
|
+
# Secondary y-axis for elevation
|
|
1537
|
+
def ring_to_elev(ring_idx):
|
|
1538
|
+
idx = np.clip(np.asarray(ring_idx), 0, rings.n_rings - 1).astype(int)
|
|
1539
|
+
return rings.ring_centers_deg[idx]
|
|
1540
|
+
|
|
1541
|
+
def elev_to_ring(elev):
|
|
1542
|
+
return np.interp(
|
|
1543
|
+
elev, rings.ring_centers_deg[::-1], np.arange(rings.n_rings)[::-1]
|
|
1544
|
+
)
|
|
1545
|
+
|
|
1546
|
+
ax_elev = ax.secondary_yaxis("right", functions=(ring_to_elev, elev_to_ring))
|
|
1547
|
+
ax_elev.set_ylabel("Elevation (deg)", fontsize=11)
|
|
1548
|
+
|
|
1549
|
+
# (d) Number of azimuth bins per ring
|
|
1550
|
+
ax = axes[1, 1]
|
|
1551
|
+
|
|
1552
|
+
# Get unique rings and their bin counts
|
|
1553
|
+
unique_rings = sorted(set(s["ring_idx"] for s in bin_stats))
|
|
1554
|
+
n_az_bins_per_ring = []
|
|
1555
|
+
ring_distances = []
|
|
1556
|
+
for ring_idx in unique_rings:
|
|
1557
|
+
ring_bins = [s for s in bin_stats if s["ring_idx"] == ring_idx]
|
|
1558
|
+
if ring_bins:
|
|
1559
|
+
n_az_bins_per_ring.append(ring_bins[0]["n_az_bins"])
|
|
1560
|
+
ring_distances.append(ring_bins[0]["distance_km"])
|
|
1561
|
+
|
|
1562
|
+
ax.bar(unique_rings, n_az_bins_per_ring, color="steelblue", alpha=0.7)
|
|
1563
|
+
ax.set_xlabel("Ring Index", fontsize=11)
|
|
1564
|
+
ax.set_ylabel("Number of Azimuth Bins", fontsize=11)
|
|
1565
|
+
ax.set_title(
|
|
1566
|
+
"(d) Azimuth Bins per Ring (constant physical size)",
|
|
1567
|
+
fontsize=12,
|
|
1568
|
+
fontweight="bold",
|
|
1569
|
+
)
|
|
1570
|
+
ax.grid(True, alpha=0.3)
|
|
1571
|
+
|
|
1572
|
+
# Add elevation on secondary x-axis
|
|
1573
|
+
ax2 = ax.twiny()
|
|
1574
|
+
ax2.set_xlim(ax.get_xlim())
|
|
1575
|
+
tick_rings = unique_rings[:: max(1, len(unique_rings) // 5)]
|
|
1576
|
+
tick_elevs_d = [
|
|
1577
|
+
rings.ring_centers_deg[int(r)] for r in tick_rings if r < rings.n_rings
|
|
1578
|
+
]
|
|
1579
|
+
ax2.set_xticks(tick_rings[: len(tick_elevs_d)])
|
|
1580
|
+
ax2.set_xticklabels([f"{e:.1f}°" for e in tick_elevs_d])
|
|
1581
|
+
ax2.set_xlabel("Elevation", fontsize=10)
|
|
1582
|
+
|
|
1583
|
+
bin_size_km = rings.detector_radial_size / 1000
|
|
1584
|
+
az_bin_size_km = (
|
|
1585
|
+
bin_stats[0]["bin_area_m2"] / rings.detector_radial_size / 1000
|
|
1586
|
+
if bin_stats
|
|
1587
|
+
else 10
|
|
1588
|
+
)
|
|
1589
|
+
|
|
1590
|
+
fig.suptitle(
|
|
1591
|
+
f"Constant Physical Size Bin Analysis\n"
|
|
1592
|
+
f"(Radial: {bin_size_km:.0f} km, Azimuthal: ~{az_bin_size_km:.0f} km, "
|
|
1593
|
+
f"{len(bin_stats)} bins with data)",
|
|
1594
|
+
fontsize=13,
|
|
1595
|
+
fontweight="bold",
|
|
1596
|
+
)
|
|
1597
|
+
fig.tight_layout()
|
|
1598
|
+
|
|
1599
|
+
if output_path:
|
|
1600
|
+
fig.savefig(output_path, dpi=dpi, bbox_inches="tight")
|
|
1601
|
+
|
|
1602
|
+
return fig
|
|
1603
|
+
|
|
1604
|
+
|
|
1605
|
+
def plot_constant_size_timing_distributions(
|
|
1606
|
+
rings: ConstantSizeDetectorRings,
|
|
1607
|
+
bin_stats: list[dict],
|
|
1608
|
+
ray_positions: np.ndarray,
|
|
1609
|
+
ray_times: np.ndarray,
|
|
1610
|
+
ray_intensities: np.ndarray,
|
|
1611
|
+
n_top: int = 12,
|
|
1612
|
+
output_path: Optional[Union[str, Path]] = None,
|
|
1613
|
+
figsize_per_col: float = 4.0,
|
|
1614
|
+
figsize_per_row: float = 3.5,
|
|
1615
|
+
dpi: int = 150,
|
|
1616
|
+
) -> Optional[plt.Figure]:
|
|
1617
|
+
"""
|
|
1618
|
+
Plot timing distributions for top bins by ray count.
|
|
1619
|
+
|
|
1620
|
+
Parameters
|
|
1621
|
+
----------
|
|
1622
|
+
rings : ConstantSizeDetectorRings
|
|
1623
|
+
Detector ring configuration
|
|
1624
|
+
bin_stats : list of dict
|
|
1625
|
+
Results from compute_constant_size_bin_stats
|
|
1626
|
+
ray_positions, ray_times, ray_intensities : ndarray
|
|
1627
|
+
Raw ray data for histogram computation
|
|
1628
|
+
n_top : int
|
|
1629
|
+
Number of top bins to plot
|
|
1630
|
+
output_path : str or Path, optional
|
|
1631
|
+
If provided, save figure to this path
|
|
1632
|
+
figsize_per_col, figsize_per_row : float
|
|
1633
|
+
Figure size multipliers
|
|
1634
|
+
dpi : int
|
|
1635
|
+
Resolution for saved figure
|
|
1636
|
+
|
|
1637
|
+
Returns
|
|
1638
|
+
-------
|
|
1639
|
+
Figure or None
|
|
1640
|
+
Matplotlib figure object, or None if no data
|
|
1641
|
+
"""
|
|
1642
|
+
if not bin_stats:
|
|
1643
|
+
return None
|
|
1644
|
+
|
|
1645
|
+
# Sort by ray count and take top N
|
|
1646
|
+
sorted_bins = sorted(bin_stats, key=lambda x: x["n_rays"], reverse=True)
|
|
1647
|
+
top_bins = sorted_bins[:n_top]
|
|
1648
|
+
|
|
1649
|
+
if not top_bins:
|
|
1650
|
+
return None
|
|
1651
|
+
|
|
1652
|
+
n_plots = len(top_bins)
|
|
1653
|
+
n_cols = min(4, n_plots)
|
|
1654
|
+
n_rows = (n_plots + n_cols - 1) // n_cols
|
|
1655
|
+
|
|
1656
|
+
fig, axes = plt.subplots(
|
|
1657
|
+
n_rows, n_cols, figsize=(figsize_per_col * n_cols, figsize_per_row * n_rows)
|
|
1658
|
+
)
|
|
1659
|
+
|
|
1660
|
+
if n_plots == 1:
|
|
1661
|
+
axes = np.array([[axes]])
|
|
1662
|
+
elif n_rows == 1:
|
|
1663
|
+
axes = axes.reshape(1, -1)
|
|
1664
|
+
|
|
1665
|
+
# Precompute ray assignments
|
|
1666
|
+
ring_boundaries_deg = rings.ring_boundaries_deg
|
|
1667
|
+
n_rings = rings.n_rings
|
|
1668
|
+
|
|
1669
|
+
horizontal_dist = np.sqrt(ray_positions[:, 0] ** 2 + ray_positions[:, 1] ** 2)
|
|
1670
|
+
vertical_dist = ray_positions[:, 2]
|
|
1671
|
+
ray_elevation_deg = np.degrees(np.arctan2(vertical_dist, horizontal_dist))
|
|
1672
|
+
ray_azimuth_deg = np.degrees(np.arctan2(ray_positions[:, 1], ray_positions[:, 0]))
|
|
1673
|
+
|
|
1674
|
+
ray_ring_indices = (
|
|
1675
|
+
np.searchsorted(-ring_boundaries_deg[:-1], -ray_elevation_deg, side="right") - 1
|
|
1676
|
+
)
|
|
1677
|
+
ray_ring_indices = np.clip(ray_ring_indices, 0, n_rings - 1)
|
|
1678
|
+
|
|
1679
|
+
for i, bin_info in enumerate(top_bins):
|
|
1680
|
+
row, col = divmod(i, n_cols)
|
|
1681
|
+
ax = axes[row, col]
|
|
1682
|
+
|
|
1683
|
+
ring_idx = bin_info["ring_idx"]
|
|
1684
|
+
n_az_bins = bin_info["n_az_bins"]
|
|
1685
|
+
az_width = 20.0 / n_az_bins # Assuming ±10° range
|
|
1686
|
+
az_center = bin_info["az_center_deg"]
|
|
1687
|
+
az_lo = az_center - az_width / 2
|
|
1688
|
+
az_hi = az_center + az_width / 2
|
|
1689
|
+
|
|
1690
|
+
# Find rays in this bin
|
|
1691
|
+
mask = (
|
|
1692
|
+
(ray_ring_indices == ring_idx)
|
|
1693
|
+
& (ray_azimuth_deg >= az_lo)
|
|
1694
|
+
& (ray_azimuth_deg < az_hi)
|
|
1695
|
+
)
|
|
1696
|
+
|
|
1697
|
+
bin_times = ray_times[mask]
|
|
1698
|
+
bin_int = ray_intensities[mask]
|
|
1699
|
+
n_rays = len(bin_times)
|
|
1700
|
+
|
|
1701
|
+
if n_rays > 0 and np.sum(bin_int) > 0:
|
|
1702
|
+
# Times relative to bin's first arrival
|
|
1703
|
+
bin_first = np.min(bin_times)
|
|
1704
|
+
times_rel = (bin_times - bin_first) * 1e9
|
|
1705
|
+
|
|
1706
|
+
# Intensity-weighted histogram
|
|
1707
|
+
counts, edges = np.histogram(times_rel, bins=30, weights=bin_int)
|
|
1708
|
+
centers = (edges[:-1] + edges[1:]) / 2
|
|
1709
|
+
|
|
1710
|
+
ax.fill_between(centers, counts, alpha=0.6, color="steelblue", step="mid")
|
|
1711
|
+
ax.step(centers, counts, where="mid", color="steelblue", linewidth=1.5)
|
|
1712
|
+
|
|
1713
|
+
# Compute percentiles
|
|
1714
|
+
sorted_idx = np.argsort(times_rel)
|
|
1715
|
+
sorted_t = times_rel[sorted_idx]
|
|
1716
|
+
sorted_w = bin_int[sorted_idx]
|
|
1717
|
+
cumsum = np.cumsum(sorted_w)
|
|
1718
|
+
cumsum_norm = cumsum / cumsum[-1]
|
|
1719
|
+
t10 = sorted_t[np.searchsorted(cumsum_norm, 0.10)]
|
|
1720
|
+
t90 = sorted_t[np.searchsorted(cumsum_norm, 0.90)]
|
|
1721
|
+
spread = t90 - t10
|
|
1722
|
+
|
|
1723
|
+
ax.axvline(t10, color="red", linestyle="--", linewidth=1.5, alpha=0.8)
|
|
1724
|
+
ax.axvline(t90, color="red", linestyle="--", linewidth=1.5, alpha=0.8)
|
|
1725
|
+
ax.axvspan(t10, t90, alpha=0.15, color="red")
|
|
1726
|
+
|
|
1727
|
+
ax.set_title(
|
|
1728
|
+
f"R{ring_idx} az={az_center:.1f}\u00b0\n"
|
|
1729
|
+
f"d={bin_info['distance_km']:.0f}km n={n_rays} \u0394t={spread:.1f}ns",
|
|
1730
|
+
fontsize=9,
|
|
1731
|
+
)
|
|
1732
|
+
else:
|
|
1733
|
+
ax.set_title(f"R{ring_idx} az={az_center:.1f}\u00b0 (no data)", fontsize=9)
|
|
1734
|
+
|
|
1735
|
+
ax.set_xlabel("Time (ns)", fontsize=9)
|
|
1736
|
+
ax.set_ylabel("Intensity", fontsize=9)
|
|
1737
|
+
ax.grid(True, alpha=0.3)
|
|
1738
|
+
ax.set_xlim(left=0)
|
|
1739
|
+
|
|
1740
|
+
# Hide unused axes
|
|
1741
|
+
for i in range(n_plots, n_rows * n_cols):
|
|
1742
|
+
row, col = divmod(i, n_cols)
|
|
1743
|
+
axes[row, col].axis("off")
|
|
1744
|
+
|
|
1745
|
+
bin_size_km = rings.detector_radial_size / 1000
|
|
1746
|
+
|
|
1747
|
+
fig.suptitle(
|
|
1748
|
+
f"Per-Bin Timing Distributions (top {n_top} by ray count)\n"
|
|
1749
|
+
f"Constant physical size: {bin_size_km:.0f} km radial",
|
|
1750
|
+
fontsize=13,
|
|
1751
|
+
fontweight="bold",
|
|
1752
|
+
)
|
|
1753
|
+
fig.tight_layout()
|
|
1754
|
+
|
|
1755
|
+
if output_path:
|
|
1756
|
+
fig.savefig(output_path, dpi=dpi, bbox_inches="tight")
|
|
1757
|
+
|
|
1758
|
+
return fig
|
|
1759
|
+
|
|
1760
|
+
|
|
1761
|
+
def compute_ring_spread_stats(
|
|
1762
|
+
ray_positions: np.ndarray,
|
|
1763
|
+
ray_times: np.ndarray,
|
|
1764
|
+
ray_intensities: np.ndarray,
|
|
1765
|
+
rings: ConstantSizeDetectorRings,
|
|
1766
|
+
az_limit: float = 10.0,
|
|
1767
|
+
n_az_bins: int = 40,
|
|
1768
|
+
) -> list:
|
|
1769
|
+
"""
|
|
1770
|
+
Compute per-ring spread statistics (azimuthal and timing).
|
|
1771
|
+
|
|
1772
|
+
Parameters
|
|
1773
|
+
----------
|
|
1774
|
+
ray_positions : ndarray
|
|
1775
|
+
Ray detection positions (N, 3)
|
|
1776
|
+
ray_times : ndarray
|
|
1777
|
+
Ray detection times (N,)
|
|
1778
|
+
ray_intensities : ndarray
|
|
1779
|
+
Ray intensities (N,)
|
|
1780
|
+
rings : ConstantSizeDetectorRings
|
|
1781
|
+
Detector ring configuration
|
|
1782
|
+
az_limit : float
|
|
1783
|
+
Azimuth limit in degrees (filter to +/- this range)
|
|
1784
|
+
n_az_bins : int
|
|
1785
|
+
Number of azimuth bins within the range
|
|
1786
|
+
|
|
1787
|
+
Returns
|
|
1788
|
+
-------
|
|
1789
|
+
list of dict
|
|
1790
|
+
Per-ring statistics with keys: ring_idx, n_rays, az_std,
|
|
1791
|
+
time_spread_ns, dist_km, total_intensity
|
|
1792
|
+
"""
|
|
1793
|
+
n_rings = rings.n_rings
|
|
1794
|
+
ring_boundaries_deg = rings.ring_boundaries_deg
|
|
1795
|
+
ring_distances = rings.ring_distances
|
|
1796
|
+
|
|
1797
|
+
# Compute elevation and azimuth
|
|
1798
|
+
horizontal_dist = np.sqrt(ray_positions[:, 0] ** 2 + ray_positions[:, 1] ** 2)
|
|
1799
|
+
vertical_dist = ray_positions[:, 2]
|
|
1800
|
+
ray_elevation_deg = np.degrees(np.arctan2(vertical_dist, horizontal_dist))
|
|
1801
|
+
ray_azimuth_deg = np.degrees(np.arctan2(ray_positions[:, 1], ray_positions[:, 0]))
|
|
1802
|
+
|
|
1803
|
+
# Assign rays to rings
|
|
1804
|
+
ray_ring_indices = (
|
|
1805
|
+
np.searchsorted(-ring_boundaries_deg[:-1], -ray_elevation_deg, side="right") - 1
|
|
1806
|
+
)
|
|
1807
|
+
ray_ring_indices = np.clip(ray_ring_indices, 0, n_rings - 1)
|
|
1808
|
+
|
|
1809
|
+
az_bin_width = 2 * az_limit / n_az_bins
|
|
1810
|
+
ring_stats = []
|
|
1811
|
+
|
|
1812
|
+
for ring_idx in range(n_rings):
|
|
1813
|
+
ring_mask = (ray_ring_indices == ring_idx) & (
|
|
1814
|
+
np.abs(ray_azimuth_deg) <= az_limit
|
|
1815
|
+
)
|
|
1816
|
+
n_rays_in_ring = np.sum(ring_mask)
|
|
1817
|
+
|
|
1818
|
+
if n_rays_in_ring >= 3:
|
|
1819
|
+
ring_az = ray_azimuth_deg[ring_mask]
|
|
1820
|
+
ring_int = ray_intensities[ring_mask]
|
|
1821
|
+
|
|
1822
|
+
az_std = np.std(ring_az)
|
|
1823
|
+
|
|
1824
|
+
# Compute time spread per azimuth bin, then take median
|
|
1825
|
+
bin_time_spreads = []
|
|
1826
|
+
for az_bin in range(n_az_bins):
|
|
1827
|
+
az_lo = -az_limit + az_bin * az_bin_width
|
|
1828
|
+
az_hi = az_lo + az_bin_width
|
|
1829
|
+
|
|
1830
|
+
bin_mask = (
|
|
1831
|
+
(ray_ring_indices == ring_idx)
|
|
1832
|
+
& (ray_azimuth_deg >= az_lo)
|
|
1833
|
+
& (ray_azimuth_deg < az_hi)
|
|
1834
|
+
)
|
|
1835
|
+
|
|
1836
|
+
n_in_bin = np.sum(bin_mask)
|
|
1837
|
+
if n_in_bin >= 3:
|
|
1838
|
+
bin_times_abs = ray_times[bin_mask]
|
|
1839
|
+
bin_int = ray_intensities[bin_mask]
|
|
1840
|
+
|
|
1841
|
+
bin_first = np.min(bin_times_abs)
|
|
1842
|
+
bin_times_rel = (bin_times_abs - bin_first) * 1e9
|
|
1843
|
+
|
|
1844
|
+
if np.sum(bin_int) > 0:
|
|
1845
|
+
sorted_idx = np.argsort(bin_times_rel)
|
|
1846
|
+
sorted_t = bin_times_rel[sorted_idx]
|
|
1847
|
+
sorted_w = bin_int[sorted_idx]
|
|
1848
|
+
cumsum = np.cumsum(sorted_w)
|
|
1849
|
+
cumsum_norm = cumsum / cumsum[-1]
|
|
1850
|
+
t10 = sorted_t[np.searchsorted(cumsum_norm, 0.10)]
|
|
1851
|
+
t90 = sorted_t[np.searchsorted(cumsum_norm, 0.90)]
|
|
1852
|
+
bin_time_spreads.append(t90 - t10)
|
|
1853
|
+
|
|
1854
|
+
time_spread = np.median(bin_time_spreads) if bin_time_spreads else 0
|
|
1855
|
+
|
|
1856
|
+
ring_stats.append(
|
|
1857
|
+
{
|
|
1858
|
+
"ring_idx": ring_idx,
|
|
1859
|
+
"n_rays": n_rays_in_ring,
|
|
1860
|
+
"az_std": az_std,
|
|
1861
|
+
"time_spread_ns": time_spread,
|
|
1862
|
+
"dist_km": ring_distances[ring_idx] / 1000,
|
|
1863
|
+
"total_intensity": np.sum(ring_int),
|
|
1864
|
+
}
|
|
1865
|
+
)
|
|
1866
|
+
|
|
1867
|
+
return ring_stats
|