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,764 @@
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
+ Linsley Atmosphere Model
36
+
37
+ GPU-compatible implementation of Linsley's 5-layer atmospheric model with
38
+ wavelength-dependent refraction (dispersion) and optional extinction data.
39
+
40
+ Physics:
41
+ - Density: ρ(z) = (b[layer] / c[layer]) * exp(-z / c[layer])
42
+ - Refraction: n(z, λ) = 1 + 0.283e-3 * (ρ(z)/ρ₀) * (0.967 + 0.033 * (400/λ)^2.5)
43
+ - Extinction: From Elterman (1968) data tables + Rayleigh above 50km
44
+
45
+ References:
46
+ - J. Linsley, Proc. 15th ICRC, 12:89, 1977
47
+ - L. Elterman, Number Tech. Rep. AFCRL-68-0153, 1968
48
+ - K. Bernlohr, Astropart. Phys., 30:149-158, 2008
49
+ - Handbook of Chemistry and Physics, 67th Edition
50
+ """
51
+
52
+ from pathlib import Path
53
+
54
+ import numpy as np
55
+ from numpy.typing import NDArray
56
+ from scipy.interpolate import RegularGridInterpolator
57
+
58
+ from ..base.spectral_inhomogeneous import SpectralInhomogeneousModel
59
+
60
+ # =============================================================================
61
+ # Linsley (1981) US Standard Atmosphere Parameterization
62
+ # =============================================================================
63
+
64
+ # Layer boundaries in km: 0-4, 4-10, 10-40, 40-100, 100+
65
+ LAYER_BOUNDARIES_KM = np.array([4.0, 10.0, 40.0, 100.0])
66
+
67
+ # Linsley parameters for each layer (5 layers)
68
+ # b values in g/cm²
69
+ ATMOS_B = np.array([1222.6562, 1144.9069, 1305.5948, 540.1778, 1.0])
70
+ # c values in cm (scale heights)
71
+ ATMOS_C = np.array([994186.38, 878153.55, 636143.04, 772170.16, 1e9])
72
+
73
+ # Physical constants
74
+ N_AVOGADRO = 6.02214076e23 # mol⁻¹
75
+ MM_AIR = 28.966 # g/mol (mean molecular mass of air)
76
+ Z_MAX_KM = 112.8 # Maximum altitude in model (km)
77
+ ALT_MAX_KM = 100.0 # Maximum altitude for extinction data (km)
78
+
79
+ # Standard conditions
80
+ N_STP_MINUS_1 = 0.283e-3 # (n - 1) at STP
81
+
82
+ # Earth radius in meters
83
+ EARTH_RADIUS_M = 6_371_000.0
84
+
85
+ # King factor for Rayleigh scattering (depolarization correction)
86
+ # From: Thalman et al., J. Quant. Spectrosc. Radiat. Transf., 147:171-177, 2014
87
+ FK_KING = 1.0608
88
+
89
+
90
+ class LinsleyAtmosphere(SpectralInhomogeneousModel):
91
+ """
92
+ Linsley's 5-layer atmospheric model with wavelength dispersion.
93
+
94
+ This model provides:
95
+ - Atmospheric density profile from Linsley's parameterization
96
+ - Wavelength-dependent refractive index with dispersion
97
+ - Optional Elterman extinction coefficients for optical depth
98
+ - Rayleigh scattering coefficients
99
+
100
+ The refractive index formula combines:
101
+ - Baseline n(z) from density ratio: n = 1 + 0.283e-3 * (ρ/ρ₀)
102
+ - Wavelength correction: factor = 0.967 + 0.033 * (400/λ)^2.5
103
+
104
+ Parameters
105
+ ----------
106
+ name : str
107
+ Name of the atmosphere model
108
+ earth_radius : float
109
+ Earth radius in meters. Default: 6,371,000
110
+ earth_center : tuple
111
+ Center of Earth (x, y, z) in meters. Default: (0, 0, 0)
112
+ altitude_range : tuple
113
+ (min, max) altitude in meters for LUT. Default: (0, 120,000)
114
+ altitude_resolution : int
115
+ Number of altitude samples in LUT. Default: 1200
116
+ wavelength_range : tuple
117
+ (min, max) wavelength in meters. Default: (270e-9, 4000e-9)
118
+ wavelength_resolution : int
119
+ Number of wavelength samples in LUT. Default: 100
120
+ extinction_data_file : str or None
121
+ Path to extinction coefficient data file. If None, uses default.
122
+
123
+ Example
124
+ -------
125
+ >>> atm = LinsleyAtmosphere()
126
+ >>> n = atm.n_at_altitude(10000, 550e-9) # n at 10km, 550nm
127
+ >>> rho = atm.density_at_altitude(5000) # density at 5km
128
+ """
129
+
130
+ def __init__(
131
+ self,
132
+ name: str = "Linsley Atmosphere",
133
+ earth_radius: float = EARTH_RADIUS_M,
134
+ earth_center: tuple[float, float, float] = (0.0, 0.0, 0.0),
135
+ altitude_range: tuple[float, float] = (0.0, 120_000.0),
136
+ altitude_resolution: int = 1200,
137
+ wavelength_range: tuple[float, float] = (270e-9, 4000e-9),
138
+ wavelength_resolution: int = 100,
139
+ extinction_data_file: str | Path | None = None,
140
+ ):
141
+ super().__init__(
142
+ name=name,
143
+ center=earth_center,
144
+ reference_radius=earth_radius,
145
+ altitude_range=altitude_range,
146
+ altitude_resolution=altitude_resolution,
147
+ wavelength_range=wavelength_range,
148
+ wavelength_resolution=wavelength_resolution,
149
+ )
150
+
151
+ # Precompute sea-level density for normalization
152
+ self._rho_sea_level = self._compute_density_km(0.0)
153
+
154
+ # Load extinction data
155
+ self._ext_wavelengths: NDArray[np.float64] | None = None
156
+ self._ext_altitudes: NDArray[np.float64] | None = None
157
+ self._ext_coefficients: NDArray[np.float64] | None = None
158
+ self._ext_interpolator: RegularGridInterpolator | None = None
159
+
160
+ if extinction_data_file is not None:
161
+ self._load_extinction_data(Path(extinction_data_file))
162
+ else:
163
+ # Try to load default data file
164
+ default_path = (
165
+ Path(__file__).parent
166
+ / "data"
167
+ / "alpha_values_typical_atmosphere_updated.txt"
168
+ )
169
+ if default_path.exists():
170
+ self._load_extinction_data(default_path)
171
+
172
+ # =========================================================================
173
+ # CORE PHYSICS: Density Profile
174
+ # =========================================================================
175
+
176
+ def _get_layer_index(
177
+ self, z_km: float | NDArray[np.float64]
178
+ ) -> int | NDArray[np.int64]:
179
+ """Get atmospheric layer index for altitude(s) in km."""
180
+ z_km = np.asarray(z_km)
181
+ scalar_input = z_km.ndim == 0
182
+ z_km = np.atleast_1d(z_km)
183
+
184
+ # Find layer: 0 for 0-4km, 1 for 4-10km, etc.
185
+ layer = np.searchsorted(LAYER_BOUNDARIES_KM, z_km, side="right")
186
+ layer = np.clip(layer, 0, 4)
187
+
188
+ return int(layer[0]) if scalar_input else layer
189
+
190
+ def _compute_density_km(
191
+ self, z_km: float | NDArray[np.float64]
192
+ ) -> float | NDArray[np.float64]:
193
+ """
194
+ Compute atmospheric density in g/cm³ for altitude in km.
195
+
196
+ Uses Linsley's 5-layer parameterization.
197
+ """
198
+ z_km = np.asarray(z_km)
199
+ scalar_input = z_km.ndim == 0
200
+ z_km = np.atleast_1d(z_km)
201
+
202
+ layer = self._get_layer_index(z_km)
203
+
204
+ # Vectorized computation
205
+ b = ATMOS_B[layer]
206
+ c = ATMOS_C[layer]
207
+
208
+ # Convert altitude to cm for c values (which are in cm)
209
+ z_cm = z_km * 1e5
210
+
211
+ density = np.where(
212
+ layer < 4,
213
+ (b / c) * np.exp(-z_cm / c),
214
+ 1.0 / c, # Layer 4 is constant (very thin)
215
+ )
216
+
217
+ # Above maximum altitude, density is 0
218
+ density = np.where(z_km > Z_MAX_KM, 0.0, density)
219
+
220
+ return float(density[0]) if scalar_input else density
221
+
222
+ def density_at_altitude(self, altitude: float) -> float:
223
+ """
224
+ Get atmospheric density at altitude.
225
+
226
+ Parameters
227
+ ----------
228
+ altitude : float
229
+ Altitude in meters
230
+
231
+ Returns
232
+ -------
233
+ float
234
+ Density in g/cm³
235
+ """
236
+ return float(self._compute_density_km(altitude / 1000.0))
237
+
238
+ def density_ratio(self, altitude: float) -> float:
239
+ """
240
+ Get density ratio ρ(z)/ρ(0) at altitude.
241
+
242
+ Parameters
243
+ ----------
244
+ altitude : float
245
+ Altitude in meters
246
+
247
+ Returns
248
+ -------
249
+ float
250
+ Density ratio (dimensionless)
251
+ """
252
+ return self.density_at_altitude(altitude) / self._rho_sea_level
253
+
254
+ def number_density(self, altitude: float) -> float:
255
+ """
256
+ Get particle number density at altitude.
257
+
258
+ Parameters
259
+ ----------
260
+ altitude : float
261
+ Altitude in meters
262
+
263
+ Returns
264
+ -------
265
+ float
266
+ Number density in particles/cm³
267
+ """
268
+ rho = self.density_at_altitude(altitude)
269
+ return rho * (N_AVOGADRO / MM_AIR)
270
+
271
+ # =========================================================================
272
+ # CORE PHYSICS: Refractive Index
273
+ # =========================================================================
274
+
275
+ def n_at_altitude(self, altitude: float, wavelength: float) -> float:
276
+ """
277
+ Compute refractive index at altitude and wavelength.
278
+
279
+ Uses baseline approximation from Handbook of Chemistry and Physics
280
+ with wavelength correction from Bernlohr (2008).
281
+
282
+ Parameters
283
+ ----------
284
+ altitude : float
285
+ Altitude in meters
286
+ wavelength : float
287
+ Wavelength in meters
288
+
289
+ Returns
290
+ -------
291
+ float
292
+ Refractive index n
293
+ """
294
+ # Density ratio
295
+ rho_ratio = self.density_ratio(altitude)
296
+
297
+ # Baseline n at this density
298
+ n_minus_1_base = N_STP_MINUS_1 * rho_ratio
299
+
300
+ # Wavelength correction factor
301
+ # Convert wavelength to nm for the formula
302
+ lambda_nm = wavelength * 1e9
303
+ correction = 0.967 + 0.033 * (400.0 / lambda_nm) ** 2.5
304
+
305
+ return 1.0 + n_minus_1_base * correction
306
+
307
+ def dn_dh_at_altitude(self, altitude: float, wavelength: float) -> float:
308
+ """
309
+ Compute analytical derivative dn/dh at altitude and wavelength.
310
+
311
+ Parameters
312
+ ----------
313
+ altitude : float
314
+ Altitude in meters
315
+ wavelength : float
316
+ Wavelength in meters
317
+
318
+ Returns
319
+ -------
320
+ float
321
+ Derivative dn/dh in m⁻¹
322
+ """
323
+ # Get layer parameters
324
+ z_km = altitude / 1000.0
325
+
326
+ # Handle altitude above model
327
+ if z_km > Z_MAX_KM:
328
+ return 0.0
329
+
330
+ layer = self._get_layer_index(z_km)
331
+
332
+ # For layer 4 (constant density), derivative is 0
333
+ if layer >= 4:
334
+ return 0.0
335
+
336
+ c_cm = ATMOS_C[layer]
337
+ c_m = c_cm / 100.0 # Convert to meters
338
+
339
+ # d(n-1)/dh = -(n-1) / c for exponential atmosphere
340
+ n = self.n_at_altitude(altitude, wavelength)
341
+ n_minus_1 = n - 1.0
342
+
343
+ # Derivative: dn/dh = d(n-1)/dh = -(n-1) / c
344
+ return -n_minus_1 / c_m
345
+
346
+ def alpha_at_altitude(self, altitude: float, wavelength: float) -> float:
347
+ """
348
+ Return absorption/extinction coefficient at altitude and wavelength.
349
+
350
+ Uses Elterman extinction data if available, otherwise Rayleigh scattering.
351
+ This enables Beer-Lambert absorption during ray propagation.
352
+
353
+ Parameters
354
+ ----------
355
+ altitude : float
356
+ Altitude in meters
357
+ wavelength : float
358
+ Wavelength in meters
359
+
360
+ Returns
361
+ -------
362
+ float
363
+ Extinction coefficient α in m⁻¹
364
+ """
365
+ return self.get_extinction_coefficient(altitude, wavelength)
366
+
367
+ # =========================================================================
368
+ # EXTINCTION AND SCATTERING
369
+ # =========================================================================
370
+
371
+ def _load_extinction_data(self, file_path: Path) -> None:
372
+ """
373
+ Load Elterman (1968) extinction coefficients.
374
+
375
+ File format:
376
+ - Row 1: Wavelengths in nm
377
+ - Row 2: Altitudes in km
378
+ - Rows 3+: Extinction coefficients in km⁻¹ for each wavelength
379
+ """
380
+ # Load wavelengths (nm)
381
+ wavelengths = np.loadtxt(file_path, delimiter=",", max_rows=1)
382
+
383
+ # Load altitudes (km)
384
+ altitudes = np.loadtxt(file_path, delimiter=",", skiprows=1, max_rows=1)
385
+
386
+ # Load extinction coefficients (km⁻¹)
387
+ coefficients = np.loadtxt(file_path, delimiter=",", skiprows=2)
388
+
389
+ # Remove any 0 values (replace with small number)
390
+ coefficients[coefficients == 0.0] = 1e-10
391
+
392
+ # Convert to m⁻¹ and take log for smooth interpolation
393
+ # Store log(alpha) to enable interpolation in log space
394
+ self._ext_coefficients_log = np.log(coefficients / 1000.0) # m⁻¹
395
+
396
+ # Store raw data
397
+ self._ext_wavelengths = wavelengths # nm
398
+ self._ext_altitudes = altitudes # km
399
+ self._ext_coefficients = coefficients / 1000.0 # m⁻¹
400
+
401
+ # Create interpolator (wavelength, altitude) -> log(extinction)
402
+ # Note: coefficients shape is (n_wavelengths, n_altitudes)
403
+ self._ext_interpolator = RegularGridInterpolator(
404
+ (wavelengths, altitudes),
405
+ self._ext_coefficients_log,
406
+ method="linear",
407
+ bounds_error=False,
408
+ fill_value=-20.0, # exp(-20) ≈ 0
409
+ )
410
+
411
+ def get_extinction_coefficient(
412
+ self, altitude: float, wavelength: float, use_rayleigh_above_50km: bool = True
413
+ ) -> float:
414
+ """
415
+ Get extinction coefficient from Elterman data.
416
+
417
+ Parameters
418
+ ----------
419
+ altitude : float
420
+ Altitude in meters
421
+ wavelength : float
422
+ Wavelength in meters
423
+ use_rayleigh_above_50km : bool
424
+ If True, use Rayleigh scattering above 50km instead of data
425
+
426
+ Returns
427
+ -------
428
+ float
429
+ Extinction coefficient in m⁻¹
430
+ """
431
+ z_km = altitude / 1000.0
432
+ lambda_nm = wavelength * 1e9
433
+
434
+ # Above max altitude, no extinction
435
+ if z_km > ALT_MAX_KM:
436
+ return 0.0
437
+
438
+ # Above 50km, optionally use Rayleigh only
439
+ if use_rayleigh_above_50km and z_km > 50.0:
440
+ return self.rayleigh_extinction(altitude, wavelength)
441
+
442
+ # Interpolate from data
443
+ if self._ext_interpolator is not None:
444
+ log_alpha = float(self._ext_interpolator((lambda_nm, z_km)))
445
+ return np.exp(log_alpha)
446
+
447
+ # Fallback to Rayleigh if no data
448
+ return self.rayleigh_extinction(altitude, wavelength)
449
+
450
+ def rayleigh_extinction(self, altitude: float, wavelength: float) -> float:
451
+ """
452
+ Compute Rayleigh scattering extinction coefficient.
453
+
454
+ From Lord Rayleigh with King factor correction.
455
+
456
+ Parameters
457
+ ----------
458
+ altitude : float
459
+ Altitude in meters
460
+ wavelength : float
461
+ Wavelength in meters
462
+
463
+ Returns
464
+ -------
465
+ float
466
+ Rayleigh extinction coefficient in m⁻¹
467
+ """
468
+ # Get refractive index and number density
469
+ n = self.n_at_altitude(altitude, wavelength)
470
+ n_squared = n * n
471
+ N = self.number_density(altitude) * 1e6 # Convert cm⁻³ to m⁻³
472
+
473
+ # Avoid division by zero
474
+ if N < 1e-10:
475
+ return 0.0
476
+
477
+ # Rayleigh formula with King factor
478
+ alpha_rayleigh = (
479
+ 24.0
480
+ * np.pi**3
481
+ * ((n_squared - 1) / (n_squared + 2)) ** 2
482
+ * FK_KING
483
+ / (wavelength**4 * N)
484
+ )
485
+
486
+ return float(alpha_rayleigh)
487
+
488
+ def get_scattering_coefficient(
489
+ self,
490
+ x: float | NDArray[np.float64],
491
+ y: float | NDArray[np.float64],
492
+ z: float | NDArray[np.float64],
493
+ wavelength: float | NDArray[np.float64],
494
+ ) -> float | NDArray[np.float64]:
495
+ """
496
+ Get scattering coefficient at position.
497
+
498
+ Uses extinction data below 50km, Rayleigh above.
499
+ """
500
+ altitude = self._compute_altitude(x, y, z)
501
+
502
+ if isinstance(altitude, np.ndarray):
503
+ wavelength = np.atleast_1d(wavelength)
504
+ if altitude.shape != wavelength.shape:
505
+ altitude, wavelength = np.broadcast_arrays(altitude, wavelength)
506
+ return np.array(
507
+ [
508
+ self.get_extinction_coefficient(float(h), float(wl))
509
+ for h, wl in zip(altitude.flat, wavelength.flat)
510
+ ]
511
+ ).reshape(altitude.shape)
512
+
513
+ return self.get_extinction_coefficient(float(altitude), float(wavelength))
514
+
515
+ # =========================================================================
516
+ # OPTICAL DEPTH CALCULATION
517
+ # =========================================================================
518
+
519
+ def optical_depth_vertical(
520
+ self,
521
+ z1: float,
522
+ z2: float,
523
+ wavelength: float,
524
+ num_points: int = 100,
525
+ use_rayleigh_above_50km: bool = True,
526
+ ) -> float:
527
+ """
528
+ Compute optical depth for vertical path between two altitudes.
529
+
530
+ Parameters
531
+ ----------
532
+ z1 : float
533
+ Starting altitude in meters (lower)
534
+ z2 : float
535
+ Ending altitude in meters (upper)
536
+ wavelength : float
537
+ Wavelength in meters
538
+ num_points : int
539
+ Number of integration points
540
+ use_rayleigh_above_50km : bool
541
+ Use Rayleigh scattering above 50km
542
+
543
+ Returns
544
+ -------
545
+ float
546
+ Optical depth (dimensionless)
547
+ """
548
+ # Integration points
549
+ altitudes = np.linspace(z1, z2, num_points)
550
+
551
+ # Get extinction at each point
552
+ extinctions = np.array(
553
+ [
554
+ self.get_extinction_coefficient(h, wavelength, use_rayleigh_above_50km)
555
+ for h in altitudes
556
+ ]
557
+ )
558
+
559
+ # Trapezoidal integration
560
+ return float(np.trapezoid(extinctions, altitudes))
561
+
562
+ def optical_depth_slant(
563
+ self,
564
+ positions: NDArray[np.float64],
565
+ wavelength: float,
566
+ use_rayleigh_above_50km: bool = True,
567
+ ) -> NDArray[np.float64]:
568
+ """
569
+ Compute cumulative optical depth along a slant path.
570
+
571
+ Parameters
572
+ ----------
573
+ positions : ndarray of shape (N, 3)
574
+ Positions along the path in meters
575
+ wavelength : float
576
+ Wavelength in meters
577
+ use_rayleigh_above_50km : bool
578
+ Use Rayleigh scattering above 50km
579
+
580
+ Returns
581
+ -------
582
+ ndarray of shape (N,)
583
+ Cumulative optical depth at each position
584
+ """
585
+ # Compute altitudes
586
+ altitudes = self._compute_altitude(
587
+ positions[:, 0], positions[:, 1], positions[:, 2]
588
+ )
589
+
590
+ # Get extinctions
591
+ extinctions = np.array(
592
+ [
593
+ self.get_extinction_coefficient(
594
+ float(h), wavelength, use_rayleigh_above_50km
595
+ )
596
+ for h in altitudes
597
+ ]
598
+ )
599
+
600
+ # Compute path lengths between points
601
+ diffs = np.diff(positions, axis=0)
602
+ path_lengths = np.sqrt(np.sum(diffs**2, axis=1))
603
+
604
+ # Cumulative integration using midpoint rule
605
+ mid_extinctions = (extinctions[:-1] + extinctions[1:]) / 2
606
+ optical_depths = np.zeros(len(positions))
607
+ optical_depths[1:] = np.cumsum(mid_extinctions * path_lengths)
608
+
609
+ return optical_depths
610
+
611
+ # =========================================================================
612
+ # ADDITIONAL ATMOSPHERIC PROPERTIES
613
+ # =========================================================================
614
+
615
+ def moliere_radius(self, altitude: float) -> float:
616
+ """
617
+ Compute Molière radius at altitude.
618
+
619
+ The Molière radius characterizes multiple Coulomb scattering.
620
+
621
+ Parameters
622
+ ----------
623
+ altitude : float
624
+ Altitude in meters
625
+
626
+ Returns
627
+ -------
628
+ float
629
+ Molière radius in meters
630
+ """
631
+ # Molière radius: r_M = X_0 / (rho * 9.6 MeV/c)
632
+ # X_0 for air ≈ 36.62 g/cm²
633
+ X_0_AIR = 36.62 # g/cm²
634
+ rho = self.density_at_altitude(altitude) # g/cm³
635
+
636
+ if rho < 1e-20:
637
+ return float("inf")
638
+
639
+ # r_M in cm, convert to m
640
+ r_M_cm = X_0_AIR / (rho * 9.6)
641
+ return r_M_cm / 100.0
642
+
643
+ def scale_height(self, altitude: float) -> float:
644
+ """
645
+ Get atmospheric scale height at altitude.
646
+
647
+ Parameters
648
+ ----------
649
+ altitude : float
650
+ Altitude in meters
651
+
652
+ Returns
653
+ -------
654
+ float
655
+ Scale height in meters
656
+ """
657
+ z_km = altitude / 1000.0
658
+
659
+ if z_km > Z_MAX_KM:
660
+ return float("inf")
661
+
662
+ layer = self._get_layer_index(z_km)
663
+
664
+ if layer >= 4:
665
+ return float("inf")
666
+
667
+ # c is scale height in cm, convert to m
668
+ return ATMOS_C[layer] / 100.0
669
+
670
+ def pressure_at_altitude(self, altitude: float) -> float:
671
+ """
672
+ Estimate atmospheric pressure at altitude.
673
+
674
+ Uses ideal gas approximation: P ∝ ρ * T, with isothermal assumption.
675
+
676
+ Parameters
677
+ ----------
678
+ altitude : float
679
+ Altitude in meters
680
+
681
+ Returns
682
+ -------
683
+ float
684
+ Pressure in Pa (approximate)
685
+ """
686
+ # Sea level pressure
687
+ P_0 = 101325.0 # Pa
688
+
689
+ # Pressure ratio ≈ density ratio for isothermal atmosphere
690
+ return P_0 * self.density_ratio(altitude)
691
+
692
+ def temperature_at_altitude(self, altitude: float) -> float:
693
+ """
694
+ Estimate temperature at altitude using standard lapse rate.
695
+
696
+ Parameters
697
+ ----------
698
+ altitude : float
699
+ Altitude in meters
700
+
701
+ Returns
702
+ -------
703
+ float
704
+ Temperature in Kelvin (approximate)
705
+ """
706
+ z_km = altitude / 1000.0
707
+
708
+ # Standard atmosphere temperature profile (simplified)
709
+ if z_km < 11.0:
710
+ # Troposphere: -6.5 K/km lapse rate
711
+ return 288.15 - 6.5 * z_km
712
+ elif z_km < 20.0:
713
+ # Tropopause: isothermal
714
+ return 216.65
715
+ elif z_km < 47.0:
716
+ # Stratosphere: +1-3 K/km
717
+ return 216.65 + 1.0 * (z_km - 20.0)
718
+ else:
719
+ # Upper atmosphere: complex, use constant
720
+ return 270.0
721
+
722
+
723
+ # =============================================================================
724
+ # Factory Functions
725
+ # =============================================================================
726
+
727
+
728
+ def create_linsley_atmosphere(
729
+ earth_center: tuple[float, float, float] = (0.0, 0.0, 0.0),
730
+ earth_radius: float = EARTH_RADIUS_M,
731
+ with_extinction: bool = True,
732
+ ) -> LinsleyAtmosphere:
733
+ """
734
+ Create a LinsleyAtmosphere with standard parameters.
735
+
736
+ Parameters
737
+ ----------
738
+ earth_center : tuple
739
+ Center of Earth in meters
740
+ earth_radius : float
741
+ Earth radius in meters
742
+ with_extinction : bool
743
+ Whether to load extinction data
744
+
745
+ Returns
746
+ -------
747
+ LinsleyAtmosphere
748
+ Configured atmosphere model
749
+ """
750
+ extinction_file = None
751
+ if with_extinction:
752
+ default_path = (
753
+ Path(__file__).parent
754
+ / "data"
755
+ / "alpha_values_typical_atmosphere_updated.txt"
756
+ )
757
+ if default_path.exists():
758
+ extinction_file = default_path
759
+
760
+ return LinsleyAtmosphere(
761
+ earth_center=earth_center,
762
+ earth_radius=earth_radius,
763
+ extinction_data_file=extinction_file,
764
+ )