lsurf 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. lsurf/__init__.py +471 -0
  2. lsurf/analysis/__init__.py +107 -0
  3. lsurf/analysis/healpix_utils.py +418 -0
  4. lsurf/analysis/sphere_viz.py +1280 -0
  5. lsurf/cli/__init__.py +48 -0
  6. lsurf/cli/build.py +398 -0
  7. lsurf/cli/config_schema.py +318 -0
  8. lsurf/cli/gui_cmd.py +76 -0
  9. lsurf/cli/interactive.py +850 -0
  10. lsurf/cli/main.py +81 -0
  11. lsurf/cli/run.py +806 -0
  12. lsurf/detectors/__init__.py +266 -0
  13. lsurf/detectors/analysis.py +289 -0
  14. lsurf/detectors/base.py +284 -0
  15. lsurf/detectors/constant_size_rings.py +485 -0
  16. lsurf/detectors/directional.py +45 -0
  17. lsurf/detectors/extended/__init__.py +73 -0
  18. lsurf/detectors/extended/local_sphere.py +353 -0
  19. lsurf/detectors/extended/recording_sphere.py +368 -0
  20. lsurf/detectors/planar.py +45 -0
  21. lsurf/detectors/protocol.py +187 -0
  22. lsurf/detectors/recording_spheres.py +63 -0
  23. lsurf/detectors/results.py +1140 -0
  24. lsurf/detectors/small/__init__.py +79 -0
  25. lsurf/detectors/small/directional.py +330 -0
  26. lsurf/detectors/small/planar.py +401 -0
  27. lsurf/detectors/small/spherical.py +450 -0
  28. lsurf/detectors/spherical.py +45 -0
  29. lsurf/geometry/__init__.py +199 -0
  30. lsurf/geometry/builder.py +478 -0
  31. lsurf/geometry/cell.py +228 -0
  32. lsurf/geometry/cell_geometry.py +247 -0
  33. lsurf/geometry/detector_arrays.py +1785 -0
  34. lsurf/geometry/geometry.py +222 -0
  35. lsurf/geometry/surface_analysis.py +375 -0
  36. lsurf/geometry/validation.py +91 -0
  37. lsurf/gui/__init__.py +51 -0
  38. lsurf/gui/app.py +903 -0
  39. lsurf/gui/core/__init__.py +39 -0
  40. lsurf/gui/core/scene.py +343 -0
  41. lsurf/gui/core/simulation.py +264 -0
  42. lsurf/gui/renderers/__init__.py +40 -0
  43. lsurf/gui/renderers/ray_renderer.py +353 -0
  44. lsurf/gui/renderers/source_renderer.py +505 -0
  45. lsurf/gui/renderers/surface_renderer.py +477 -0
  46. lsurf/gui/views/__init__.py +48 -0
  47. lsurf/gui/views/config_editor.py +3199 -0
  48. lsurf/gui/views/properties.py +257 -0
  49. lsurf/gui/views/results.py +291 -0
  50. lsurf/gui/views/scene_tree.py +180 -0
  51. lsurf/gui/views/viewport_3d.py +555 -0
  52. lsurf/gui/views/visualizations.py +712 -0
  53. lsurf/materials/__init__.py +169 -0
  54. lsurf/materials/base/__init__.py +64 -0
  55. lsurf/materials/base/full_inhomogeneous.py +208 -0
  56. lsurf/materials/base/grid_inhomogeneous.py +319 -0
  57. lsurf/materials/base/homogeneous.py +342 -0
  58. lsurf/materials/base/material_field.py +527 -0
  59. lsurf/materials/base/simple_inhomogeneous.py +418 -0
  60. lsurf/materials/base/spectral_inhomogeneous.py +497 -0
  61. lsurf/materials/implementations/__init__.py +120 -0
  62. lsurf/materials/implementations/data/alpha_values_typical_atmosphere_updated.txt +24 -0
  63. lsurf/materials/implementations/duct_atmosphere.py +390 -0
  64. lsurf/materials/implementations/exponential_atmosphere.py +435 -0
  65. lsurf/materials/implementations/gaussian_lens.py +120 -0
  66. lsurf/materials/implementations/interpolated_data.py +123 -0
  67. lsurf/materials/implementations/layered_atmosphere.py +134 -0
  68. lsurf/materials/implementations/linear_gradient.py +109 -0
  69. lsurf/materials/implementations/linsley_atmosphere.py +764 -0
  70. lsurf/materials/implementations/standard_materials.py +126 -0
  71. lsurf/materials/implementations/turbulent_atmosphere.py +135 -0
  72. lsurf/materials/implementations/us_standard_atmosphere.py +149 -0
  73. lsurf/materials/utils/__init__.py +77 -0
  74. lsurf/materials/utils/constants.py +45 -0
  75. lsurf/materials/utils/device_functions.py +117 -0
  76. lsurf/materials/utils/dispersion.py +160 -0
  77. lsurf/materials/utils/factories.py +142 -0
  78. lsurf/propagation/__init__.py +91 -0
  79. lsurf/propagation/detector_gpu.py +67 -0
  80. lsurf/propagation/gpu_device_rays.py +294 -0
  81. lsurf/propagation/kernels/__init__.py +175 -0
  82. lsurf/propagation/kernels/absorption/__init__.py +61 -0
  83. lsurf/propagation/kernels/absorption/grid.py +240 -0
  84. lsurf/propagation/kernels/absorption/simple.py +232 -0
  85. lsurf/propagation/kernels/absorption/spectral.py +410 -0
  86. lsurf/propagation/kernels/detection/__init__.py +64 -0
  87. lsurf/propagation/kernels/detection/protocol.py +102 -0
  88. lsurf/propagation/kernels/detection/spherical.py +255 -0
  89. lsurf/propagation/kernels/device_functions.py +790 -0
  90. lsurf/propagation/kernels/fresnel/__init__.py +64 -0
  91. lsurf/propagation/kernels/fresnel/protocol.py +97 -0
  92. lsurf/propagation/kernels/fresnel/standard.py +258 -0
  93. lsurf/propagation/kernels/intersection/__init__.py +79 -0
  94. lsurf/propagation/kernels/intersection/annular_plane.py +207 -0
  95. lsurf/propagation/kernels/intersection/bounded_plane.py +205 -0
  96. lsurf/propagation/kernels/intersection/plane.py +166 -0
  97. lsurf/propagation/kernels/intersection/protocol.py +95 -0
  98. lsurf/propagation/kernels/intersection/signed_distance.py +742 -0
  99. lsurf/propagation/kernels/intersection/sphere.py +190 -0
  100. lsurf/propagation/kernels/propagation/__init__.py +85 -0
  101. lsurf/propagation/kernels/propagation/grid.py +527 -0
  102. lsurf/propagation/kernels/propagation/protocol.py +105 -0
  103. lsurf/propagation/kernels/propagation/simple.py +460 -0
  104. lsurf/propagation/kernels/propagation/spectral.py +875 -0
  105. lsurf/propagation/kernels/registry.py +331 -0
  106. lsurf/propagation/kernels/surface/__init__.py +72 -0
  107. lsurf/propagation/kernels/surface/bisection.py +232 -0
  108. lsurf/propagation/kernels/surface/detection.py +402 -0
  109. lsurf/propagation/kernels/surface/reduction.py +166 -0
  110. lsurf/propagation/propagator_protocol.py +222 -0
  111. lsurf/propagation/propagators/__init__.py +101 -0
  112. lsurf/propagation/propagators/detector_handler.py +354 -0
  113. lsurf/propagation/propagators/factory.py +200 -0
  114. lsurf/propagation/propagators/fresnel_handler.py +305 -0
  115. lsurf/propagation/propagators/gpu_gradient.py +566 -0
  116. lsurf/propagation/propagators/gpu_surface_propagator.py +707 -0
  117. lsurf/propagation/propagators/gradient.py +429 -0
  118. lsurf/propagation/propagators/intersection_handler.py +327 -0
  119. lsurf/propagation/propagators/material_propagator.py +398 -0
  120. lsurf/propagation/propagators/signed_distance_handler.py +522 -0
  121. lsurf/propagation/propagators/spectral_gpu_gradient.py +553 -0
  122. lsurf/propagation/propagators/surface_interaction.py +616 -0
  123. lsurf/propagation/propagators/surface_propagator.py +719 -0
  124. lsurf/py.typed +1 -0
  125. lsurf/simulation/__init__.py +70 -0
  126. lsurf/simulation/config.py +164 -0
  127. lsurf/simulation/orchestrator.py +462 -0
  128. lsurf/simulation/result.py +299 -0
  129. lsurf/simulation/simulation.py +262 -0
  130. lsurf/sources/__init__.py +128 -0
  131. lsurf/sources/base.py +264 -0
  132. lsurf/sources/collimated.py +252 -0
  133. lsurf/sources/custom.py +409 -0
  134. lsurf/sources/diverging.py +228 -0
  135. lsurf/sources/gaussian.py +272 -0
  136. lsurf/sources/parallel_from_positions.py +197 -0
  137. lsurf/sources/point.py +172 -0
  138. lsurf/sources/uniform_diverging.py +258 -0
  139. lsurf/surfaces/__init__.py +184 -0
  140. lsurf/surfaces/cpu/__init__.py +50 -0
  141. lsurf/surfaces/cpu/curved_wave.py +463 -0
  142. lsurf/surfaces/cpu/gerstner_wave.py +381 -0
  143. lsurf/surfaces/cpu/wave_params.py +118 -0
  144. lsurf/surfaces/gpu/__init__.py +72 -0
  145. lsurf/surfaces/gpu/annular_plane.py +453 -0
  146. lsurf/surfaces/gpu/bounded_plane.py +390 -0
  147. lsurf/surfaces/gpu/curved_wave.py +483 -0
  148. lsurf/surfaces/gpu/gerstner_wave.py +377 -0
  149. lsurf/surfaces/gpu/multi_curved_wave.py +520 -0
  150. lsurf/surfaces/gpu/plane.py +299 -0
  151. lsurf/surfaces/gpu/recording_sphere.py +587 -0
  152. lsurf/surfaces/gpu/sphere.py +311 -0
  153. lsurf/surfaces/protocol.py +336 -0
  154. lsurf/surfaces/registry.py +373 -0
  155. lsurf/utilities/__init__.py +175 -0
  156. lsurf/utilities/detector_analysis.py +814 -0
  157. lsurf/utilities/fresnel.py +628 -0
  158. lsurf/utilities/interactions.py +1215 -0
  159. lsurf/utilities/propagation.py +602 -0
  160. lsurf/utilities/ray_data.py +532 -0
  161. lsurf/utilities/recording_sphere.py +745 -0
  162. lsurf/utilities/time_spread.py +463 -0
  163. lsurf/visualization/__init__.py +329 -0
  164. lsurf/visualization/absorption_plots.py +334 -0
  165. lsurf/visualization/atmospheric_plots.py +754 -0
  166. lsurf/visualization/common.py +348 -0
  167. lsurf/visualization/detector_plots.py +1350 -0
  168. lsurf/visualization/detector_sphere_plots.py +1173 -0
  169. lsurf/visualization/fresnel_plots.py +1061 -0
  170. lsurf/visualization/ocean_simulation_plots.py +999 -0
  171. lsurf/visualization/polarization_plots.py +916 -0
  172. lsurf/visualization/raytracing_plots.py +1521 -0
  173. lsurf/visualization/ring_detector_plots.py +1867 -0
  174. lsurf/visualization/time_spread_plots.py +531 -0
  175. lsurf-1.0.0.dist-info/METADATA +381 -0
  176. lsurf-1.0.0.dist-info/RECORD +180 -0
  177. lsurf-1.0.0.dist-info/WHEEL +5 -0
  178. lsurf-1.0.0.dist-info/entry_points.txt +2 -0
  179. lsurf-1.0.0.dist-info/licenses/LICENSE +32 -0
  180. lsurf-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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