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