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,754 @@
|
|
|
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
|
+
Atmospheric Refraction Visualization
|
|
36
|
+
|
|
37
|
+
Functions for plotting ray trajectories through inhomogeneous atmospheres
|
|
38
|
+
and refractive index profiles.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
import matplotlib.pyplot as plt
|
|
45
|
+
import numpy as np
|
|
46
|
+
from numpy.typing import NDArray
|
|
47
|
+
|
|
48
|
+
from ..materials.utils.constants import EARTH_RADIUS
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def plot_ray_trajectories(
|
|
52
|
+
ax: plt.Axes,
|
|
53
|
+
trajectories: dict[float, NDArray],
|
|
54
|
+
source_altitude: float = 0.0,
|
|
55
|
+
show_straight_lines: bool = True,
|
|
56
|
+
show_earth_surface: bool = True,
|
|
57
|
+
duct_center: float = 0.0,
|
|
58
|
+
duct_width: float = 0.0,
|
|
59
|
+
xlim: tuple[float, float] | None = None,
|
|
60
|
+
ylim: tuple[float, float] | None = None,
|
|
61
|
+
cmap: str = "turbo",
|
|
62
|
+
linewidth: float = 1.5,
|
|
63
|
+
impact_parameter_keys: bool = False,
|
|
64
|
+
use_colorbar: bool = False,
|
|
65
|
+
) -> plt.cm.ScalarMappable | None:
|
|
66
|
+
"""
|
|
67
|
+
Plot ray trajectories on a single axis.
|
|
68
|
+
|
|
69
|
+
Parameters
|
|
70
|
+
----------
|
|
71
|
+
ax : matplotlib.axes.Axes
|
|
72
|
+
Axes to plot on.
|
|
73
|
+
trajectories : dict
|
|
74
|
+
Dictionary mapping initial angle (degrees) or impact parameter (meters)
|
|
75
|
+
to trajectory array. Each trajectory is shape (N, 2) with [x, z] in meters.
|
|
76
|
+
source_altitude : float
|
|
77
|
+
Source altitude in meters (for straight line reference).
|
|
78
|
+
show_straight_lines : bool
|
|
79
|
+
If True, show dashed straight-line references.
|
|
80
|
+
show_earth_surface : bool
|
|
81
|
+
If True, show Earth's curved surface.
|
|
82
|
+
xlim : tuple, optional
|
|
83
|
+
X-axis limits in km.
|
|
84
|
+
ylim : tuple, optional
|
|
85
|
+
Y-axis limits in km.
|
|
86
|
+
cmap : str
|
|
87
|
+
Colormap for ray colors.
|
|
88
|
+
linewidth : float
|
|
89
|
+
Line width for trajectories.
|
|
90
|
+
impact_parameter_keys : bool
|
|
91
|
+
If True, dictionary keys are impact parameters in meters (not angles).
|
|
92
|
+
use_colorbar : bool
|
|
93
|
+
If True, use colorbar instead of legend (better for many rays).
|
|
94
|
+
|
|
95
|
+
Returns
|
|
96
|
+
-------
|
|
97
|
+
sm : ScalarMappable or None
|
|
98
|
+
ScalarMappable for colorbar if use_colorbar=True, else None.
|
|
99
|
+
|
|
100
|
+
Examples
|
|
101
|
+
--------
|
|
102
|
+
>>> fig, ax = plt.subplots()
|
|
103
|
+
>>> plot_ray_trajectories(ax, trajectories, source_altitude=0)
|
|
104
|
+
>>> plt.show()
|
|
105
|
+
"""
|
|
106
|
+
keys = list(trajectories.keys())
|
|
107
|
+
n_rays = len(keys)
|
|
108
|
+
colors = plt.get_cmap(cmap)(np.linspace(0.1, 0.9, n_rays))
|
|
109
|
+
|
|
110
|
+
# For colorbar, create a ScalarMappable
|
|
111
|
+
sm = None
|
|
112
|
+
if use_colorbar and impact_parameter_keys:
|
|
113
|
+
norm = plt.Normalize(vmin=min(keys) / 1000, vmax=max(keys) / 1000)
|
|
114
|
+
sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm)
|
|
115
|
+
sm.set_array([])
|
|
116
|
+
|
|
117
|
+
for i, ((key, traj), color) in enumerate(zip(trajectories.items(), colors)):
|
|
118
|
+
x_km = traj[:, 0] / 1000
|
|
119
|
+
z_km = traj[:, 1] / 1000
|
|
120
|
+
|
|
121
|
+
# Determine label
|
|
122
|
+
if use_colorbar:
|
|
123
|
+
label = None
|
|
124
|
+
elif impact_parameter_keys:
|
|
125
|
+
label = f"{key / 1000:.1f} km"
|
|
126
|
+
else:
|
|
127
|
+
label = f"{key}°"
|
|
128
|
+
|
|
129
|
+
# Plot refracted ray
|
|
130
|
+
ax.plot(x_km, z_km, color=color, linewidth=linewidth, label=label)
|
|
131
|
+
|
|
132
|
+
# Plot straight-line reference (only for angle-based trajectories)
|
|
133
|
+
if show_straight_lines and not impact_parameter_keys:
|
|
134
|
+
angle_rad = np.radians(key)
|
|
135
|
+
if np.abs(np.cos(angle_rad)) > 0.1:
|
|
136
|
+
max_x = traj[-1, 0]
|
|
137
|
+
straight_z = (source_altitude + max_x * np.tan(angle_rad)) / 1000
|
|
138
|
+
ax.plot(
|
|
139
|
+
[0, max_x / 1000],
|
|
140
|
+
[source_altitude / 1000, straight_z],
|
|
141
|
+
color=color,
|
|
142
|
+
linewidth=linewidth * 0.6,
|
|
143
|
+
linestyle="--",
|
|
144
|
+
alpha=0.4,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Plot Earth's surface
|
|
148
|
+
if show_earth_surface and xlim is not None:
|
|
149
|
+
x_surface = np.linspace(xlim[0] * 1000, xlim[1] * 1000, 500)
|
|
150
|
+
z_surface = -EARTH_RADIUS + np.sqrt(
|
|
151
|
+
np.maximum(EARTH_RADIUS**2 - x_surface**2, 0)
|
|
152
|
+
)
|
|
153
|
+
ax.fill_between(
|
|
154
|
+
x_surface / 1000, z_surface / 1000, -50, color="saddlebrown", alpha=0.3
|
|
155
|
+
)
|
|
156
|
+
ax.plot(x_surface / 1000, z_surface / 1000, "k-", linewidth=2)
|
|
157
|
+
|
|
158
|
+
# Plot duct region
|
|
159
|
+
if show_earth_surface and xlim is not None and duct_width > 0:
|
|
160
|
+
x_surface = np.linspace(xlim[0] * 1000, xlim[1] * 1000, 500)
|
|
161
|
+
z_surface_l = -EARTH_RADIUS + np.sqrt(
|
|
162
|
+
np.maximum(
|
|
163
|
+
(EARTH_RADIUS + duct_center - duct_width / 2) ** 2 - x_surface**2,
|
|
164
|
+
0,
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
z_surface_u = -EARTH_RADIUS + np.sqrt(
|
|
168
|
+
np.maximum(
|
|
169
|
+
(EARTH_RADIUS + duct_center + duct_width / 2) ** 2 - x_surface**2, 0
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
ax.fill_between(
|
|
173
|
+
x_surface / 1000,
|
|
174
|
+
z_surface_l / 1000,
|
|
175
|
+
z_surface_u / 1000,
|
|
176
|
+
color="blue",
|
|
177
|
+
alpha=0.1,
|
|
178
|
+
)
|
|
179
|
+
ax.plot(x_surface / 1000, z_surface_l / 1000, "k:", linewidth=1)
|
|
180
|
+
ax.plot(x_surface / 1000, z_surface_u / 1000, "k:", linewidth=1)
|
|
181
|
+
|
|
182
|
+
ax.set_xlabel("Horizontal distance (km)")
|
|
183
|
+
ax.set_ylabel("Altitude (km)")
|
|
184
|
+
ax.grid(True, alpha=0.3)
|
|
185
|
+
|
|
186
|
+
# Add legend only if not using colorbar and limited number of rays
|
|
187
|
+
if not use_colorbar:
|
|
188
|
+
title = "Impact param (km)" if impact_parameter_keys else "Initial angle"
|
|
189
|
+
ax.legend(title=title, loc="upper left", fontsize=8, ncol=2)
|
|
190
|
+
|
|
191
|
+
if xlim:
|
|
192
|
+
ax.set_xlim(xlim)
|
|
193
|
+
if ylim:
|
|
194
|
+
ax.set_ylim(ylim)
|
|
195
|
+
|
|
196
|
+
return sm
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def plot_refractive_index_profile(
|
|
200
|
+
ax: plt.Axes,
|
|
201
|
+
atmosphere: Any,
|
|
202
|
+
max_altitude_km: float = 100.0,
|
|
203
|
+
n_points: int = 500,
|
|
204
|
+
color: str = "b",
|
|
205
|
+
linewidth: float = 2.0,
|
|
206
|
+
annotate_sea_level: bool = True,
|
|
207
|
+
wavelength: float = 532e-9,
|
|
208
|
+
) -> None:
|
|
209
|
+
"""
|
|
210
|
+
Plot refractive index vs altitude profile.
|
|
211
|
+
|
|
212
|
+
Parameters
|
|
213
|
+
----------
|
|
214
|
+
ax : matplotlib.axes.Axes
|
|
215
|
+
Axes to plot on.
|
|
216
|
+
atmosphere : MaterialField
|
|
217
|
+
Atmosphere material with n_at_altitude method.
|
|
218
|
+
max_altitude_km : float
|
|
219
|
+
Maximum altitude in km.
|
|
220
|
+
n_points : int
|
|
221
|
+
Number of points for profile.
|
|
222
|
+
color : str
|
|
223
|
+
Line color.
|
|
224
|
+
linewidth : float
|
|
225
|
+
Line width.
|
|
226
|
+
annotate_sea_level : bool
|
|
227
|
+
If True, annotate the sea level value.
|
|
228
|
+
wavelength : float
|
|
229
|
+
Wavelength in meters for spectral atmospheres (default: 532nm).
|
|
230
|
+
|
|
231
|
+
Examples
|
|
232
|
+
--------
|
|
233
|
+
>>> fig, ax = plt.subplots()
|
|
234
|
+
>>> plot_refractive_index_profile(ax, atmosphere)
|
|
235
|
+
>>> plt.show()
|
|
236
|
+
"""
|
|
237
|
+
altitudes_km = np.linspace(0, max_altitude_km, n_points)
|
|
238
|
+
|
|
239
|
+
# Handle both simple (1-arg) and spectral (2-arg) atmospheres
|
|
240
|
+
def get_n(altitude_m: float) -> float:
|
|
241
|
+
try:
|
|
242
|
+
return atmosphere.n_at_altitude(altitude_m, wavelength)
|
|
243
|
+
except TypeError:
|
|
244
|
+
return atmosphere.n_at_altitude(altitude_m)
|
|
245
|
+
|
|
246
|
+
n_values = [get_n(h * 1000) for h in altitudes_km]
|
|
247
|
+
|
|
248
|
+
ax.plot(n_values, altitudes_km, color=color, linewidth=linewidth)
|
|
249
|
+
ax.set_xlabel("Refractive Index n")
|
|
250
|
+
ax.set_ylabel("Altitude (km)")
|
|
251
|
+
ax.set_title("Atmospheric Refractive Index Profile")
|
|
252
|
+
ax.grid(True, alpha=0.3)
|
|
253
|
+
|
|
254
|
+
# Annotate sea level
|
|
255
|
+
if annotate_sea_level:
|
|
256
|
+
n_sea = get_n(0)
|
|
257
|
+
ax.axhline(y=0, color="brown", linestyle="-", linewidth=1)
|
|
258
|
+
# Place text inside plot, above sea level line
|
|
259
|
+
ax.text(
|
|
260
|
+
n_sea,
|
|
261
|
+
max_altitude_km * 0.08,
|
|
262
|
+
f"n₀ = {n_sea:.6f}",
|
|
263
|
+
fontsize=9,
|
|
264
|
+
ha="center",
|
|
265
|
+
va="bottom",
|
|
266
|
+
bbox=dict(boxstyle="round,pad=0.3", facecolor="white", alpha=0.8),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def create_atmospheric_refraction_figure(
|
|
271
|
+
trajectories: dict[float, NDArray],
|
|
272
|
+
atmosphere: Any,
|
|
273
|
+
title: str = "Atmospheric Refraction",
|
|
274
|
+
source_altitude: float = 0.0,
|
|
275
|
+
xlim: tuple[float, float] | None = None,
|
|
276
|
+
ylim: tuple[float, float] | None = None,
|
|
277
|
+
show_earth_surface: bool = True,
|
|
278
|
+
show_straight_lines: bool = True,
|
|
279
|
+
duct_center: float = 0.0,
|
|
280
|
+
duct_width: float = 0.0,
|
|
281
|
+
figsize: tuple[float, float] = (14, 5),
|
|
282
|
+
) -> plt.Figure:
|
|
283
|
+
"""
|
|
284
|
+
Create a two-panel figure with ray trajectories and refractive index profile.
|
|
285
|
+
|
|
286
|
+
Parameters
|
|
287
|
+
----------
|
|
288
|
+
trajectories : dict
|
|
289
|
+
Dictionary mapping initial angle (degrees) to trajectory array.
|
|
290
|
+
Each trajectory is shape (N, 2) with columns [x, z] in meters.
|
|
291
|
+
atmosphere : MaterialField
|
|
292
|
+
Atmosphere material with n_at_altitude method.
|
|
293
|
+
title : str
|
|
294
|
+
Title for the trajectory plot.
|
|
295
|
+
source_altitude : float
|
|
296
|
+
Source altitude in meters.
|
|
297
|
+
xlim : tuple, optional
|
|
298
|
+
X-axis limits in km for trajectory plot.
|
|
299
|
+
ylim : tuple, optional
|
|
300
|
+
Y-axis limits in km for trajectory plot.
|
|
301
|
+
show_earth_surface : bool
|
|
302
|
+
If True, show Earth's curved surface in trajectory plot.
|
|
303
|
+
show_straight_lines : bool
|
|
304
|
+
If True, show dashed straight-line references.
|
|
305
|
+
figsize : tuple
|
|
306
|
+
Figure size (width, height) in inches.
|
|
307
|
+
|
|
308
|
+
Returns
|
|
309
|
+
-------
|
|
310
|
+
fig : matplotlib.figure.Figure
|
|
311
|
+
The created figure.
|
|
312
|
+
|
|
313
|
+
Examples
|
|
314
|
+
--------
|
|
315
|
+
>>> fig = create_atmospheric_refraction_figure(
|
|
316
|
+
... trajectories, atmosphere,
|
|
317
|
+
... title="Exponential Atmosphere",
|
|
318
|
+
... xlim=(0, 1000), ylim=(0, 100),
|
|
319
|
+
... )
|
|
320
|
+
>>> plt.savefig("refraction.png")
|
|
321
|
+
"""
|
|
322
|
+
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=figsize)
|
|
323
|
+
|
|
324
|
+
# Left: Ray trajectories
|
|
325
|
+
plot_ray_trajectories(
|
|
326
|
+
ax1,
|
|
327
|
+
trajectories,
|
|
328
|
+
source_altitude=source_altitude,
|
|
329
|
+
show_straight_lines=show_straight_lines,
|
|
330
|
+
show_earth_surface=show_earth_surface,
|
|
331
|
+
duct_center=duct_center,
|
|
332
|
+
duct_width=duct_width,
|
|
333
|
+
xlim=xlim,
|
|
334
|
+
ylim=ylim,
|
|
335
|
+
)
|
|
336
|
+
subtitle = "(solid=refracted, dashed=straight)" if show_straight_lines else ""
|
|
337
|
+
ax1.set_title(f"{title}\n{subtitle}".strip())
|
|
338
|
+
|
|
339
|
+
# Right: Refractive index profile
|
|
340
|
+
max_alt = ylim[1] if ylim else 100.0
|
|
341
|
+
plot_refractive_index_profile(
|
|
342
|
+
ax=ax2, atmosphere=atmosphere, max_altitude_km=max_alt
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
plt.tight_layout()
|
|
346
|
+
return fig
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def plot_trajectory_comparison(
|
|
350
|
+
results: list[dict],
|
|
351
|
+
figsize: tuple[float, float] | None = None,
|
|
352
|
+
suptitle: str = "Atmospheric Model Comparison",
|
|
353
|
+
) -> plt.Figure:
|
|
354
|
+
"""
|
|
355
|
+
Create a comparison figure with multiple trajectory plots.
|
|
356
|
+
|
|
357
|
+
Parameters
|
|
358
|
+
----------
|
|
359
|
+
results : list of dict
|
|
360
|
+
List of result dictionaries, each containing:
|
|
361
|
+
- 'trajectories': dict mapping angle to trajectory array
|
|
362
|
+
- 'title': plot title
|
|
363
|
+
- 'xlim': x-axis limits in km
|
|
364
|
+
- 'ylim': y-axis limits in km
|
|
365
|
+
figsize : tuple, optional
|
|
366
|
+
Figure size. Auto-determined if None.
|
|
367
|
+
suptitle : str
|
|
368
|
+
Super title for the figure.
|
|
369
|
+
|
|
370
|
+
Returns
|
|
371
|
+
-------
|
|
372
|
+
fig : matplotlib.figure.Figure
|
|
373
|
+
The created figure.
|
|
374
|
+
|
|
375
|
+
Examples
|
|
376
|
+
--------
|
|
377
|
+
>>> results = [
|
|
378
|
+
... {'trajectories': traj1, 'title': 'Model A', 'xlim': (0, 100), 'ylim': (0, 50)},
|
|
379
|
+
... {'trajectories': traj2, 'title': 'Model B', 'xlim': (0, 100), 'ylim': (0, 50)},
|
|
380
|
+
... ]
|
|
381
|
+
>>> fig = plot_trajectory_comparison(results)
|
|
382
|
+
"""
|
|
383
|
+
n_plots = len(results)
|
|
384
|
+
if n_plots == 0:
|
|
385
|
+
return plt.figure()
|
|
386
|
+
|
|
387
|
+
# Determine grid layout
|
|
388
|
+
if n_plots <= 2:
|
|
389
|
+
rows, cols = 1, n_plots
|
|
390
|
+
elif n_plots <= 4:
|
|
391
|
+
rows, cols = 2, 2
|
|
392
|
+
elif n_plots <= 6:
|
|
393
|
+
rows, cols = 2, 3
|
|
394
|
+
else:
|
|
395
|
+
rows, cols = 3, 3
|
|
396
|
+
|
|
397
|
+
if figsize is None:
|
|
398
|
+
figsize = (5 * cols, 4 * rows)
|
|
399
|
+
|
|
400
|
+
fig, axes = plt.subplots(rows, cols, figsize=figsize)
|
|
401
|
+
if n_plots == 1:
|
|
402
|
+
axes = [axes]
|
|
403
|
+
else:
|
|
404
|
+
axes = axes.flatten()
|
|
405
|
+
|
|
406
|
+
for ax, result in zip(axes, results):
|
|
407
|
+
traj = result["trajectories"]
|
|
408
|
+
colors = plt.get_cmap("turbo")(np.linspace(0.1, 0.9, len(traj)))
|
|
409
|
+
|
|
410
|
+
for (angle_deg, t), color in zip(traj.items(), colors):
|
|
411
|
+
ax.plot(t[:, 0] / 1000, t[:, 1] / 1000, color=color, linewidth=1.5)
|
|
412
|
+
|
|
413
|
+
ax.set_title(result.get("title", ""), fontsize=10)
|
|
414
|
+
if "xlim" in result:
|
|
415
|
+
ax.set_xlim(result["xlim"])
|
|
416
|
+
if "ylim" in result:
|
|
417
|
+
ax.set_ylim(result["ylim"])
|
|
418
|
+
ax.set_xlabel("Distance (km)", fontsize=9)
|
|
419
|
+
ax.set_ylabel("Altitude (km)", fontsize=9)
|
|
420
|
+
ax.grid(True, alpha=0.3)
|
|
421
|
+
|
|
422
|
+
# Hide unused subplots
|
|
423
|
+
for ax in axes[n_plots:]:
|
|
424
|
+
ax.axis("off")
|
|
425
|
+
|
|
426
|
+
plt.suptitle(suptitle, fontsize=14)
|
|
427
|
+
plt.tight_layout()
|
|
428
|
+
return fig
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def plot_trajectory_offset(
|
|
432
|
+
ax: plt.Axes,
|
|
433
|
+
trajectories: dict[float, NDArray],
|
|
434
|
+
source_altitude: float = 0.0,
|
|
435
|
+
cmap: str = "turbo",
|
|
436
|
+
linewidth: float = 1.5,
|
|
437
|
+
xlim: tuple[float, float] | None = None,
|
|
438
|
+
) -> None:
|
|
439
|
+
"""
|
|
440
|
+
Plot angular deviation from initial ray direction vs horizontal distance.
|
|
441
|
+
|
|
442
|
+
Shows how much the ray direction has changed from its initial launch angle
|
|
443
|
+
as it propagates through the atmosphere. Atmospheric refraction typically
|
|
444
|
+
bends rays downward (toward higher n), resulting in negative angular offsets.
|
|
445
|
+
|
|
446
|
+
Parameters
|
|
447
|
+
----------
|
|
448
|
+
ax : matplotlib.axes.Axes
|
|
449
|
+
Axes to plot on.
|
|
450
|
+
trajectories : dict
|
|
451
|
+
Dictionary mapping initial angle (degrees) to trajectory array.
|
|
452
|
+
Each trajectory is shape (N, 2) with columns [x, z] in meters.
|
|
453
|
+
source_altitude : float
|
|
454
|
+
Source altitude in meters (unused, kept for API compatibility).
|
|
455
|
+
cmap : str
|
|
456
|
+
Colormap for ray colors.
|
|
457
|
+
linewidth : float
|
|
458
|
+
Line width for curves.
|
|
459
|
+
xlim : tuple, optional
|
|
460
|
+
X-axis limits in km.
|
|
461
|
+
|
|
462
|
+
Examples
|
|
463
|
+
--------
|
|
464
|
+
>>> fig, ax = plt.subplots()
|
|
465
|
+
>>> plot_trajectory_offset(ax, trajectories, source_altitude=0)
|
|
466
|
+
>>> plt.show()
|
|
467
|
+
"""
|
|
468
|
+
colors = plt.get_cmap(cmap)(np.linspace(0.1, 0.9, len(trajectories)))
|
|
469
|
+
|
|
470
|
+
for (angle_deg, traj), color in zip(trajectories.items(), colors):
|
|
471
|
+
x_m = traj[:, 0]
|
|
472
|
+
|
|
473
|
+
# Compute direction angle at each point from finite differences
|
|
474
|
+
dx = np.diff(traj[:, 0])
|
|
475
|
+
dz = np.diff(traj[:, 1])
|
|
476
|
+
|
|
477
|
+
# Direction angle in degrees (angle above horizontal)
|
|
478
|
+
segment_angles = np.degrees(np.arctan2(dz, dx))
|
|
479
|
+
|
|
480
|
+
# Compute angular deviation from initial direction
|
|
481
|
+
# First point has zero deviation, subsequent points show the change
|
|
482
|
+
angular_deviation = np.zeros(len(traj))
|
|
483
|
+
angular_deviation[0] = 0.0 # No deviation at start
|
|
484
|
+
angular_deviation[1:] = segment_angles - angle_deg # Deviation from initial
|
|
485
|
+
|
|
486
|
+
ax.plot(
|
|
487
|
+
x_m / 1000,
|
|
488
|
+
angular_deviation,
|
|
489
|
+
color=color,
|
|
490
|
+
linewidth=linewidth,
|
|
491
|
+
label=f"{angle_deg}°",
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
ax.axhline(y=0, color="gray", linestyle="--", linewidth=1, alpha=0.5)
|
|
495
|
+
ax.set_xlabel("Horizontal distance (km)")
|
|
496
|
+
ax.set_ylabel("Angular deviation (°)")
|
|
497
|
+
ax.set_title("Angular Deviation from Initial Direction")
|
|
498
|
+
ax.legend(title="Initial angle", loc="best", fontsize=9)
|
|
499
|
+
ax.grid(True, alpha=0.3)
|
|
500
|
+
|
|
501
|
+
if xlim:
|
|
502
|
+
ax.set_xlim(xlim)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def create_atmospheric_refraction_figure_with_offset(
|
|
506
|
+
trajectories: dict[float, NDArray],
|
|
507
|
+
atmosphere: Any,
|
|
508
|
+
title: str = "Atmospheric Refraction",
|
|
509
|
+
source_altitude: float = 0.0,
|
|
510
|
+
xlim: tuple[float, float] | None = None,
|
|
511
|
+
ylim: tuple[float, float] | None = None,
|
|
512
|
+
show_earth_surface: bool = True,
|
|
513
|
+
show_straight_lines: bool = True,
|
|
514
|
+
duct_center: float = 0.0,
|
|
515
|
+
duct_width: float = 0.0,
|
|
516
|
+
figsize: tuple[float, float] = (16, 10),
|
|
517
|
+
horizontal_rays: bool = False,
|
|
518
|
+
) -> plt.Figure:
|
|
519
|
+
"""
|
|
520
|
+
Create a four-panel figure with trajectories, offset, and refractive index.
|
|
521
|
+
|
|
522
|
+
Panels:
|
|
523
|
+
- Top-left: Ray trajectories (refracted vs straight-line)
|
|
524
|
+
- Top-right: Refractive index profile vs altitude
|
|
525
|
+
- Bottom: Altitude offset between refracted and straight paths
|
|
526
|
+
|
|
527
|
+
Parameters
|
|
528
|
+
----------
|
|
529
|
+
trajectories : dict
|
|
530
|
+
Dictionary mapping initial angle (degrees) to trajectory array.
|
|
531
|
+
Each trajectory is shape (N, 2) with columns [x, z] in meters.
|
|
532
|
+
atmosphere : MaterialField
|
|
533
|
+
Atmosphere material with n_at_altitude method.
|
|
534
|
+
title : str
|
|
535
|
+
Title for the figure.
|
|
536
|
+
source_altitude : float
|
|
537
|
+
Source altitude in meters.
|
|
538
|
+
xlim : tuple, optional
|
|
539
|
+
X-axis limits in km for trajectory plot.
|
|
540
|
+
ylim : tuple, optional
|
|
541
|
+
Y-axis limits in km for trajectory plot.
|
|
542
|
+
show_earth_surface : bool
|
|
543
|
+
If True, show Earth's curved surface in trajectory plot.
|
|
544
|
+
show_straight_lines : bool
|
|
545
|
+
If True, show dashed straight-line references.
|
|
546
|
+
figsize : tuple
|
|
547
|
+
Figure size (width, height) in inches.
|
|
548
|
+
horizontal_rays : bool
|
|
549
|
+
If True, use plot for horizontal rays with varying impact parameters.
|
|
550
|
+
If False (default), use plot for rays with varying initial angles.
|
|
551
|
+
|
|
552
|
+
Returns
|
|
553
|
+
-------
|
|
554
|
+
fig : matplotlib.figure.Figure
|
|
555
|
+
The created figure.
|
|
556
|
+
|
|
557
|
+
Examples
|
|
558
|
+
--------
|
|
559
|
+
>>> fig = create_atmospheric_refraction_figure_with_offset(
|
|
560
|
+
... trajectories, atmosphere,
|
|
561
|
+
... title="Exponential Atmosphere",
|
|
562
|
+
... xlim=(0, 1000), ylim=(0, 100),
|
|
563
|
+
... )
|
|
564
|
+
>>> plt.savefig("refraction_with_offset.png")
|
|
565
|
+
"""
|
|
566
|
+
fig = plt.figure(figsize=figsize)
|
|
567
|
+
|
|
568
|
+
# Create grid using GridSpec for better control
|
|
569
|
+
if horizontal_rays:
|
|
570
|
+
# Use gridspec to leave room for colorbar
|
|
571
|
+
gs = fig.add_gridspec(2, 3, width_ratios=[1, 1, 0.05], wspace=0.3, hspace=0.3)
|
|
572
|
+
ax1 = fig.add_subplot(gs[0, 0]) # Top-left: trajectories
|
|
573
|
+
ax2 = fig.add_subplot(gs[0, 1]) # Top-right: n profile
|
|
574
|
+
ax3 = fig.add_subplot(gs[1, 0:2]) # Bottom: deviation (spans 2 cols)
|
|
575
|
+
cax = fig.add_subplot(gs[:, 2]) # Colorbar (right side, full height)
|
|
576
|
+
else:
|
|
577
|
+
ax1 = fig.add_subplot(2, 2, 1)
|
|
578
|
+
ax2 = fig.add_subplot(2, 2, 2)
|
|
579
|
+
ax3 = fig.add_subplot(2, 1, 2)
|
|
580
|
+
|
|
581
|
+
# Top-left: Ray trajectories
|
|
582
|
+
sm = plot_ray_trajectories(
|
|
583
|
+
ax1,
|
|
584
|
+
trajectories,
|
|
585
|
+
source_altitude=source_altitude,
|
|
586
|
+
show_straight_lines=show_straight_lines and not horizontal_rays,
|
|
587
|
+
show_earth_surface=show_earth_surface,
|
|
588
|
+
duct_center=duct_center,
|
|
589
|
+
duct_width=duct_width,
|
|
590
|
+
xlim=xlim,
|
|
591
|
+
ylim=ylim,
|
|
592
|
+
impact_parameter_keys=horizontal_rays,
|
|
593
|
+
use_colorbar=horizontal_rays,
|
|
594
|
+
)
|
|
595
|
+
ax1.set_title("Ray Trajectories")
|
|
596
|
+
|
|
597
|
+
# Top-right: Refractive index profile
|
|
598
|
+
max_alt = ylim[1] if ylim else 100.0
|
|
599
|
+
plot_refractive_index_profile(
|
|
600
|
+
ax=ax2, atmosphere=atmosphere, max_altitude_km=max_alt
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
# Bottom: Angular deviation
|
|
604
|
+
if horizontal_rays:
|
|
605
|
+
plot_horizontal_ray_deviation(
|
|
606
|
+
ax3,
|
|
607
|
+
trajectories,
|
|
608
|
+
xlim=xlim,
|
|
609
|
+
use_colorbar=True,
|
|
610
|
+
)
|
|
611
|
+
# Add shared colorbar
|
|
612
|
+
if sm is not None:
|
|
613
|
+
cbar = fig.colorbar(sm, cax=cax)
|
|
614
|
+
cbar.set_label("Impact parameter (km)", fontsize=10)
|
|
615
|
+
else:
|
|
616
|
+
plot_trajectory_offset(
|
|
617
|
+
ax3,
|
|
618
|
+
trajectories,
|
|
619
|
+
source_altitude=source_altitude,
|
|
620
|
+
xlim=xlim,
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
fig.suptitle(title, fontsize=14, fontweight="bold")
|
|
624
|
+
plt.tight_layout()
|
|
625
|
+
return fig
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def plot_horizontal_ray_deviation(
|
|
629
|
+
ax: plt.Axes,
|
|
630
|
+
trajectories: dict[float, NDArray],
|
|
631
|
+
cmap: str = "turbo",
|
|
632
|
+
linewidth: float = 1.5,
|
|
633
|
+
xlim: tuple[float, float] | None = None,
|
|
634
|
+
use_colorbar: bool = False,
|
|
635
|
+
) -> None:
|
|
636
|
+
"""
|
|
637
|
+
Plot angular deviation from initial direction for rays with varying impact parameters.
|
|
638
|
+
|
|
639
|
+
For rays that start at different altitudes (impact parameters), this shows how
|
|
640
|
+
each ray bends relative to its initial direction as it propagates through the
|
|
641
|
+
atmosphere. Atmospheric refraction typically bends rays downward.
|
|
642
|
+
|
|
643
|
+
Parameters
|
|
644
|
+
----------
|
|
645
|
+
ax : matplotlib.axes.Axes
|
|
646
|
+
Axes to plot on.
|
|
647
|
+
trajectories : dict
|
|
648
|
+
Dictionary mapping impact parameter (meters) to trajectory array.
|
|
649
|
+
Each trajectory is shape (N, 2) with columns [x, z] in meters.
|
|
650
|
+
cmap : str
|
|
651
|
+
Colormap for ray colors.
|
|
652
|
+
linewidth : float
|
|
653
|
+
Line width for curves.
|
|
654
|
+
xlim : tuple, optional
|
|
655
|
+
X-axis limits in km.
|
|
656
|
+
use_colorbar : bool
|
|
657
|
+
If True, skip legend (colorbar added externally).
|
|
658
|
+
|
|
659
|
+
Examples
|
|
660
|
+
--------
|
|
661
|
+
>>> fig, ax = plt.subplots()
|
|
662
|
+
>>> plot_horizontal_ray_deviation(ax, trajectories)
|
|
663
|
+
>>> plt.show()
|
|
664
|
+
"""
|
|
665
|
+
colors = plt.get_cmap(cmap)(np.linspace(0.1, 0.9, len(trajectories)))
|
|
666
|
+
|
|
667
|
+
for (impact_param_m, traj), color in zip(trajectories.items(), colors):
|
|
668
|
+
x_m = traj[:, 0]
|
|
669
|
+
|
|
670
|
+
# Compute direction angle at each point from finite differences
|
|
671
|
+
dx = np.diff(traj[:, 0])
|
|
672
|
+
dz = np.diff(traj[:, 1])
|
|
673
|
+
|
|
674
|
+
# Direction angle in degrees (angle above horizontal)
|
|
675
|
+
segment_angles = np.degrees(np.arctan2(dz, dx))
|
|
676
|
+
|
|
677
|
+
# Get initial direction from first segment
|
|
678
|
+
initial_angle = segment_angles[0]
|
|
679
|
+
|
|
680
|
+
# Build full array: deviation = current angle - initial angle
|
|
681
|
+
angular_deviation = np.zeros(len(traj))
|
|
682
|
+
angular_deviation[0] = 0.0 # No deviation at start
|
|
683
|
+
angular_deviation[1:] = segment_angles - initial_angle
|
|
684
|
+
|
|
685
|
+
# Convert impact parameter to km for legend
|
|
686
|
+
impact_km = impact_param_m / 1000
|
|
687
|
+
label = None if use_colorbar else f"{impact_km:.1f} km"
|
|
688
|
+
|
|
689
|
+
ax.plot(
|
|
690
|
+
x_m / 1000,
|
|
691
|
+
angular_deviation,
|
|
692
|
+
color=color,
|
|
693
|
+
linewidth=linewidth,
|
|
694
|
+
label=label,
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
ax.axhline(y=0, color="gray", linestyle="--", linewidth=1, alpha=0.5)
|
|
698
|
+
ax.set_xlabel("Horizontal distance (km)")
|
|
699
|
+
ax.set_ylabel("Angular deviation from initial direction (°)")
|
|
700
|
+
ax.set_title("Angular Deviation from Initial Ray Direction")
|
|
701
|
+
if not use_colorbar:
|
|
702
|
+
ax.legend(title="Impact param", loc="best", fontsize=8, ncol=2)
|
|
703
|
+
ax.grid(True, alpha=0.3)
|
|
704
|
+
|
|
705
|
+
if xlim:
|
|
706
|
+
ax.set_xlim(xlim)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def save_atmospheric_figure(
|
|
710
|
+
fig: plt.Figure,
|
|
711
|
+
filename: str | Path,
|
|
712
|
+
output_dir: Path | None = None,
|
|
713
|
+
dpi: int = 150,
|
|
714
|
+
) -> Path:
|
|
715
|
+
"""
|
|
716
|
+
Save an atmospheric refraction figure.
|
|
717
|
+
|
|
718
|
+
Parameters
|
|
719
|
+
----------
|
|
720
|
+
fig : matplotlib.figure.Figure
|
|
721
|
+
Figure to save.
|
|
722
|
+
filename : str or Path
|
|
723
|
+
Output filename.
|
|
724
|
+
output_dir : Path, optional
|
|
725
|
+
Output directory. Created if doesn't exist.
|
|
726
|
+
dpi : int
|
|
727
|
+
Resolution in dots per inch.
|
|
728
|
+
|
|
729
|
+
Returns
|
|
730
|
+
-------
|
|
731
|
+
output_path : Path
|
|
732
|
+
Full path to saved file.
|
|
733
|
+
"""
|
|
734
|
+
if output_dir is not None:
|
|
735
|
+
output_dir = Path(output_dir)
|
|
736
|
+
output_dir.mkdir(exist_ok=True)
|
|
737
|
+
output_path = output_dir / filename
|
|
738
|
+
else:
|
|
739
|
+
output_path = Path(filename)
|
|
740
|
+
|
|
741
|
+
fig.savefig(output_path, dpi=dpi)
|
|
742
|
+
return output_path
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
__all__ = [
|
|
746
|
+
"plot_ray_trajectories",
|
|
747
|
+
"plot_refractive_index_profile",
|
|
748
|
+
"plot_trajectory_offset",
|
|
749
|
+
"plot_horizontal_ray_deviation",
|
|
750
|
+
"create_atmospheric_refraction_figure",
|
|
751
|
+
"create_atmospheric_refraction_figure_with_offset",
|
|
752
|
+
"plot_trajectory_comparison",
|
|
753
|
+
"save_atmospheric_figure",
|
|
754
|
+
]
|