lsurf 1.0.0__py3-none-any.whl

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