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,1173 @@
1
+ # The Clear BSD License
2
+ #
3
+ # Copyright (c) 2026 Tobias Heibges
4
+ # All rights reserved.
5
+ #
6
+ # Redistribution and use in source and binary forms, with or without
7
+ # modification, are permitted (subject to the limitations in the disclaimer
8
+ # below) provided that the following conditions are met:
9
+ #
10
+ # * Redistributions of source code must retain the above copyright notice,
11
+ # this list of conditions and the following disclaimer.
12
+ #
13
+ # * Redistributions in binary form must reproduce the above copyright
14
+ # notice, this list of conditions and the following disclaimer in the
15
+ # documentation and/or other materials provided with the distribution.
16
+ #
17
+ # * Neither the name of the copyright holder nor the names of its
18
+ # contributors may be used to endorse or promote products derived from this
19
+ # software without specific prior written permission.
20
+ #
21
+ # NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
22
+ # THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
23
+ # CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
24
+ # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
25
+ # PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
26
+ # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
27
+ # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
28
+ # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
29
+ # BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
30
+ # IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
31
+ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
32
+ # POSSIBILITY OF SUCH DAMAGE.
33
+
34
+ """
35
+ Detector Sphere Visualization
36
+
37
+ Plotting functions for visualizing ray intersections with detector spheres,
38
+ energy density maps, Pareto front analysis, and geometry schematics.
39
+ """
40
+
41
+ from typing import TYPE_CHECKING
42
+
43
+ import matplotlib.pyplot as plt
44
+ import numpy as np
45
+
46
+ if TYPE_CHECKING:
47
+ from numpy.typing import NDArray
48
+
49
+ from ..utilities.recording_sphere import RecordedRays
50
+
51
+
52
+ def plot_geometry_schematic(
53
+ source_position: tuple[float, float, float],
54
+ beam_direction: tuple[float, float, float] | "NDArray",
55
+ grazing_angle_deg: float,
56
+ detector_altitude: float,
57
+ intersection_points: "NDArray",
58
+ reflected_directions: "NDArray",
59
+ n_rays_to_show: int = 20,
60
+ save_path: str | None = None,
61
+ ):
62
+ """
63
+ Plot a schematic overview of the simulation geometry.
64
+
65
+ Shows side view (x-z plane) with source, rays, ocean surface, and detector sphere.
66
+
67
+ Parameters
68
+ ----------
69
+ source_position : tuple
70
+ Source (x, y, z) position in meters
71
+ beam_direction : tuple or ndarray
72
+ Beam direction unit vector
73
+ grazing_angle_deg : float
74
+ Grazing angle in degrees
75
+ detector_altitude : float
76
+ Detector sphere radius in meters
77
+ intersection_points : ndarray, shape (N, 3)
78
+ XYZ coordinates of surface intersections
79
+ reflected_directions : ndarray, shape (N, 3)
80
+ Direction vectors of reflected rays
81
+ n_rays_to_show : int
82
+ Number of rays to display
83
+ save_path : str, optional
84
+ Path to save figure
85
+
86
+ Returns
87
+ -------
88
+ fig : matplotlib.figure.Figure
89
+ The created figure
90
+ """
91
+ fig, axes = plt.subplots(1, 2, figsize=(16, 7))
92
+
93
+ # =========================================================================
94
+ # Left panel: Side view schematic (x-z plane) - scaled to show detector
95
+ # =========================================================================
96
+ ax1 = axes[0]
97
+
98
+ # Scale to km for display
99
+ src_x, src_y, src_z = np.array(source_position) / 1000
100
+ det_r = detector_altitude / 1000
101
+
102
+ # Compute where reflected rays hit the detector sphere
103
+ hit_positions_km = []
104
+ for i in range(len(reflected_directions)):
105
+ rd = reflected_directions[i]
106
+ rd_norm = rd / np.linalg.norm(rd)
107
+ hit_x = det_r * rd_norm[0]
108
+ hit_z = det_r * rd_norm[2]
109
+ hit_positions_km.append((hit_x, hit_z))
110
+ hit_positions_km = np.array(hit_positions_km)
111
+
112
+ # Get the range of detector hits to focus the view
113
+ hit_x_min, hit_x_max = hit_positions_km[:, 0].min(), hit_positions_km[:, 0].max()
114
+ hit_z_min, hit_z_max = hit_positions_km[:, 1].min(), hit_positions_km[:, 1].max()
115
+
116
+ # Draw detector sphere arc (full upper hemisphere, faded)
117
+ theta_arc = np.linspace(0, np.pi, 200)
118
+ x_arc = det_r * np.cos(theta_arc)
119
+ z_arc = det_r * np.sin(theta_arc)
120
+ ax1.plot(x_arc, z_arc, "g-", linewidth=1.5, alpha=0.3)
121
+
122
+ # Highlight the portion where rays hit
123
+ theta_hit_min = np.arctan2(hit_z_min, hit_x_max)
124
+ theta_hit_max = np.arctan2(hit_z_max, hit_x_min)
125
+ theta_highlight = np.linspace(theta_hit_min - 0.02, theta_hit_max + 0.02, 50)
126
+ x_highlight = det_r * np.cos(theta_highlight)
127
+ z_highlight = det_r * np.sin(theta_highlight)
128
+ ax1.plot(
129
+ x_highlight,
130
+ z_highlight,
131
+ "g-",
132
+ linewidth=3,
133
+ label=f"Detector sphere (r={det_r:.0f} km)",
134
+ )
135
+
136
+ # Draw ocean surface following Earth curvature
137
+ # Earth center is at (0, 0, -EARTH_RADIUS), surface at x is at z = sqrt(R^2 - x^2) - R
138
+ from ..surfaces import EARTH_RADIUS
139
+
140
+ earth_r_km = EARTH_RADIUS / 1000
141
+ x_ocean_min = min(src_x - 1, -det_r * 0.15)
142
+ x_ocean_max = det_r # Extend to detector sphere radius
143
+ x_ocean = np.linspace(x_ocean_min, x_ocean_max, 300)
144
+ # Curved Earth surface (for x in km, result in km)
145
+ z_ocean_curved = np.sqrt(earth_r_km**2 - x_ocean**2) - earth_r_km
146
+ # Add small decorative waves on top of curvature
147
+ z_ocean = z_ocean_curved + 0.0003 * det_r * np.sin(80 * x_ocean / det_r)
148
+ ax1.fill_between(
149
+ x_ocean,
150
+ z_ocean,
151
+ z_ocean.min() - det_r * 0.01,
152
+ color="lightblue",
153
+ alpha=0.5,
154
+ label="Ocean",
155
+ )
156
+ ax1.plot(x_ocean, z_ocean, "b-", linewidth=2)
157
+
158
+ # Draw source
159
+ ax1.plot(src_x, src_z, "ro", markersize=10, label="Source", zorder=10)
160
+
161
+ # Draw representative rays
162
+ # Use minimum of intersection_points and reflected_directions sizes
163
+ n_total = min(len(intersection_points), len(reflected_directions))
164
+ indices = np.linspace(0, n_total - 1, min(n_rays_to_show, n_total), dtype=int)
165
+
166
+ for idx in indices:
167
+ # Incoming ray: source to intersection (near origin)
168
+ int_x, int_y, int_z = intersection_points[idx] / 1000
169
+ ax1.plot([src_x, int_x], [src_z, int_z], "r-", alpha=0.2, linewidth=0.8)
170
+
171
+ # Reflected ray: intersection to detector sphere
172
+ rd = reflected_directions[idx]
173
+ rd_norm = rd / np.linalg.norm(rd)
174
+ det_x = det_r * rd_norm[0]
175
+ det_z = det_r * rd_norm[2]
176
+ ax1.plot([int_x, det_x], [int_z, det_z], "orange", alpha=0.2, linewidth=0.8)
177
+
178
+ # Draw chief ray more prominently
179
+ mid_idx = n_total // 2
180
+ int_x, int_y, int_z = intersection_points[mid_idx] / 1000
181
+ ax1.plot([src_x, int_x], [src_z, int_z], "r-", linewidth=2, label="Incident rays")
182
+ rd = reflected_directions[mid_idx]
183
+ rd_norm = rd / np.linalg.norm(rd)
184
+ det_x = det_r * rd_norm[0]
185
+ det_z = det_r * rd_norm[2]
186
+ ax1.plot(
187
+ [int_x, det_x], [int_z, det_z], "orange", linewidth=2, label="Reflected rays"
188
+ )
189
+
190
+ # Mark key points
191
+ ax1.plot(0, 0, "k*", markersize=12, label="Reflection point", zorder=10)
192
+ ax1.plot(det_x, det_z, "g*", markersize=10, label="Detection region", zorder=10)
193
+
194
+ # Draw grazing angle arc (scaled to be visible)
195
+ arc_r = det_r * 0.06
196
+ theta_graze = np.linspace(0, np.radians(grazing_angle_deg), 20)
197
+ x_graze = -arc_r * np.cos(theta_graze)
198
+ z_graze = -arc_r * np.sin(theta_graze)
199
+ ax1.plot(x_graze, z_graze, "m-", linewidth=2)
200
+ ax1.annotate(
201
+ f"{grazing_angle_deg}°",
202
+ (-arc_r * 0.7, -arc_r * 0.5),
203
+ fontsize=10,
204
+ color="purple",
205
+ )
206
+
207
+ # Set axis limits to show full geometry including source and detector hit region
208
+ x_left = min(src_x - 1, -det_r * 0.15)
209
+ ax1.set_xlim(x_left, det_r * 1.05)
210
+ # Adjust y-limits to show curved ocean surface
211
+ z_min = min(z_ocean.min(), -det_r * 0.03)
212
+ ax1.set_ylim(z_min - det_r * 0.01, det_r * 0.35)
213
+ ax1.set_xlabel("X (km)", fontsize=12)
214
+ ax1.set_ylabel("Z (km)", fontsize=12)
215
+ ax1.set_title(
216
+ "Geometry Schematic - Side View (X-Z plane)", fontsize=14, fontweight="bold"
217
+ )
218
+ ax1.legend(loc="upper left", fontsize=9)
219
+ ax1.grid(True, alpha=0.3)
220
+
221
+ # =========================================================================
222
+ # Right panel: Top view with annotations
223
+ # =========================================================================
224
+ ax2 = axes[1]
225
+
226
+ # Draw intersection points footprint
227
+ ix_all = intersection_points[:, 0]
228
+ iy_all = intersection_points[:, 1]
229
+ ax2.scatter(ix_all, iy_all, c="blue", s=1, alpha=0.3, label="Ray footprint")
230
+
231
+ # Draw source projection
232
+ ax2.plot(
233
+ source_position[0],
234
+ source_position[1],
235
+ "ro",
236
+ markersize=12,
237
+ label="Source (projected)",
238
+ )
239
+
240
+ # Draw reflection center
241
+ ax2.plot(0, 0, "k*", markersize=15, label="Reflection center")
242
+
243
+ # Draw beam spread arrows
244
+ ax2.annotate(
245
+ "",
246
+ xy=(np.mean(ix_all), np.max(iy_all)),
247
+ xytext=(source_position[0], source_position[1]),
248
+ arrowprops=dict(arrowstyle="->", color="red", alpha=0.5),
249
+ )
250
+ ax2.annotate(
251
+ "",
252
+ xy=(np.mean(ix_all), np.min(iy_all)),
253
+ xytext=(source_position[0], source_position[1]),
254
+ arrowprops=dict(arrowstyle="->", color="red", alpha=0.5),
255
+ )
256
+
257
+ ax2.set_xlabel("X (m)", fontsize=12)
258
+ ax2.set_ylabel("Y (m)", fontsize=12)
259
+ ax2.set_title("Geometry - Top View (X-Y plane)", fontsize=14, fontweight="bold")
260
+ ax2.legend(loc="upper right", fontsize=9)
261
+ ax2.set_aspect("equal")
262
+ ax2.grid(True, alpha=0.3)
263
+
264
+ plt.tight_layout()
265
+
266
+ if save_path:
267
+ plt.savefig(save_path, dpi=150, bbox_inches="tight")
268
+ print(f" Saved: {save_path}")
269
+
270
+ return fig
271
+
272
+
273
+ def plot_ocean_intersections_top_view(
274
+ intersection_points: "NDArray",
275
+ wave_surface=None,
276
+ n_bins: int = 100,
277
+ save_path: str | None = None,
278
+ ):
279
+ """
280
+ Plot top-down heatmap of where rays hit the ocean surface.
281
+
282
+ Parameters
283
+ ----------
284
+ intersection_points : ndarray, shape (N, 3)
285
+ XYZ coordinates of surface intersections
286
+ wave_surface : CurvedWaveSurface, optional
287
+ The ocean surface for context (not currently used)
288
+ n_bins : int
289
+ Number of bins for the 2D histogram
290
+ save_path : str, optional
291
+ Path to save figure
292
+
293
+ Returns
294
+ -------
295
+ fig : matplotlib.figure.Figure
296
+ The created figure
297
+ """
298
+ from matplotlib.colors import LogNorm
299
+
300
+ fig, ax = plt.subplots(figsize=(12, 10))
301
+
302
+ x = intersection_points[:, 0]
303
+ y = intersection_points[:, 1]
304
+
305
+ # Create 2D histogram
306
+ hist, x_edges, y_edges = np.histogram2d(x, y, bins=n_bins)
307
+
308
+ # Replace zeros with small value for log scale
309
+ hist_log = np.where(hist > 0, hist, np.nan)
310
+
311
+ # Plot as heatmap with log scale
312
+ im = ax.pcolormesh(
313
+ x_edges,
314
+ y_edges,
315
+ hist_log.T,
316
+ cmap="hot",
317
+ shading="auto",
318
+ norm=LogNorm(vmin=1, vmax=hist.max()),
319
+ )
320
+
321
+ # Add colorbar
322
+ cbar = plt.colorbar(im, ax=ax)
323
+ cbar.set_label("Ray count per bin (log scale)", fontsize=11)
324
+
325
+ # Add coordinate grid for reference
326
+ ax.grid(True, alpha=0.3, color="white", linewidth=0.5)
327
+ ax.set_xlabel("X (m)", fontsize=12)
328
+ ax.set_ylabel("Y (m)", fontsize=12)
329
+ ax.set_title(
330
+ "Ocean Surface Intersections - Top View", fontsize=14, fontweight="bold"
331
+ )
332
+ ax.set_aspect("equal")
333
+
334
+ plt.tight_layout()
335
+
336
+ if save_path:
337
+ plt.savefig(save_path, dpi=150, bbox_inches="tight")
338
+ print(f" Saved: {save_path}")
339
+
340
+ return fig
341
+
342
+
343
+ def plot_3d_intersection_scatter(
344
+ intersection_points: "NDArray",
345
+ save_path: str | None = None,
346
+ ):
347
+ """
348
+ 3D scatter plot of ocean surface intersection points with equal axes.
349
+
350
+ Parameters
351
+ ----------
352
+ intersection_points : ndarray, shape (N, 3)
353
+ XYZ coordinates of surface intersections
354
+ save_path : str, optional
355
+ Path to save figure
356
+
357
+ Returns
358
+ -------
359
+ fig : matplotlib.figure.Figure
360
+ The created figure
361
+ """
362
+ fig = plt.figure(figsize=(12, 10))
363
+ ax = fig.add_subplot(111, projection="3d")
364
+
365
+ # Plot points
366
+ ax.scatter(
367
+ intersection_points[:, 0],
368
+ intersection_points[:, 1],
369
+ intersection_points[:, 2],
370
+ c=intersection_points[:, 2], # Color by height
371
+ cmap="viridis",
372
+ s=1,
373
+ alpha=0.6,
374
+ )
375
+
376
+ # Set equal aspect ratio
377
+ max_range = np.ptp(intersection_points, axis=0).max() / 2.0
378
+ mid_x = (intersection_points[:, 0].max() + intersection_points[:, 0].min()) / 2.0
379
+ mid_y = (intersection_points[:, 1].max() + intersection_points[:, 1].min()) / 2.0
380
+ mid_z = (intersection_points[:, 2].max() + intersection_points[:, 2].min()) / 2.0
381
+
382
+ ax.set_xlim(mid_x - max_range, mid_x + max_range)
383
+ ax.set_ylim(mid_y - max_range, mid_y + max_range)
384
+ ax.set_zlim(mid_z - max_range, mid_z + max_range)
385
+
386
+ ax.set_xlabel("X (m)", fontsize=11)
387
+ ax.set_ylabel("Y (m)", fontsize=11)
388
+ ax.set_zlabel("Z (m)", fontsize=11)
389
+ ax.set_title("3D Ocean Surface Intersections", fontsize=14, fontweight="bold")
390
+
391
+ plt.tight_layout()
392
+
393
+ if save_path:
394
+ plt.savefig(save_path, dpi=150, bbox_inches="tight")
395
+ print(f" Saved: {save_path}")
396
+
397
+ return fig
398
+
399
+
400
+ def plot_pareto_front(
401
+ pareto_result: dict,
402
+ time_threshold_ns: float = 10.0,
403
+ source_power: float = 1.0,
404
+ save_path: str | None = None,
405
+ ):
406
+ """
407
+ Plot the Pareto front of energy density vs time spread.
408
+
409
+ Parameters
410
+ ----------
411
+ pareto_result : dict
412
+ Result from compute_pareto_front()
413
+ time_threshold_ns : float
414
+ Detector time resolution threshold (ns)
415
+ source_power : float
416
+ Input source power for normalization (W)
417
+ save_path : str, optional
418
+ Path to save figure
419
+
420
+ Returns
421
+ -------
422
+ fig : matplotlib.figure.Figure or None
423
+ The created figure, or None if no data
424
+ """
425
+ bin_data = pareto_result["bin_data"]
426
+ pareto_front = pareto_result["pareto_front"]
427
+
428
+ if len(bin_data) == 0:
429
+ print(" No bins with sufficient rays for Pareto analysis")
430
+ return None
431
+
432
+ # Extract data and normalize by source power
433
+ all_energy = pareto_result["all_energy_densities"] / source_power
434
+ all_time = pareto_result["all_time_spreads"]
435
+
436
+ pareto_energy = np.array([p["energy_density"] / source_power for p in pareto_front])
437
+ pareto_time = np.array([p["time_spread_ns"] for p in pareto_front])
438
+
439
+ fig, axes = plt.subplots(1, 2, figsize=(16, 6))
440
+
441
+ # Left: Pareto front scatter plot
442
+ ax1 = axes[0]
443
+
444
+ # Plot all bins
445
+ ax1.scatter(all_time, all_energy, c="lightgray", s=20, alpha=0.6, label="All bins")
446
+
447
+ # Plot Pareto front
448
+ ax1.scatter(
449
+ pareto_time,
450
+ pareto_energy,
451
+ c="red",
452
+ s=60,
453
+ alpha=0.9,
454
+ label="Pareto front",
455
+ zorder=5,
456
+ )
457
+
458
+ # Connect Pareto front points
459
+ sort_idx = np.argsort(pareto_time)
460
+ ax1.plot(
461
+ pareto_time[sort_idx],
462
+ pareto_energy[sort_idx],
463
+ "r-",
464
+ linewidth=1.5,
465
+ alpha=0.7,
466
+ zorder=4,
467
+ )
468
+
469
+ # Mark threshold
470
+ ax1.axvline(
471
+ x=time_threshold_ns,
472
+ color="blue",
473
+ linestyle="--",
474
+ linewidth=2,
475
+ label=f"Time threshold ({time_threshold_ns} ns)",
476
+ )
477
+
478
+ # Find and annotate key points
479
+ # Best within threshold (highest energy where time < threshold)
480
+ within_threshold = [
481
+ p for p in pareto_front if p["time_spread_ns"] < time_threshold_ns
482
+ ]
483
+ if within_threshold:
484
+ best_within = max(within_threshold, key=lambda x: x["energy_density"])
485
+ ax1.scatter(
486
+ [best_within["time_spread_ns"]],
487
+ [best_within["energy_density"] / source_power],
488
+ c="green",
489
+ s=200,
490
+ marker="*",
491
+ edgecolor="black",
492
+ linewidth=1.5,
493
+ label="Best within threshold",
494
+ zorder=10,
495
+ )
496
+ ax1.annotate(
497
+ f"({best_within['lon_deg']:.1f}, {best_within['lat_deg']:.1f})",
498
+ (
499
+ best_within["time_spread_ns"],
500
+ best_within["energy_density"] / source_power,
501
+ ),
502
+ xytext=(10, 10),
503
+ textcoords="offset points",
504
+ fontsize=9,
505
+ arrowprops=dict(arrowstyle="->", color="green"),
506
+ )
507
+
508
+ # Highest energy on Pareto front
509
+ if len(pareto_front) > 0:
510
+ best_energy = max(pareto_front, key=lambda x: x["energy_density"])
511
+ if not within_threshold or best_energy != best_within:
512
+ ax1.scatter(
513
+ [best_energy["time_spread_ns"]],
514
+ [best_energy["energy_density"] / source_power],
515
+ c="orange",
516
+ s=150,
517
+ marker="D",
518
+ edgecolor="black",
519
+ linewidth=1.5,
520
+ label="Highest energy",
521
+ zorder=9,
522
+ )
523
+
524
+ ax1.set_xlabel("Time Spread (90th-10th percentile) [ns]", fontsize=12)
525
+ ax1.set_ylabel("Normalized Energy Density (sr^-1)", fontsize=12)
526
+ ax1.set_title(
527
+ "Pareto Front: Energy Density vs Time Spread", fontsize=14, fontweight="bold"
528
+ )
529
+ ax1.legend(loc="upper right", fontsize=9)
530
+ ax1.grid(True, alpha=0.3)
531
+ ax1.set_xlim(left=0)
532
+ ax1.set_ylim(bottom=0)
533
+
534
+ # Right: Spatial map of Pareto-optimal bins
535
+ ax2 = axes[1]
536
+
537
+ lon_all = np.array([b["lon_deg"] for b in bin_data])
538
+ lat_all = np.array([b["lat_deg"] for b in bin_data])
539
+ energy_all = np.array([b["energy_density"] / source_power for b in bin_data])
540
+ time_all = np.array([b["time_spread_ns"] for b in bin_data])
541
+
542
+ # Color by normalized energy density, size by inverse time spread
543
+ sizes = 20 + 80 * (1 - time_all / max(time_all)) # Smaller time = larger marker
544
+
545
+ sc2 = ax2.scatter(
546
+ lon_all,
547
+ lat_all,
548
+ c=energy_all,
549
+ s=sizes,
550
+ alpha=0.7,
551
+ cmap="hot",
552
+ edgecolors="none",
553
+ )
554
+
555
+ # Highlight Pareto front
556
+ lon_pareto = np.array([p["lon_deg"] for p in pareto_front])
557
+ lat_pareto = np.array([p["lat_deg"] for p in pareto_front])
558
+ ax2.scatter(
559
+ lon_pareto,
560
+ lat_pareto,
561
+ facecolors="none",
562
+ edgecolors="red",
563
+ s=100,
564
+ linewidths=2,
565
+ label="Pareto front",
566
+ )
567
+
568
+ if within_threshold:
569
+ ax2.scatter(
570
+ [best_within["lon_deg"]],
571
+ [best_within["lat_deg"]],
572
+ c="green",
573
+ s=200,
574
+ marker="*",
575
+ edgecolor="black",
576
+ linewidth=1.5,
577
+ label="Best within threshold",
578
+ zorder=10,
579
+ )
580
+
581
+ ax2.set_xlabel("Longitude (deg)", fontsize=12)
582
+ ax2.set_ylabel("Latitude (deg)", fontsize=12)
583
+ ax2.set_title(
584
+ "Spatial Distribution (size = time resolution)", fontsize=14, fontweight="bold"
585
+ )
586
+ ax2.legend(loc="upper right", fontsize=9)
587
+ ax2.set_aspect("equal")
588
+ ax2.grid(True, alpha=0.3)
589
+
590
+ cbar2 = plt.colorbar(sc2, ax=ax2)
591
+ cbar2.set_label("Normalized Energy Density (sr^-1)", fontsize=11)
592
+
593
+ plt.tight_layout()
594
+
595
+ if save_path:
596
+ plt.savefig(save_path, dpi=150, bbox_inches="tight")
597
+ print(f" Saved: {save_path}")
598
+
599
+ return fig
600
+
601
+
602
+ def plot_energy_density_map(
603
+ peak_result: dict,
604
+ recorded_rays: "RecordedRays",
605
+ source_power: float = 1.0,
606
+ detector_center=None,
607
+ save_path: str | None = None,
608
+ ):
609
+ """
610
+ Plot energy density map on detector sphere with peak location marked.
611
+
612
+ Parameters
613
+ ----------
614
+ peak_result : dict
615
+ Result from find_peak_energy_density()
616
+ recorded_rays : RecordedRays
617
+ Recorded rays for overlay
618
+ source_power : float
619
+ Input source power for normalization (W)
620
+ detector_center : array-like, optional
621
+ Center of detector sphere (not used currently)
622
+ save_path : str, optional
623
+ Path to save figure
624
+
625
+ Returns
626
+ -------
627
+ fig : matplotlib.figure.Figure
628
+ The created figure
629
+ """
630
+ fig, axes = plt.subplots(1, 2, figsize=(16, 6))
631
+
632
+ # Left: Energy density heatmap (normalized by source power)
633
+ ax1 = axes[0]
634
+ hist = peak_result["histogram"] / source_power # Normalize
635
+ lon_edges = peak_result["lon_edges"]
636
+ lat_edges = peak_result["lat_edges"]
637
+
638
+ # Plot heatmap
639
+ im = ax1.pcolormesh(
640
+ np.degrees(lon_edges),
641
+ np.degrees(lat_edges),
642
+ hist.T,
643
+ cmap="hot",
644
+ shading="auto",
645
+ )
646
+
647
+ # Mark peak location
648
+ ax1.plot(
649
+ peak_result["peak_lon_deg"],
650
+ peak_result["peak_lat_deg"],
651
+ "c*",
652
+ markersize=15,
653
+ markeredgecolor="white",
654
+ markeredgewidth=1.5,
655
+ label=f"Peak: ({peak_result['peak_lon_deg']:.2f}, {peak_result['peak_lat_deg']:.2f})",
656
+ )
657
+
658
+ ax1.set_xlabel("Longitude (deg)", fontsize=12)
659
+ ax1.set_ylabel("Latitude (deg)", fontsize=12)
660
+ ax1.set_title("Normalized Energy Density", fontsize=14, fontweight="bold")
661
+ ax1.legend(loc="upper right")
662
+
663
+ cbar1 = plt.colorbar(im, ax=ax1)
664
+ cbar1.set_label("Energy Density / Input Power (sr^-1)", fontsize=11)
665
+
666
+ # Right: Scatter plot with peak marked
667
+ ax2 = axes[1]
668
+ positions = recorded_rays.positions
669
+ x, y, z = positions[:, 0], positions[:, 1], positions[:, 2]
670
+ r = np.sqrt(x**2 + y**2 + z**2)
671
+ lat = np.degrees(np.arcsin(z / r))
672
+ lon = np.degrees(np.arctan2(y, x))
673
+
674
+ sc = ax2.scatter(
675
+ lon,
676
+ lat,
677
+ c=recorded_rays.intensities,
678
+ s=3,
679
+ alpha=0.6,
680
+ cmap="hot",
681
+ vmin=0,
682
+ vmax=np.percentile(recorded_rays.intensities, 95),
683
+ )
684
+
685
+ ax2.plot(
686
+ peak_result["peak_lon_deg"],
687
+ peak_result["peak_lat_deg"],
688
+ "c*",
689
+ markersize=15,
690
+ markeredgecolor="white",
691
+ markeredgewidth=1.5,
692
+ )
693
+
694
+ ax2.set_xlabel("Longitude (deg)", fontsize=12)
695
+ ax2.set_ylabel("Latitude (deg)", fontsize=12)
696
+ ax2.set_title("Ray Intersections with Peak", fontsize=14, fontweight="bold")
697
+ ax2.set_aspect("equal")
698
+
699
+ cbar2 = plt.colorbar(sc, ax=ax2)
700
+ cbar2.set_label("Intensity", fontsize=11)
701
+
702
+ plt.tight_layout()
703
+
704
+ if save_path:
705
+ plt.savefig(save_path, dpi=150, bbox_inches="tight")
706
+ print(f" Saved: {save_path}")
707
+
708
+ return fig
709
+
710
+
711
+ def plot_mollweide_detector_projection(
712
+ recorded_rays: "RecordedRays",
713
+ save_path: str | None = None,
714
+ ):
715
+ """
716
+ Mollweide projection showing where rays intersect the detector sphere.
717
+
718
+ Parameters
719
+ ----------
720
+ recorded_rays : RecordedRays
721
+ Recorded rays on detection sphere
722
+ save_path : str, optional
723
+ Path to save figure
724
+
725
+ Returns
726
+ -------
727
+ fig : matplotlib.figure.Figure
728
+ The created figure
729
+ """
730
+ # Convert Cartesian to spherical coordinates (lon, lat)
731
+ positions = recorded_rays.positions
732
+
733
+ # Compute longitude and latitude
734
+ x, y, z = positions[:, 0], positions[:, 1], positions[:, 2]
735
+ r = np.sqrt(x**2 + y**2 + z**2)
736
+
737
+ # Latitude: angle from equator (-90 to +90 degrees)
738
+ lat = np.arcsin(z / r)
739
+
740
+ # Longitude: angle in xy-plane (-180 to +180 degrees)
741
+ lon = np.arctan2(y, x)
742
+
743
+ # Create Mollweide projection
744
+ fig = plt.figure(figsize=(14, 8))
745
+ ax = fig.add_subplot(111, projection="mollweide")
746
+
747
+ # Plot as scatter with intensity coloring
748
+ sc = ax.scatter(
749
+ lon,
750
+ lat,
751
+ c=recorded_rays.intensities,
752
+ s=2,
753
+ alpha=0.5,
754
+ cmap="hot",
755
+ vmin=0,
756
+ vmax=np.percentile(
757
+ recorded_rays.intensities, 95
758
+ ), # Saturate at 95th percentile
759
+ )
760
+
761
+ ax.set_xlabel("Longitude (rad)", fontsize=12)
762
+ ax.set_ylabel("Latitude (rad)", fontsize=12)
763
+ ax.set_title(
764
+ "Detector Sphere Intersections - Mollweide Projection",
765
+ fontsize=14,
766
+ fontweight="bold",
767
+ )
768
+ ax.grid(True, alpha=0.3)
769
+
770
+ # Add colorbar
771
+ cbar = plt.colorbar(sc, ax=ax, pad=0.05, fraction=0.046)
772
+ cbar.set_label("Intensity", fontsize=11)
773
+
774
+ plt.tight_layout()
775
+
776
+ if save_path:
777
+ plt.savefig(save_path, dpi=150, bbox_inches="tight")
778
+ print(f" Saved: {save_path}")
779
+
780
+ return fig
781
+
782
+
783
+ def plot_arrival_time_distributions(
784
+ recorded_rays: "RecordedRays",
785
+ pareto_result: dict,
786
+ n_top: int = 10,
787
+ bins: int = 50,
788
+ time_threshold_ns: float = 10.0,
789
+ save_path: str | None = None,
790
+ ):
791
+ """
792
+ Plot arrival time histograms for the top N intensity bins.
793
+
794
+ Shows the actual distribution of arrival times (not just percentiles)
795
+ for each of the highest energy density detector bins.
796
+
797
+ Parameters
798
+ ----------
799
+ recorded_rays : RecordedRays
800
+ Recorded rays on detection sphere
801
+ pareto_result : dict
802
+ Result from compute_pareto_front()
803
+ n_top : int
804
+ Number of top intensity bins to plot
805
+ bins : int
806
+ Number of histogram bins
807
+ time_threshold_ns : float
808
+ Time threshold to mark on plots (ns)
809
+ save_path : str, optional
810
+ Path to save figure
811
+
812
+ Returns
813
+ -------
814
+ fig : matplotlib.figure.Figure or None
815
+ The created figure, or None if no data
816
+ """
817
+ bin_data = pareto_result["bin_data"]
818
+ if len(bin_data) == 0:
819
+ print(" No bin data for arrival time distributions")
820
+ return None
821
+
822
+ if len(bin_data) < n_top:
823
+ n_top = len(bin_data)
824
+
825
+ # Sort by energy density and take top N
826
+ sorted_bins = sorted(bin_data, key=lambda x: x["energy_density"], reverse=True)
827
+ top_bins = sorted_bins[:n_top]
828
+
829
+ # Get ray data
830
+ positions = recorded_rays.positions
831
+ intensities = recorded_rays.intensities
832
+ times = recorded_rays.times
833
+
834
+ # Convert to spherical coordinates
835
+ x, y, z = positions[:, 0], positions[:, 1], positions[:, 2]
836
+ r = np.sqrt(x**2 + y**2 + z**2)
837
+ lat = np.arcsin(z / r)
838
+ lon = np.arctan2(y, x)
839
+
840
+ # Infer bin edges from pareto_result
841
+ # Get the bin spacing from first bin's location
842
+ all_lons = np.array([b["lon"] for b in bin_data])
843
+ all_lats = np.array([b["lat"] for b in bin_data])
844
+ unique_lons = np.unique(all_lons)
845
+ unique_lats = np.unique(all_lats)
846
+ dlon = np.diff(unique_lons).min() if len(unique_lons) > 1 else 0.1
847
+ dlat = np.diff(unique_lats).min() if len(unique_lats) > 1 else 0.1
848
+
849
+ # Create figure with subplots for each bin
850
+ n_cols = min(5, n_top)
851
+ n_rows = (n_top + n_cols - 1) // n_cols
852
+ fig, axes = plt.subplots(n_rows, n_cols, figsize=(4 * n_cols, 3.5 * n_rows))
853
+ if n_top == 1:
854
+ axes = np.array([[axes]])
855
+ elif n_rows == 1:
856
+ axes = axes.reshape(1, -1)
857
+
858
+ # Find global time range for consistent x-axis
859
+ first_arrival = np.min(times)
860
+
861
+ for i, b in enumerate(top_bins):
862
+ row, col = divmod(i, n_cols)
863
+ ax = axes[row, col]
864
+
865
+ # Find rays in this bin
866
+ bin_lon = b["lon"]
867
+ bin_lat = b["lat"]
868
+ mask = (
869
+ (lon >= bin_lon - dlon / 2)
870
+ & (lon < bin_lon + dlon / 2)
871
+ & (lat >= bin_lat - dlat / 2)
872
+ & (lat < bin_lat + dlat / 2)
873
+ )
874
+
875
+ n_rays = np.sum(mask)
876
+ if n_rays == 0:
877
+ ax.text(
878
+ 0.5, 0.5, "No rays", ha="center", va="center", transform=ax.transAxes
879
+ )
880
+ ax.set_title(f"#{i+1}: ({b['lon_deg']:.1f}, {b['lat_deg']:.1f})°")
881
+ continue
882
+
883
+ bin_times = times[mask]
884
+ bin_intensities = intensities[mask]
885
+
886
+ # Convert to relative arrival time in ns
887
+ times_relative_ns = (bin_times - first_arrival) * 1e9
888
+
889
+ # Create histogram (intensity-weighted)
890
+ counts, edges = np.histogram(
891
+ times_relative_ns, bins=bins, weights=bin_intensities
892
+ )
893
+ centers = (edges[:-1] + edges[1:]) / 2
894
+
895
+ # Plot as step histogram
896
+ ax.fill_between(centers, counts, alpha=0.6, color="steelblue", step="mid")
897
+ ax.step(centers, counts, where="mid", color="steelblue", linewidth=1.5)
898
+
899
+ # Mark the 10th and 90th percentiles
900
+ from ..utilities.detector_analysis import weighted_percentile
901
+
902
+ t10 = weighted_percentile(times_relative_ns, bin_intensities, 10)
903
+ t90 = weighted_percentile(times_relative_ns, bin_intensities, 90)
904
+ ax.axvline(
905
+ t10,
906
+ color="red",
907
+ linestyle="--",
908
+ linewidth=1.5,
909
+ alpha=0.8,
910
+ label=f"10th: {t10:.1f} ns",
911
+ )
912
+ ax.axvline(
913
+ t90,
914
+ color="red",
915
+ linestyle="--",
916
+ linewidth=1.5,
917
+ alpha=0.8,
918
+ label=f"90th: {t90:.1f} ns",
919
+ )
920
+
921
+ # Mark time spread
922
+ spread = t90 - t10
923
+ ax.axvspan(t10, t90, alpha=0.15, color="red")
924
+
925
+ ax.set_xlabel("Relative Arrival Time (ns)", fontsize=9)
926
+ ax.set_ylabel("Intensity", fontsize=9)
927
+ ax.set_title(
928
+ f"#{i+1}: ({b['lon_deg']:.1f}, {b['lat_deg']:.1f})°\n"
929
+ f"n={n_rays}, spread={spread:.1f} ns",
930
+ fontsize=10,
931
+ )
932
+ ax.grid(True, alpha=0.3)
933
+ ax.set_xlim(left=0)
934
+
935
+ # Add legend only on first subplot
936
+ if i == 0:
937
+ ax.legend(fontsize=7, loc="upper right")
938
+
939
+ # Hide unused subplots
940
+ for i in range(n_top, n_rows * n_cols):
941
+ row, col = divmod(i, n_cols)
942
+ axes[row, col].axis("off")
943
+
944
+ fig.suptitle(
945
+ f"Arrival Time Distributions for Top {n_top} Energy Density Bins",
946
+ fontsize=14,
947
+ fontweight="bold",
948
+ )
949
+ plt.tight_layout()
950
+
951
+ if save_path:
952
+ plt.savefig(save_path, dpi=150, bbox_inches="tight")
953
+ print(f" Saved: {save_path}")
954
+
955
+ return fig
956
+
957
+
958
+ def plot_time_spread_comparison(
959
+ pareto_result: dict,
960
+ source_position: tuple[float, float, float],
961
+ beam_direction: tuple[float, float, float],
962
+ divergence_angle_rad: float,
963
+ detector_altitude: float,
964
+ surface=None,
965
+ n_top: int = 10,
966
+ source_power: float = 1.0,
967
+ time_threshold_ns: float = 10.0,
968
+ save_path: str | None = None,
969
+ ):
970
+ """
971
+ Compare ray-traced time spread with single-bounce geometric estimate.
972
+
973
+ The geometric estimate assumes a single reflection (source → surface → detector).
974
+ Ray-traced values may exceed this if rays take multi-bounce paths due to
975
+ atmospheric refraction causing rays to return to the surface multiple times.
976
+
977
+ Parameters
978
+ ----------
979
+ pareto_result : dict
980
+ Result from compute_pareto_front()
981
+ source_position : tuple
982
+ Source (x, y, z) position in meters
983
+ beam_direction : tuple
984
+ Beam direction unit vector
985
+ divergence_angle_rad : float
986
+ Beam half-angle divergence in radians
987
+ detector_altitude : float
988
+ Detector sphere radius in meters
989
+ surface : Surface, optional
990
+ Surface object for geometric estimate. If None, uses flat surface at z=0.
991
+ n_top : int
992
+ Number of top intensity bins to analyze
993
+ source_power : float
994
+ Input source power for normalization (W)
995
+ time_threshold_ns : float
996
+ Time threshold for highlighting (ns)
997
+ save_path : str, optional
998
+ Path to save figure
999
+
1000
+ Returns
1001
+ -------
1002
+ fig : matplotlib.figure.Figure
1003
+ The created figure
1004
+ """
1005
+ from ..utilities import estimate_time_spread
1006
+
1007
+ bin_data = pareto_result["bin_data"]
1008
+ if len(bin_data) < n_top:
1009
+ n_top = len(bin_data)
1010
+
1011
+ # Sort by energy density and take top N
1012
+ sorted_bins = sorted(bin_data, key=lambda x: x["energy_density"], reverse=True)
1013
+ top_bins = sorted_bins[:n_top]
1014
+
1015
+ # Compute geometric time spread estimate for each detector location
1016
+ geometric_bounds = []
1017
+ raytraced_spreads = []
1018
+ labels = []
1019
+
1020
+ for i, b in enumerate(top_bins):
1021
+ # Convert bin angular position to Cartesian detector position
1022
+ lon_rad = b["lon"]
1023
+ lat_rad = b["lat"]
1024
+ det_x = detector_altitude * np.cos(lat_rad) * np.cos(lon_rad)
1025
+ det_y = detector_altitude * np.cos(lat_rad) * np.sin(lon_rad)
1026
+ det_z = detector_altitude * np.sin(lat_rad)
1027
+ detector_position = (det_x, det_y, det_z)
1028
+
1029
+ # Compute geometric estimate (single-bounce assumption)
1030
+ result = estimate_time_spread(
1031
+ source_position=source_position,
1032
+ beam_direction=beam_direction,
1033
+ divergence_angle=divergence_angle_rad,
1034
+ detector_position=detector_position,
1035
+ surface=surface,
1036
+ )
1037
+
1038
+ geometric_bounds.append(result.time_spread_ns)
1039
+ raytraced_spreads.append(b["time_spread_ns"])
1040
+ labels.append(f"({b['lon_deg']:.1f}, {b['lat_deg']:.1f})")
1041
+
1042
+ geometric_bounds = np.array(geometric_bounds)
1043
+ raytraced_spreads = np.array(raytraced_spreads)
1044
+
1045
+ # Create plot with two subplots
1046
+ fig, axes = plt.subplots(1, 2, figsize=(16, 6))
1047
+
1048
+ # ==========================================================================
1049
+ # Left: Bar chart comparing raytraced vs geometric
1050
+ # ==========================================================================
1051
+ ax = axes[0]
1052
+
1053
+ x = np.arange(n_top)
1054
+ width = 0.35
1055
+
1056
+ bars1 = ax.bar(
1057
+ x - width / 2,
1058
+ raytraced_spreads,
1059
+ width,
1060
+ label="Ray-traced (includes multi-bounce)",
1061
+ color="steelblue",
1062
+ alpha=0.8,
1063
+ )
1064
+ bars2 = ax.bar(
1065
+ x + width / 2,
1066
+ geometric_bounds,
1067
+ width,
1068
+ label="Geometric (single-bounce only)",
1069
+ color="coral",
1070
+ alpha=0.8,
1071
+ )
1072
+
1073
+ # Add threshold line
1074
+ ax.axhline(
1075
+ y=time_threshold_ns,
1076
+ color="red",
1077
+ linestyle="--",
1078
+ linewidth=2,
1079
+ label=f"Threshold ({time_threshold_ns} ns)",
1080
+ )
1081
+
1082
+ ax.set_xlabel("Detector Location (lon, lat) deg", fontsize=12)
1083
+ ax.set_ylabel("Time Spread (ns)", fontsize=12)
1084
+ ax.set_title(
1085
+ "Time Spread: Ray-Traced vs Single-Bounce Geometric",
1086
+ fontsize=14,
1087
+ fontweight="bold",
1088
+ )
1089
+ ax.set_xticks(x)
1090
+ ax.set_xticklabels(labels, rotation=45, ha="right", fontsize=9)
1091
+ ax.legend(fontsize=9, loc="upper right")
1092
+ ax.grid(True, alpha=0.3, axis="y")
1093
+
1094
+ # Add value labels on bars
1095
+ for bar, val in zip(bars1, raytraced_spreads, strict=False):
1096
+ ax.text(
1097
+ bar.get_x() + bar.get_width() / 2,
1098
+ bar.get_height() + 2,
1099
+ f"{val:.0f}",
1100
+ ha="center",
1101
+ va="bottom",
1102
+ fontsize=8,
1103
+ )
1104
+ for bar, val in zip(bars2, geometric_bounds, strict=False):
1105
+ ax.text(
1106
+ bar.get_x() + bar.get_width() / 2,
1107
+ bar.get_height() + 2,
1108
+ f"{val:.1f}",
1109
+ ha="center",
1110
+ va="bottom",
1111
+ fontsize=8,
1112
+ )
1113
+
1114
+ # ==========================================================================
1115
+ # Right: Ratio of raytraced to geometric (shows multi-bounce contribution)
1116
+ # ==========================================================================
1117
+ ax2 = axes[1]
1118
+
1119
+ # Compute ratio (how much larger is raytraced vs geometric)
1120
+ ratio = raytraced_spreads / np.maximum(geometric_bounds, 0.1) # Avoid div by zero
1121
+
1122
+ colors = plt.cm.RdYlGn_r(np.clip(ratio / 50, 0, 1)) # Red = high ratio
1123
+
1124
+ bars = ax2.bar(
1125
+ x,
1126
+ ratio,
1127
+ width=0.7,
1128
+ color=colors,
1129
+ alpha=0.8,
1130
+ edgecolor="black",
1131
+ linewidth=0.5,
1132
+ )
1133
+
1134
+ ax2.axhline(
1135
+ y=1,
1136
+ color="green",
1137
+ linestyle="-",
1138
+ linewidth=2,
1139
+ label="Ratio = 1 (single-bounce)",
1140
+ )
1141
+ ax2.axhline(y=10, color="orange", linestyle="--", linewidth=1.5, label="Ratio = 10")
1142
+
1143
+ ax2.set_xlabel("Detector Location (lon, lat) deg", fontsize=12)
1144
+ ax2.set_ylabel("Raytraced / Geometric Ratio", fontsize=12)
1145
+ ax2.set_title(
1146
+ "Multi-Bounce Contribution\n(ratio > 1 indicates multi-bounce paths)",
1147
+ fontsize=14,
1148
+ fontweight="bold",
1149
+ )
1150
+ ax2.set_xticks(x)
1151
+ ax2.set_xticklabels(labels, rotation=45, ha="right", fontsize=9)
1152
+ ax2.legend(fontsize=9, loc="upper right")
1153
+ ax2.grid(True, alpha=0.3, axis="y")
1154
+
1155
+ # Add value labels
1156
+ for bar, val in zip(bars, ratio, strict=False):
1157
+ ax2.text(
1158
+ bar.get_x() + bar.get_width() / 2,
1159
+ bar.get_height() + 0.5,
1160
+ f"{val:.0f}x",
1161
+ ha="center",
1162
+ va="bottom",
1163
+ fontsize=9,
1164
+ fontweight="bold",
1165
+ )
1166
+
1167
+ plt.tight_layout()
1168
+
1169
+ if save_path:
1170
+ plt.savefig(save_path, dpi=150, bbox_inches="tight")
1171
+ print(f" Saved: {save_path}")
1172
+
1173
+ return fig