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,999 @@
|
|
|
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
|
+
Ocean Wave Simulation Visualization
|
|
36
|
+
|
|
37
|
+
Complete visualization suite for ocean wave ray tracing simulations.
|
|
38
|
+
Generates comprehensive figure sets including ray overview, statistics,
|
|
39
|
+
intensity-angle-time plots, 3D views, and energy conservation checks.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import TYPE_CHECKING
|
|
44
|
+
|
|
45
|
+
import matplotlib.gridspec as gridspec
|
|
46
|
+
import matplotlib.pyplot as plt
|
|
47
|
+
import numpy as np
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING:
|
|
50
|
+
from ..surfaces import CurvedWaveSurface
|
|
51
|
+
|
|
52
|
+
# Import from specific modules to avoid circular imports
|
|
53
|
+
from ..surfaces import EARTH_RADIUS
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def create_ocean_simulation_figures(
|
|
57
|
+
original_rays,
|
|
58
|
+
surface: "CurvedWaveSurface",
|
|
59
|
+
recorded_rays,
|
|
60
|
+
reflected_rays,
|
|
61
|
+
refracted_rays,
|
|
62
|
+
config: dict,
|
|
63
|
+
output_dir: str,
|
|
64
|
+
timestamp: str,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""
|
|
67
|
+
Create complete visualization suite for ocean wave simulation.
|
|
68
|
+
|
|
69
|
+
Generates 8 figures:
|
|
70
|
+
1. Ray paths overview (full scale and surface detail)
|
|
71
|
+
2. Recorded rays statistics (6-panel figure)
|
|
72
|
+
3. Intensity vs angle (log scale, fraction)
|
|
73
|
+
4. Intensity vs angle (linear scale, fraction)
|
|
74
|
+
5. Intensity density (log scale, ns⁻¹)
|
|
75
|
+
6. Intensity density (linear scale, ns⁻¹)
|
|
76
|
+
7. 3D visualization
|
|
77
|
+
8. Energy conservation check
|
|
78
|
+
|
|
79
|
+
Parameters
|
|
80
|
+
----------
|
|
81
|
+
original_rays : RayBatch
|
|
82
|
+
Initial input rays before interaction
|
|
83
|
+
surface : CurvedWaveSurface
|
|
84
|
+
The ocean surface model
|
|
85
|
+
recorded_rays : RecordedRays
|
|
86
|
+
Rays detected at the recording sphere
|
|
87
|
+
reflected_rays : RayBatch
|
|
88
|
+
Reflected rays from surface
|
|
89
|
+
refracted_rays : RayBatch
|
|
90
|
+
Refracted rays into water
|
|
91
|
+
config : dict
|
|
92
|
+
Simulation configuration parameters
|
|
93
|
+
output_dir : str
|
|
94
|
+
Directory for output files
|
|
95
|
+
timestamp : str
|
|
96
|
+
Timestamp string for filenames
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
output_path = Path(output_dir)
|
|
100
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
|
|
102
|
+
# =========================================================================
|
|
103
|
+
# Figure 1: Ray overview using actual simulation results
|
|
104
|
+
# =========================================================================
|
|
105
|
+
print(" Creating ray paths overview...")
|
|
106
|
+
|
|
107
|
+
_create_ray_overview(
|
|
108
|
+
original_rays,
|
|
109
|
+
surface,
|
|
110
|
+
recorded_rays,
|
|
111
|
+
reflected_rays,
|
|
112
|
+
refracted_rays,
|
|
113
|
+
config,
|
|
114
|
+
output_path,
|
|
115
|
+
timestamp,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# =========================================================================
|
|
119
|
+
# Figure 2: Recorded rays statistics
|
|
120
|
+
# =========================================================================
|
|
121
|
+
print(" Creating recorded rays statistics...")
|
|
122
|
+
|
|
123
|
+
if recorded_rays.num_rays > 0:
|
|
124
|
+
_create_statistics_figure(
|
|
125
|
+
original_rays, recorded_rays, reflected_rays, config, output_path, timestamp
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Create dedicated intensity-angle-time plots
|
|
129
|
+
_create_intensity_angle_plots(
|
|
130
|
+
original_rays, recorded_rays, reflected_rays, config, output_path, timestamp
|
|
131
|
+
)
|
|
132
|
+
else:
|
|
133
|
+
print(" WARNING: No recorded rays - skipping statistics figures")
|
|
134
|
+
|
|
135
|
+
# =========================================================================
|
|
136
|
+
# Figure 7: 3D visualization
|
|
137
|
+
# =========================================================================
|
|
138
|
+
print(" Creating 3D visualization...")
|
|
139
|
+
|
|
140
|
+
_create_3d_visualization(recorded_rays, config, output_path, timestamp)
|
|
141
|
+
|
|
142
|
+
# =========================================================================
|
|
143
|
+
# Figure 8: Energy conservation check
|
|
144
|
+
# =========================================================================
|
|
145
|
+
print(" Creating energy conservation figure...")
|
|
146
|
+
|
|
147
|
+
_create_energy_conservation(
|
|
148
|
+
original_rays,
|
|
149
|
+
recorded_rays,
|
|
150
|
+
reflected_rays,
|
|
151
|
+
refracted_rays,
|
|
152
|
+
config,
|
|
153
|
+
output_path,
|
|
154
|
+
timestamp,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _create_ray_overview(
|
|
159
|
+
original_rays,
|
|
160
|
+
surface,
|
|
161
|
+
recorded_rays,
|
|
162
|
+
reflected_rays,
|
|
163
|
+
refracted_rays=None,
|
|
164
|
+
config=None,
|
|
165
|
+
output_path=None,
|
|
166
|
+
timestamp=None,
|
|
167
|
+
):
|
|
168
|
+
"""Create ray paths overview figure."""
|
|
169
|
+
|
|
170
|
+
fig, axes = plt.subplots(1, 2, figsize=(16, 8))
|
|
171
|
+
|
|
172
|
+
# Subsample rays for visualization
|
|
173
|
+
n_vis = min(500, original_rays.num_rays)
|
|
174
|
+
vis_idx = np.random.choice(original_rays.num_rays, n_vis, replace=False)
|
|
175
|
+
|
|
176
|
+
# Get hit positions (where rays hit the surface)
|
|
177
|
+
distances, hit_mask = surface.intersect(
|
|
178
|
+
original_rays.positions[vis_idx], original_rays.directions[vis_idx]
|
|
179
|
+
)
|
|
180
|
+
hit_positions = (
|
|
181
|
+
original_rays.positions[vis_idx][hit_mask]
|
|
182
|
+
+ distances[hit_mask, np.newaxis] * original_rays.directions[vis_idx][hit_mask]
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Left panel: Full scale view (X-Z plane)
|
|
186
|
+
ax1 = axes[0]
|
|
187
|
+
|
|
188
|
+
# Draw Earth surface
|
|
189
|
+
earth_center = np.array([0, 0, -EARTH_RADIUS])
|
|
190
|
+
theta = np.linspace(-0.01, 0.01, 100)
|
|
191
|
+
earth_x = EARTH_RADIUS * np.sin(theta)
|
|
192
|
+
earth_z = earth_center[2] + EARTH_RADIUS * np.cos(theta)
|
|
193
|
+
ax1.fill_between(
|
|
194
|
+
earth_x / 1000, earth_z / 1000, -10, color="#4a90d9", alpha=0.3, label="Ocean"
|
|
195
|
+
)
|
|
196
|
+
ax1.plot(earth_x / 1000, earth_z / 1000, "b-", linewidth=2, label="Sea surface")
|
|
197
|
+
|
|
198
|
+
# Recording sphere - LOCAL sphere centered at origin
|
|
199
|
+
# Draw a circle centered at (0, 0, 0) with radius = recording_altitude
|
|
200
|
+
recording_radius_local = config["recording_altitude"] / 1000 # in km
|
|
201
|
+
theta_sphere = np.linspace(0, 2 * np.pi, 100)
|
|
202
|
+
sphere_x = recording_radius_local * np.cos(theta_sphere)
|
|
203
|
+
sphere_z = recording_radius_local * np.sin(theta_sphere)
|
|
204
|
+
ax1.plot(
|
|
205
|
+
sphere_x,
|
|
206
|
+
sphere_z,
|
|
207
|
+
"k--",
|
|
208
|
+
linewidth=1.5,
|
|
209
|
+
label=f'Recording sphere ({config["recording_altitude"]/1000:.0f} km)',
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Plot incoming rays (from source to surface)
|
|
213
|
+
n_plot = min(100, len(hit_positions))
|
|
214
|
+
for i in range(n_plot):
|
|
215
|
+
idx = vis_idx[np.where(hit_mask)[0][i]]
|
|
216
|
+
start = original_rays.positions[idx]
|
|
217
|
+
end = hit_positions[i]
|
|
218
|
+
color = "r" if i == 0 else "r"
|
|
219
|
+
alpha = 0.6 if i == 0 else 0.3
|
|
220
|
+
label = "Incoming rays" if i == 0 else None
|
|
221
|
+
ax1.plot(
|
|
222
|
+
[start[0] / 1000, end[0] / 1000],
|
|
223
|
+
[start[2] / 1000, end[2] / 1000],
|
|
224
|
+
color=color,
|
|
225
|
+
alpha=alpha,
|
|
226
|
+
linewidth=0.8,
|
|
227
|
+
label=label,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Plot actual reflected rays (from surface toward recording sphere)
|
|
231
|
+
upward_mask = reflected_rays.directions[:, 2] > 0
|
|
232
|
+
upward_reflected = np.where(upward_mask)[0]
|
|
233
|
+
n_plot_refl = min(100, len(upward_reflected))
|
|
234
|
+
ray_length = config["recording_altitude"] * 1.5
|
|
235
|
+
|
|
236
|
+
for i, idx in enumerate(upward_reflected[:n_plot_refl]):
|
|
237
|
+
start = reflected_rays.positions[idx]
|
|
238
|
+
direction = reflected_rays.directions[idx]
|
|
239
|
+
end = start + direction * ray_length
|
|
240
|
+
color = "g"
|
|
241
|
+
alpha = 0.6 if i == 0 else 0.3
|
|
242
|
+
label = "Reflected rays (upward)" if i == 0 else None
|
|
243
|
+
ax1.plot(
|
|
244
|
+
[start[0] / 1000, end[0] / 1000],
|
|
245
|
+
[start[2] / 1000, end[2] / 1000],
|
|
246
|
+
color=color,
|
|
247
|
+
alpha=alpha,
|
|
248
|
+
linewidth=0.8,
|
|
249
|
+
label=label,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Plot downward-going reflected rays (a few)
|
|
253
|
+
downward_reflected = np.where(~upward_mask)[0]
|
|
254
|
+
n_plot_down = min(20, len(downward_reflected))
|
|
255
|
+
for i, idx in enumerate(downward_reflected[:n_plot_down]):
|
|
256
|
+
start = reflected_rays.positions[idx]
|
|
257
|
+
direction = reflected_rays.directions[idx]
|
|
258
|
+
end = start + direction * 1000 # shorter length for downward
|
|
259
|
+
color = "orange"
|
|
260
|
+
alpha = 0.6 if i == 0 else 0.3
|
|
261
|
+
label = "Reflected rays (downward)" if i == 0 else None
|
|
262
|
+
ax1.plot(
|
|
263
|
+
[start[0] / 1000, end[0] / 1000],
|
|
264
|
+
[start[2] / 1000, end[2] / 1000],
|
|
265
|
+
color=color,
|
|
266
|
+
alpha=alpha,
|
|
267
|
+
linewidth=0.8,
|
|
268
|
+
label=label,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# Plot refracted rays (downward into water) - optional
|
|
272
|
+
if refracted_rays is not None and refracted_rays.num_rays > 0:
|
|
273
|
+
n_plot_refr = min(50, refracted_rays.num_rays)
|
|
274
|
+
refracted_indices = np.random.choice(
|
|
275
|
+
refracted_rays.num_rays, n_plot_refr, replace=False
|
|
276
|
+
)
|
|
277
|
+
ray_length_refr = config["recording_altitude"] * 1.5 # Same as upward reflected
|
|
278
|
+
|
|
279
|
+
for i, idx in enumerate(refracted_indices):
|
|
280
|
+
start = refracted_rays.positions[idx]
|
|
281
|
+
direction = refracted_rays.directions[idx]
|
|
282
|
+
end = start + direction * ray_length_refr
|
|
283
|
+
color = "cyan"
|
|
284
|
+
alpha = 0.6 if i == 0 else 0.3
|
|
285
|
+
label = "Refracted rays (into water)" if i == 0 else None
|
|
286
|
+
ax1.plot(
|
|
287
|
+
[start[0] / 1000, end[0] / 1000],
|
|
288
|
+
[start[2] / 1000, end[2] / 1000],
|
|
289
|
+
color=color,
|
|
290
|
+
alpha=alpha,
|
|
291
|
+
linewidth=0.8,
|
|
292
|
+
linestyle="--",
|
|
293
|
+
label=label,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
ax1.set_xlabel("X (km)", fontsize=12)
|
|
297
|
+
ax1.set_ylabel("Z (km)", fontsize=12)
|
|
298
|
+
ax1.set_title("Ray Paths Overview (X-Z Plane)", fontsize=14)
|
|
299
|
+
ax1.legend(loc="upper right")
|
|
300
|
+
ax1.set_aspect("equal")
|
|
301
|
+
ax1.grid(True, alpha=0.3)
|
|
302
|
+
max_range = (
|
|
303
|
+
max(config.get("source_distance", 10000), config["recording_altitude"]) * 1.5
|
|
304
|
+
)
|
|
305
|
+
ax1.set_xlim(-max_range / 1000 * 0.5, max_range / 1000 * 1.5)
|
|
306
|
+
ax1.set_ylim(-5, config["recording_altitude"] / 1000 * 1.2)
|
|
307
|
+
|
|
308
|
+
# Right panel: Zoom on surface interaction
|
|
309
|
+
ax2 = axes[1]
|
|
310
|
+
|
|
311
|
+
# Plot wave surface using get_surface_point
|
|
312
|
+
x_range = np.linspace(-200, 200, 500)
|
|
313
|
+
surface_positions = np.column_stack(
|
|
314
|
+
[x_range, np.zeros_like(x_range), np.zeros_like(x_range)]
|
|
315
|
+
)
|
|
316
|
+
surface_points = surface.get_surface_point(surface_positions.astype(np.float32))
|
|
317
|
+
z_surface = surface_points[:, 2]
|
|
318
|
+
ax2.fill_between(
|
|
319
|
+
x_range, z_surface, z_surface.min() - 5, color="#4a90d9", alpha=0.3
|
|
320
|
+
)
|
|
321
|
+
ax2.plot(x_range, z_surface, "b-", linewidth=2, label="Wave surface")
|
|
322
|
+
|
|
323
|
+
# Plot hit points
|
|
324
|
+
ax2.scatter(
|
|
325
|
+
hit_positions[:, 0],
|
|
326
|
+
hit_positions[:, 2],
|
|
327
|
+
c="red",
|
|
328
|
+
s=10,
|
|
329
|
+
alpha=0.5,
|
|
330
|
+
label="Hit points",
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# Plot reflected ray directions from hit points
|
|
334
|
+
for i in range(min(50, len(hit_positions))):
|
|
335
|
+
idx = vis_idx[np.where(hit_mask)[0][i]]
|
|
336
|
+
# Find corresponding reflected ray (same index in reflected_rays)
|
|
337
|
+
if idx < reflected_rays.num_rays:
|
|
338
|
+
start = reflected_rays.positions[idx]
|
|
339
|
+
direction = reflected_rays.directions[idx]
|
|
340
|
+
length = 50 # 50m arrow
|
|
341
|
+
end = start + direction * length
|
|
342
|
+
color = "green" if direction[2] > 0 else "orange"
|
|
343
|
+
ax2.arrow(
|
|
344
|
+
start[0],
|
|
345
|
+
start[2],
|
|
346
|
+
direction[0] * length,
|
|
347
|
+
direction[2] * length,
|
|
348
|
+
head_width=2,
|
|
349
|
+
head_length=1,
|
|
350
|
+
fc=color,
|
|
351
|
+
ec=color,
|
|
352
|
+
alpha=0.5,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
ax2.set_xlabel("X (m)", fontsize=12)
|
|
356
|
+
ax2.set_ylabel("Z (m)", fontsize=12)
|
|
357
|
+
ax2.set_title("Surface Interaction Detail", fontsize=14)
|
|
358
|
+
ax2.legend(loc="upper right")
|
|
359
|
+
ax2.set_aspect("equal")
|
|
360
|
+
ax2.grid(True, alpha=0.3)
|
|
361
|
+
ax2.set_xlim(-200, 200)
|
|
362
|
+
ax2.set_ylim(-5, 10)
|
|
363
|
+
|
|
364
|
+
plt.tight_layout()
|
|
365
|
+
overview_path = output_path / f"local_simulation_{timestamp}_overview.png"
|
|
366
|
+
plt.savefig(overview_path, dpi=150, bbox_inches="tight")
|
|
367
|
+
plt.close()
|
|
368
|
+
print(f" Saved: {overview_path}")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _create_statistics_figure(
|
|
372
|
+
original_rays, recorded_rays, reflected_rays, config, output_path, timestamp
|
|
373
|
+
):
|
|
374
|
+
"""Create 6-panel statistics figure."""
|
|
375
|
+
|
|
376
|
+
fig = plt.figure(figsize=(16, 12))
|
|
377
|
+
gs = gridspec.GridSpec(2, 3, figure=fig)
|
|
378
|
+
|
|
379
|
+
# Angular coordinates (for azimuth only)
|
|
380
|
+
angular = recorded_rays.compute_angular_coordinates()
|
|
381
|
+
|
|
382
|
+
# Ray direction angle relative to horizontal at origin
|
|
383
|
+
directions = recorded_rays.directions
|
|
384
|
+
ray_elevation_deg = np.degrees(np.arcsin(directions[:, 2]))
|
|
385
|
+
|
|
386
|
+
# Panel 1: Ray angle from horizontal
|
|
387
|
+
ax1 = fig.add_subplot(gs[0, 0])
|
|
388
|
+
ax1.hist(
|
|
389
|
+
ray_elevation_deg,
|
|
390
|
+
bins=50,
|
|
391
|
+
weights=recorded_rays.intensities,
|
|
392
|
+
color="steelblue",
|
|
393
|
+
edgecolor="black",
|
|
394
|
+
alpha=0.7,
|
|
395
|
+
)
|
|
396
|
+
ax1.set_xlabel("Ray Angle from Horizontal (degrees)", fontsize=11)
|
|
397
|
+
ax1.set_ylabel("Intensity-weighted Count", fontsize=11)
|
|
398
|
+
ax1.set_title("Ray Direction Angle (relative to z=0 at origin)", fontsize=12)
|
|
399
|
+
ax1.grid(True, alpha=0.3)
|
|
400
|
+
|
|
401
|
+
# Panel 2: Azimuth angle distribution
|
|
402
|
+
ax2 = fig.add_subplot(gs[0, 1])
|
|
403
|
+
azimuth_deg = np.degrees(angular["azimuth"])
|
|
404
|
+
ax2.hist(
|
|
405
|
+
azimuth_deg,
|
|
406
|
+
bins=50,
|
|
407
|
+
weights=recorded_rays.intensities,
|
|
408
|
+
color="coral",
|
|
409
|
+
edgecolor="black",
|
|
410
|
+
alpha=0.7,
|
|
411
|
+
)
|
|
412
|
+
ax2.set_xlabel("Azimuth Angle (degrees)", fontsize=11)
|
|
413
|
+
ax2.set_ylabel("Intensity-weighted Count", fontsize=11)
|
|
414
|
+
ax2.set_title("Azimuth Angle Distribution", fontsize=12)
|
|
415
|
+
ax2.grid(True, alpha=0.3)
|
|
416
|
+
|
|
417
|
+
# Panel 3: Time distribution
|
|
418
|
+
ax3 = fig.add_subplot(gs[0, 2])
|
|
419
|
+
times_ns = recorded_rays.times * 1e9 # Convert to nanoseconds
|
|
420
|
+
relative_times_ns = times_ns - times_ns.min()
|
|
421
|
+
relative_times_ns_safe = np.maximum(relative_times_ns, 1.0)
|
|
422
|
+
if relative_times_ns.max() > 1.0:
|
|
423
|
+
log_bins = np.logspace(0, np.log10(relative_times_ns_safe.max()), 51)
|
|
424
|
+
ax3.hist(
|
|
425
|
+
relative_times_ns_safe,
|
|
426
|
+
bins=log_bins,
|
|
427
|
+
weights=recorded_rays.intensities,
|
|
428
|
+
color="green",
|
|
429
|
+
edgecolor="black",
|
|
430
|
+
alpha=0.7,
|
|
431
|
+
)
|
|
432
|
+
ax3.set_xscale("log")
|
|
433
|
+
else:
|
|
434
|
+
ax3.hist(
|
|
435
|
+
relative_times_ns,
|
|
436
|
+
bins=50,
|
|
437
|
+
weights=recorded_rays.intensities,
|
|
438
|
+
color="green",
|
|
439
|
+
edgecolor="black",
|
|
440
|
+
alpha=0.7,
|
|
441
|
+
)
|
|
442
|
+
ax3.set_xlabel("Relative Arrival Time (ns)", fontsize=11)
|
|
443
|
+
ax3.set_ylabel("Intensity-weighted Count", fontsize=11)
|
|
444
|
+
ax3.set_title("Time of Arrival Distribution", fontsize=12)
|
|
445
|
+
ax3.grid(True, alpha=0.3)
|
|
446
|
+
|
|
447
|
+
# Panel 4: Intensity distribution
|
|
448
|
+
ax4 = fig.add_subplot(gs[1, 0])
|
|
449
|
+
ax4.hist(
|
|
450
|
+
np.log10(recorded_rays.intensities + 1e-20),
|
|
451
|
+
bins=50,
|
|
452
|
+
color="purple",
|
|
453
|
+
edgecolor="black",
|
|
454
|
+
alpha=0.7,
|
|
455
|
+
)
|
|
456
|
+
ax4.set_xlabel("log₁₀(Intensity)", fontsize=11)
|
|
457
|
+
ax4.set_ylabel("Count", fontsize=11)
|
|
458
|
+
ax4.set_title("Intensity Distribution", fontsize=12)
|
|
459
|
+
ax4.grid(True, alpha=0.3)
|
|
460
|
+
|
|
461
|
+
# Panel 5: Relative arrival times by angle bin
|
|
462
|
+
ax5 = fig.add_subplot(gs[1, 1])
|
|
463
|
+
|
|
464
|
+
# Compute normalization
|
|
465
|
+
total_incident_power = np.sum(original_rays.intensities)
|
|
466
|
+
|
|
467
|
+
# Bin rays by angle
|
|
468
|
+
num_angle_bins = 15
|
|
469
|
+
angle_bins = np.linspace(
|
|
470
|
+
ray_elevation_deg.min(), ray_elevation_deg.max(), num_angle_bins + 1
|
|
471
|
+
)
|
|
472
|
+
bin_indices = np.digitize(ray_elevation_deg, angle_bins)
|
|
473
|
+
colors = plt.cm.turbo(np.linspace(0, 1, num_angle_bins))
|
|
474
|
+
|
|
475
|
+
# Shared log time bins
|
|
476
|
+
time_bins = np.logspace(-2, 4, 101)
|
|
477
|
+
times_ns_plot = recorded_rays.times * 1e9
|
|
478
|
+
|
|
479
|
+
for bin_idx in range(1, len(angle_bins)):
|
|
480
|
+
mask = bin_indices == bin_idx
|
|
481
|
+
if np.sum(mask) > 5:
|
|
482
|
+
bin_times = times_ns_plot[mask]
|
|
483
|
+
bin_intensities = recorded_rays.intensities[mask]
|
|
484
|
+
earliest = bin_times.min()
|
|
485
|
+
relative_times = bin_times - earliest
|
|
486
|
+
relative_times_safe = np.maximum(relative_times, 0.01)
|
|
487
|
+
|
|
488
|
+
hist_intensity, _ = np.histogram(
|
|
489
|
+
relative_times_safe, bins=time_bins, weights=bin_intensities
|
|
490
|
+
)
|
|
491
|
+
hist_intensity_normalized = hist_intensity / total_incident_power
|
|
492
|
+
bin_centers = np.sqrt(time_bins[:-1] * time_bins[1:])
|
|
493
|
+
mean_angle = ray_elevation_deg[mask].mean()
|
|
494
|
+
|
|
495
|
+
ax5.plot(
|
|
496
|
+
bin_centers,
|
|
497
|
+
hist_intensity_normalized,
|
|
498
|
+
alpha=0.6,
|
|
499
|
+
linewidth=1.0,
|
|
500
|
+
color=colors[bin_idx - 1],
|
|
501
|
+
label=f"{mean_angle:.1f}°",
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
ax5.set_xlabel("Relative Arrival Time (ns)", fontsize=11)
|
|
505
|
+
ax5.set_ylabel("Normalized Intensity Fraction", fontsize=11)
|
|
506
|
+
ax5.set_title("Intensity vs Time by Angle Bin", fontsize=12)
|
|
507
|
+
ax5.set_xscale("log")
|
|
508
|
+
ax5.grid(True, alpha=0.3)
|
|
509
|
+
ax5.legend(fontsize=7, ncol=2, title="Angle")
|
|
510
|
+
|
|
511
|
+
# Panel 6: 2D angular distribution
|
|
512
|
+
ax6 = fig.add_subplot(gs[1, 2])
|
|
513
|
+
h = ax6.hist2d(
|
|
514
|
+
azimuth_deg,
|
|
515
|
+
ray_elevation_deg,
|
|
516
|
+
bins=30,
|
|
517
|
+
weights=recorded_rays.intensities,
|
|
518
|
+
cmap="hot",
|
|
519
|
+
)
|
|
520
|
+
plt.colorbar(h[3], ax=ax6, label="Intensity")
|
|
521
|
+
ax6.set_xlabel("Azimuth (degrees)", fontsize=11)
|
|
522
|
+
ax6.set_ylabel("Ray Angle from Horizontal (degrees)", fontsize=11)
|
|
523
|
+
ax6.set_title("2D Angular Distribution", fontsize=12)
|
|
524
|
+
|
|
525
|
+
plt.tight_layout()
|
|
526
|
+
fig_path = output_path / f"local_simulation_{timestamp}_statistics.png"
|
|
527
|
+
plt.savefig(fig_path, dpi=150, bbox_inches="tight")
|
|
528
|
+
plt.close()
|
|
529
|
+
print(f" Saved: {fig_path}")
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _create_intensity_angle_plots(
|
|
533
|
+
original_rays, recorded_rays, reflected_rays, config, output_path, timestamp
|
|
534
|
+
):
|
|
535
|
+
"""Create dedicated intensity vs angle bin plots (4 variants)."""
|
|
536
|
+
|
|
537
|
+
# Common setup
|
|
538
|
+
directions = recorded_rays.directions
|
|
539
|
+
ray_elevation_deg = np.degrees(np.arcsin(directions[:, 2]))
|
|
540
|
+
total_incident_power = np.sum(original_rays.intensities)
|
|
541
|
+
times_ns_plot = recorded_rays.times * 1e9
|
|
542
|
+
|
|
543
|
+
num_angle_bins = 20
|
|
544
|
+
angle_bins = np.linspace(
|
|
545
|
+
ray_elevation_deg.min(), ray_elevation_deg.max(), num_angle_bins + 1
|
|
546
|
+
)
|
|
547
|
+
bin_indices = np.digitize(ray_elevation_deg, angle_bins)
|
|
548
|
+
colors = plt.cm.turbo(np.linspace(0, 1, num_angle_bins))
|
|
549
|
+
|
|
550
|
+
# Logarithmic time bins
|
|
551
|
+
time_bins_log = np.logspace(-2, 4, 101)
|
|
552
|
+
bin_widths_log = time_bins_log[1:] - time_bins_log[:-1]
|
|
553
|
+
bin_centers_log = np.sqrt(time_bins_log[:-1] * time_bins_log[1:])
|
|
554
|
+
|
|
555
|
+
# Linear time bins
|
|
556
|
+
max_time = times_ns_plot.max() - times_ns_plot.min() + 100
|
|
557
|
+
time_bins_lin = np.arange(0, max_time, 1.0)
|
|
558
|
+
bin_widths_lin = 1.0
|
|
559
|
+
bin_centers_lin = time_bins_lin[:-1] + 0.5
|
|
560
|
+
|
|
561
|
+
# =========================================================================
|
|
562
|
+
# Plot 1: Log scale, fraction
|
|
563
|
+
# =========================================================================
|
|
564
|
+
print(" Creating intensity vs angle bin plot (log)...")
|
|
565
|
+
|
|
566
|
+
fig_log = plt.figure(figsize=(12, 8))
|
|
567
|
+
ax_log = fig_log.add_subplot(111)
|
|
568
|
+
legend_handles_log = []
|
|
569
|
+
legend_labels_log = []
|
|
570
|
+
|
|
571
|
+
for bin_idx in range(1, len(angle_bins)):
|
|
572
|
+
mask = bin_indices == bin_idx
|
|
573
|
+
if np.sum(mask) > 5:
|
|
574
|
+
bin_times = times_ns_plot[mask]
|
|
575
|
+
bin_intensities = recorded_rays.intensities[mask]
|
|
576
|
+
earliest = bin_times.min()
|
|
577
|
+
relative_times = bin_times - earliest
|
|
578
|
+
relative_times_safe = np.maximum(relative_times, 0.01)
|
|
579
|
+
|
|
580
|
+
hist_intensity, _ = np.histogram(
|
|
581
|
+
relative_times_safe, bins=time_bins_log, weights=bin_intensities
|
|
582
|
+
)
|
|
583
|
+
hist_intensity_normalized = hist_intensity / total_incident_power
|
|
584
|
+
|
|
585
|
+
mean_angle = ray_elevation_deg[mask].mean()
|
|
586
|
+
bin_total = np.sum(bin_intensities)
|
|
587
|
+
|
|
588
|
+
(line,) = ax_log.plot(
|
|
589
|
+
bin_centers_log,
|
|
590
|
+
hist_intensity_normalized,
|
|
591
|
+
alpha=0.7,
|
|
592
|
+
linewidth=1.0,
|
|
593
|
+
color=colors[bin_idx - 1],
|
|
594
|
+
)
|
|
595
|
+
legend_handles_log.append(line)
|
|
596
|
+
legend_labels_log.append(f"{mean_angle:.1f}° (Σ={bin_total:.2e})")
|
|
597
|
+
|
|
598
|
+
ax_log.set_xlabel("Relative Arrival Time (ns)", fontsize=12)
|
|
599
|
+
ax_log.set_ylabel("Normalized Intensity Fraction", fontsize=12)
|
|
600
|
+
ax_log.set_title(
|
|
601
|
+
f"Intensity vs Arrival Time by Ray Angle Bin (Log Scale)\n"
|
|
602
|
+
f"Grazing angle: {config['grazing_angle']:.1f}°, "
|
|
603
|
+
f"Wave amplitude: {config['wave_amplitude']:.2f} m, "
|
|
604
|
+
f"Wavelength: {config['wave_wavelength']:.1f} m",
|
|
605
|
+
fontsize=14,
|
|
606
|
+
)
|
|
607
|
+
ax_log.set_xscale("log")
|
|
608
|
+
ax_log.grid(True, alpha=0.3)
|
|
609
|
+
ax_log.legend(
|
|
610
|
+
legend_handles_log,
|
|
611
|
+
legend_labels_log,
|
|
612
|
+
fontsize=9,
|
|
613
|
+
ncol=3,
|
|
614
|
+
title="Ray Angle (Total Intensity)",
|
|
615
|
+
loc="upper right",
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
sm = plt.cm.ScalarMappable(
|
|
619
|
+
cmap="turbo",
|
|
620
|
+
norm=plt.Normalize(vmin=ray_elevation_deg.min(), vmax=ray_elevation_deg.max()),
|
|
621
|
+
)
|
|
622
|
+
sm.set_array([])
|
|
623
|
+
plt.colorbar(sm, ax=ax_log, label="Ray Angle from Horizontal (°)")
|
|
624
|
+
|
|
625
|
+
plt.tight_layout()
|
|
626
|
+
plt.savefig(
|
|
627
|
+
output_path / f"local_simulation_{timestamp}_intensity_angle_log.png",
|
|
628
|
+
dpi=150,
|
|
629
|
+
bbox_inches="tight",
|
|
630
|
+
)
|
|
631
|
+
plt.close()
|
|
632
|
+
print(
|
|
633
|
+
f" Saved: {output_path / f'local_simulation_{timestamp}_intensity_angle_log.png'}"
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
# =========================================================================
|
|
637
|
+
# Plot 2: Linear scale, fraction
|
|
638
|
+
# =========================================================================
|
|
639
|
+
print(" Creating intensity vs angle bin plot (linear)...")
|
|
640
|
+
|
|
641
|
+
fig_lin = plt.figure(figsize=(12, 8))
|
|
642
|
+
ax_lin = fig_lin.add_subplot(111)
|
|
643
|
+
legend_handles_lin = []
|
|
644
|
+
legend_labels_lin = []
|
|
645
|
+
|
|
646
|
+
for bin_idx in range(1, len(angle_bins)):
|
|
647
|
+
mask = bin_indices == bin_idx
|
|
648
|
+
if np.sum(mask) > 5:
|
|
649
|
+
bin_times = times_ns_plot[mask]
|
|
650
|
+
bin_intensities = recorded_rays.intensities[mask]
|
|
651
|
+
earliest = bin_times.min()
|
|
652
|
+
relative_times = bin_times - earliest
|
|
653
|
+
|
|
654
|
+
hist_intensity, _ = np.histogram(
|
|
655
|
+
relative_times, bins=time_bins_lin, weights=bin_intensities
|
|
656
|
+
)
|
|
657
|
+
hist_intensity_normalized = hist_intensity / total_incident_power
|
|
658
|
+
|
|
659
|
+
mean_angle = ray_elevation_deg[mask].mean()
|
|
660
|
+
bin_total = np.sum(bin_intensities)
|
|
661
|
+
|
|
662
|
+
(line,) = ax_lin.plot(
|
|
663
|
+
bin_centers_lin,
|
|
664
|
+
hist_intensity_normalized,
|
|
665
|
+
alpha=0.7,
|
|
666
|
+
linewidth=1.0,
|
|
667
|
+
color=colors[bin_idx - 1],
|
|
668
|
+
)
|
|
669
|
+
legend_handles_lin.append(line)
|
|
670
|
+
legend_labels_lin.append(f"{mean_angle:.1f}° (Σ={bin_total:.2e})")
|
|
671
|
+
|
|
672
|
+
ax_lin.set_xlabel("Relative Arrival Time (ns)", fontsize=12)
|
|
673
|
+
ax_lin.set_ylabel("Normalized Intensity Fraction", fontsize=12)
|
|
674
|
+
ax_lin.set_title(
|
|
675
|
+
f"Intensity vs Arrival Time by Ray Angle Bin (Linear Scale)\n"
|
|
676
|
+
f"Grazing angle: {config['grazing_angle']:.1f}°, "
|
|
677
|
+
f"Wave amplitude: {config['wave_amplitude']:.2f} m, "
|
|
678
|
+
f"Wavelength: {config['wave_wavelength']:.1f} m",
|
|
679
|
+
fontsize=14,
|
|
680
|
+
)
|
|
681
|
+
ax_lin.grid(True, alpha=0.3)
|
|
682
|
+
ax_lin.legend(
|
|
683
|
+
legend_handles_lin,
|
|
684
|
+
legend_labels_lin,
|
|
685
|
+
fontsize=9,
|
|
686
|
+
ncol=3,
|
|
687
|
+
title="Ray Angle (Total Intensity)",
|
|
688
|
+
loc="upper right",
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
sm = plt.cm.ScalarMappable(
|
|
692
|
+
cmap="turbo",
|
|
693
|
+
norm=plt.Normalize(vmin=ray_elevation_deg.min(), vmax=ray_elevation_deg.max()),
|
|
694
|
+
)
|
|
695
|
+
sm.set_array([])
|
|
696
|
+
plt.colorbar(sm, ax=ax_lin, label="Ray Angle from Horizontal (°)")
|
|
697
|
+
|
|
698
|
+
plt.tight_layout()
|
|
699
|
+
plt.savefig(
|
|
700
|
+
output_path / f"local_simulation_{timestamp}_intensity_angle_linear.png",
|
|
701
|
+
dpi=150,
|
|
702
|
+
bbox_inches="tight",
|
|
703
|
+
)
|
|
704
|
+
plt.close()
|
|
705
|
+
print(
|
|
706
|
+
f" Saved: {output_path / f'local_simulation_{timestamp}_intensity_angle_linear.png'}"
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
# =========================================================================
|
|
710
|
+
# Plot 3: Log scale, density
|
|
711
|
+
# =========================================================================
|
|
712
|
+
print(" Creating intensity density plot (log)...")
|
|
713
|
+
|
|
714
|
+
fig_dens_log = plt.figure(figsize=(12, 8))
|
|
715
|
+
ax_dens_log = fig_dens_log.add_subplot(111)
|
|
716
|
+
legend_handles_dens_log = []
|
|
717
|
+
legend_labels_dens_log = []
|
|
718
|
+
|
|
719
|
+
for bin_idx in range(1, len(angle_bins)):
|
|
720
|
+
mask = bin_indices == bin_idx
|
|
721
|
+
if np.sum(mask) > 5:
|
|
722
|
+
bin_times = times_ns_plot[mask]
|
|
723
|
+
bin_intensities = recorded_rays.intensities[mask]
|
|
724
|
+
earliest = bin_times.min()
|
|
725
|
+
relative_times = bin_times - earliest
|
|
726
|
+
relative_times_safe = np.maximum(relative_times, 0.01)
|
|
727
|
+
|
|
728
|
+
hist_intensity, _ = np.histogram(
|
|
729
|
+
relative_times_safe, bins=time_bins_log, weights=bin_intensities
|
|
730
|
+
)
|
|
731
|
+
hist_intensity_density = hist_intensity / (
|
|
732
|
+
total_incident_power * bin_widths_log
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
mean_angle = ray_elevation_deg[mask].mean()
|
|
736
|
+
bin_total = np.sum(bin_intensities)
|
|
737
|
+
|
|
738
|
+
(line,) = ax_dens_log.plot(
|
|
739
|
+
bin_centers_log,
|
|
740
|
+
hist_intensity_density,
|
|
741
|
+
alpha=0.7,
|
|
742
|
+
linewidth=1.0,
|
|
743
|
+
color=colors[bin_idx - 1],
|
|
744
|
+
)
|
|
745
|
+
legend_handles_dens_log.append(line)
|
|
746
|
+
legend_labels_dens_log.append(f"{mean_angle:.1f}° (Σ={bin_total:.2e})")
|
|
747
|
+
|
|
748
|
+
ax_dens_log.set_xlabel("Relative Arrival Time (ns)", fontsize=12)
|
|
749
|
+
ax_dens_log.set_ylabel("Normalized Intensity Density (ns⁻¹)", fontsize=12)
|
|
750
|
+
ax_dens_log.set_title(
|
|
751
|
+
f"Intensity Density vs Arrival Time by Ray Angle Bin (Log Scale)\n"
|
|
752
|
+
f"Grazing angle: {config['grazing_angle']:.1f}°, "
|
|
753
|
+
f"Wave amplitude: {config['wave_amplitude']:.2f} m, "
|
|
754
|
+
f"Wavelength: {config['wave_wavelength']:.1f} m",
|
|
755
|
+
fontsize=14,
|
|
756
|
+
)
|
|
757
|
+
ax_dens_log.set_xscale("log")
|
|
758
|
+
ax_dens_log.grid(True, alpha=0.3)
|
|
759
|
+
ax_dens_log.legend(
|
|
760
|
+
legend_handles_dens_log,
|
|
761
|
+
legend_labels_dens_log,
|
|
762
|
+
fontsize=9,
|
|
763
|
+
ncol=3,
|
|
764
|
+
title="Ray Angle (Total Intensity)",
|
|
765
|
+
loc="upper right",
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
sm = plt.cm.ScalarMappable(
|
|
769
|
+
cmap="turbo",
|
|
770
|
+
norm=plt.Normalize(vmin=ray_elevation_deg.min(), vmax=ray_elevation_deg.max()),
|
|
771
|
+
)
|
|
772
|
+
sm.set_array([])
|
|
773
|
+
plt.colorbar(sm, ax=ax_dens_log, label="Ray Angle from Horizontal (°)")
|
|
774
|
+
|
|
775
|
+
plt.tight_layout()
|
|
776
|
+
plt.savefig(
|
|
777
|
+
output_path / f"local_simulation_{timestamp}_intensity_angle_log_density.png",
|
|
778
|
+
dpi=150,
|
|
779
|
+
bbox_inches="tight",
|
|
780
|
+
)
|
|
781
|
+
plt.close()
|
|
782
|
+
print(
|
|
783
|
+
f" Saved: {output_path / f'local_simulation_{timestamp}_intensity_angle_log_density.png'}"
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
# =========================================================================
|
|
787
|
+
# Plot 4: Linear scale, density
|
|
788
|
+
# =========================================================================
|
|
789
|
+
print(" Creating intensity density plot (linear)...")
|
|
790
|
+
|
|
791
|
+
fig_dens_lin = plt.figure(figsize=(12, 8))
|
|
792
|
+
ax_dens_lin = fig_dens_lin.add_subplot(111)
|
|
793
|
+
legend_handles_dens_lin = []
|
|
794
|
+
legend_labels_dens_lin = []
|
|
795
|
+
|
|
796
|
+
for bin_idx in range(1, len(angle_bins)):
|
|
797
|
+
mask = bin_indices == bin_idx
|
|
798
|
+
if np.sum(mask) > 5:
|
|
799
|
+
bin_times = times_ns_plot[mask]
|
|
800
|
+
bin_intensities = recorded_rays.intensities[mask]
|
|
801
|
+
earliest = bin_times.min()
|
|
802
|
+
relative_times = bin_times - earliest
|
|
803
|
+
|
|
804
|
+
hist_intensity, _ = np.histogram(
|
|
805
|
+
relative_times, bins=time_bins_lin, weights=bin_intensities
|
|
806
|
+
)
|
|
807
|
+
hist_intensity_density = hist_intensity / (
|
|
808
|
+
total_incident_power * bin_widths_lin
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
mean_angle = ray_elevation_deg[mask].mean()
|
|
812
|
+
bin_total = np.sum(bin_intensities)
|
|
813
|
+
|
|
814
|
+
(line,) = ax_dens_lin.plot(
|
|
815
|
+
bin_centers_lin,
|
|
816
|
+
hist_intensity_density,
|
|
817
|
+
alpha=0.7,
|
|
818
|
+
linewidth=1.0,
|
|
819
|
+
color=colors[bin_idx - 1],
|
|
820
|
+
)
|
|
821
|
+
legend_handles_dens_lin.append(line)
|
|
822
|
+
legend_labels_dens_lin.append(f"{mean_angle:.1f}° (Σ={bin_total:.2e})")
|
|
823
|
+
|
|
824
|
+
ax_dens_lin.set_xlabel("Relative Arrival Time (ns)", fontsize=12)
|
|
825
|
+
ax_dens_lin.set_ylabel("Normalized Intensity Density (ns⁻¹)", fontsize=12)
|
|
826
|
+
ax_dens_lin.set_title(
|
|
827
|
+
f"Intensity Density vs Arrival Time by Ray Angle Bin (Linear Scale)\n"
|
|
828
|
+
f"Grazing angle: {config['grazing_angle']:.1f}°, "
|
|
829
|
+
f"Wave amplitude: {config['wave_amplitude']:.2f} m, "
|
|
830
|
+
f"Wavelength: {config['wave_wavelength']:.1f} m",
|
|
831
|
+
fontsize=14,
|
|
832
|
+
)
|
|
833
|
+
ax_dens_lin.grid(True, alpha=0.3)
|
|
834
|
+
ax_dens_lin.legend(
|
|
835
|
+
legend_handles_dens_lin,
|
|
836
|
+
legend_labels_dens_lin,
|
|
837
|
+
fontsize=9,
|
|
838
|
+
ncol=3,
|
|
839
|
+
title="Ray Angle (Total Intensity)",
|
|
840
|
+
loc="upper right",
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
sm = plt.cm.ScalarMappable(
|
|
844
|
+
cmap="turbo",
|
|
845
|
+
norm=plt.Normalize(vmin=ray_elevation_deg.min(), vmax=ray_elevation_deg.max()),
|
|
846
|
+
)
|
|
847
|
+
sm.set_array([])
|
|
848
|
+
plt.colorbar(sm, ax=ax_dens_lin, label="Ray Angle from Horizontal (°)")
|
|
849
|
+
|
|
850
|
+
plt.tight_layout()
|
|
851
|
+
plt.savefig(
|
|
852
|
+
output_path
|
|
853
|
+
/ f"local_simulation_{timestamp}_intensity_angle_linear_density.png",
|
|
854
|
+
dpi=150,
|
|
855
|
+
bbox_inches="tight",
|
|
856
|
+
)
|
|
857
|
+
plt.close()
|
|
858
|
+
print(
|
|
859
|
+
f" Saved: {output_path / f'local_simulation_{timestamp}_intensity_angle_linear_density.png'}"
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _create_3d_visualization(recorded_rays, config, output_path, timestamp):
|
|
864
|
+
"""Create 3D scatter plot of recorded rays."""
|
|
865
|
+
|
|
866
|
+
fig = plt.figure(figsize=(14, 10))
|
|
867
|
+
ax = fig.add_subplot(111, projection="3d")
|
|
868
|
+
|
|
869
|
+
if recorded_rays.num_rays > 0:
|
|
870
|
+
n_plot = min(500, recorded_rays.num_rays)
|
|
871
|
+
indices = np.random.choice(recorded_rays.num_rays, n_plot, replace=False)
|
|
872
|
+
|
|
873
|
+
positions = recorded_rays.positions[indices] / 1000 # km
|
|
874
|
+
intensities = recorded_rays.intensities[indices]
|
|
875
|
+
|
|
876
|
+
scatter = ax.scatter(
|
|
877
|
+
positions[:, 0],
|
|
878
|
+
positions[:, 1],
|
|
879
|
+
positions[:, 2],
|
|
880
|
+
c=intensities,
|
|
881
|
+
cmap="hot",
|
|
882
|
+
s=10,
|
|
883
|
+
alpha=0.6,
|
|
884
|
+
)
|
|
885
|
+
plt.colorbar(scatter, ax=ax, label="Intensity", shrink=0.6)
|
|
886
|
+
|
|
887
|
+
# Draw coordinate axes at origin
|
|
888
|
+
axis_length = config["recording_altitude"] / 1000 * 0.3
|
|
889
|
+
ax.quiver(0, 0, 0, axis_length, 0, 0, color="r", arrow_length_ratio=0.1, label="X")
|
|
890
|
+
ax.quiver(0, 0, 0, 0, axis_length, 0, color="g", arrow_length_ratio=0.1, label="Y")
|
|
891
|
+
ax.quiver(0, 0, 0, 0, 0, axis_length, color="b", arrow_length_ratio=0.1, label="Z")
|
|
892
|
+
|
|
893
|
+
ax.set_xlabel("X (km)", fontsize=11)
|
|
894
|
+
ax.set_ylabel("Y (km)", fontsize=11)
|
|
895
|
+
ax.set_zlabel("Z (km)", fontsize=11)
|
|
896
|
+
ax.set_title("Recorded Rays at Detection Sphere (3D)", fontsize=14)
|
|
897
|
+
|
|
898
|
+
plt.tight_layout()
|
|
899
|
+
fig_path = output_path / f"local_simulation_{timestamp}_3d.png"
|
|
900
|
+
plt.savefig(fig_path, dpi=150, bbox_inches="tight")
|
|
901
|
+
plt.close()
|
|
902
|
+
print(f" Saved: {fig_path}")
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def _create_energy_conservation(
|
|
906
|
+
original_rays,
|
|
907
|
+
recorded_rays,
|
|
908
|
+
reflected_rays,
|
|
909
|
+
refracted_rays,
|
|
910
|
+
config,
|
|
911
|
+
output_path,
|
|
912
|
+
timestamp,
|
|
913
|
+
):
|
|
914
|
+
"""Create energy conservation check figure."""
|
|
915
|
+
|
|
916
|
+
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
|
|
917
|
+
|
|
918
|
+
# Calculate intensities
|
|
919
|
+
input_intensity = np.sum(original_rays.intensities)
|
|
920
|
+
output_intensity = (
|
|
921
|
+
np.sum(recorded_rays.intensities) if recorded_rays.num_rays > 0 else 0
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
if reflected_rays.num_rays > 0:
|
|
925
|
+
up_mask = reflected_rays.directions[:, 2] > 0
|
|
926
|
+
reflected_intensity = np.sum(reflected_rays.intensities[up_mask])
|
|
927
|
+
else:
|
|
928
|
+
reflected_intensity = 0
|
|
929
|
+
|
|
930
|
+
if refracted_rays.num_rays > 0:
|
|
931
|
+
refracted_intensity = np.sum(refracted_rays.intensities)
|
|
932
|
+
else:
|
|
933
|
+
refracted_intensity = 0
|
|
934
|
+
|
|
935
|
+
# Bar chart
|
|
936
|
+
ax1 = axes[0]
|
|
937
|
+
categories = ["Input", "Reflected\n(upward)", "Refracted\n(downward)", "Recorded"]
|
|
938
|
+
values = [
|
|
939
|
+
input_intensity,
|
|
940
|
+
reflected_intensity,
|
|
941
|
+
refracted_intensity,
|
|
942
|
+
output_intensity,
|
|
943
|
+
]
|
|
944
|
+
colors = ["steelblue", "coral", "lightblue", "green"]
|
|
945
|
+
ax1.bar(categories, values, color=colors, edgecolor="black")
|
|
946
|
+
ax1.set_ylabel("Total Intensity", fontsize=11)
|
|
947
|
+
ax1.set_title("Energy Balance", fontsize=12)
|
|
948
|
+
ax1.grid(True, alpha=0.3, axis="y")
|
|
949
|
+
|
|
950
|
+
for i, (_cat, val) in enumerate(zip(categories, values, strict=False)):
|
|
951
|
+
ax1.text(i, val + 0.02 * max(values), f"{val:.3f}", ha="center", fontsize=10)
|
|
952
|
+
|
|
953
|
+
# Efficiency text
|
|
954
|
+
ax2 = axes[1]
|
|
955
|
+
if input_intensity > 0:
|
|
956
|
+
efficiency = output_intensity / input_intensity * 100
|
|
957
|
+
reflected_frac = reflected_intensity / input_intensity * 100
|
|
958
|
+
refracted_frac = refracted_intensity / input_intensity * 100
|
|
959
|
+
else:
|
|
960
|
+
efficiency = 0
|
|
961
|
+
reflected_frac = 0
|
|
962
|
+
refracted_frac = 0
|
|
963
|
+
|
|
964
|
+
info_text = f"""Simulation Summary
|
|
965
|
+
─────────────────────────────
|
|
966
|
+
Input rays: {original_rays.num_rays:,}
|
|
967
|
+
Recorded rays: {recorded_rays.num_rays:,}
|
|
968
|
+
|
|
969
|
+
Input intensity: {input_intensity:.4f}
|
|
970
|
+
Reflected intensity: {reflected_intensity:.4f} ({reflected_frac:.1f}%)
|
|
971
|
+
Refracted intensity: {refracted_intensity:.4f} ({refracted_frac:.1f}%)
|
|
972
|
+
Recorded intensity: {output_intensity:.4f}
|
|
973
|
+
|
|
974
|
+
Recording efficiency: {efficiency:.2f}%
|
|
975
|
+
─────────────────────────────
|
|
976
|
+
Recording altitude: {config['recording_altitude']/1000:.0f} km
|
|
977
|
+
Grazing angle: {config['grazing_angle']:.1f}°
|
|
978
|
+
Wave amplitude: {config['wave_amplitude']:.2f} m
|
|
979
|
+
Wave wavelength: {config['wave_wavelength']:.1f} m
|
|
980
|
+
"""
|
|
981
|
+
|
|
982
|
+
ax2.text(
|
|
983
|
+
0.1,
|
|
984
|
+
0.5,
|
|
985
|
+
info_text,
|
|
986
|
+
transform=ax2.transAxes,
|
|
987
|
+
fontsize=11,
|
|
988
|
+
verticalalignment="center",
|
|
989
|
+
fontfamily="monospace",
|
|
990
|
+
bbox={"boxstyle": "round", "facecolor": "wheat", "alpha": 0.5},
|
|
991
|
+
)
|
|
992
|
+
ax2.axis("off")
|
|
993
|
+
ax2.set_title("Simulation Statistics", fontsize=12)
|
|
994
|
+
|
|
995
|
+
plt.tight_layout()
|
|
996
|
+
fig_path = output_path / f"local_simulation_{timestamp}_energy.png"
|
|
997
|
+
plt.savefig(fig_path, dpi=150, bbox_inches="tight")
|
|
998
|
+
plt.close()
|
|
999
|
+
print(f" Saved: {fig_path}")
|