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,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
+ ]