nrl-tracker 0.22.5__py3-none-any.whl → 1.8.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 (86) hide show
  1. {nrl_tracker-0.22.5.dist-info → nrl_tracker-1.8.0.dist-info}/METADATA +57 -10
  2. {nrl_tracker-0.22.5.dist-info → nrl_tracker-1.8.0.dist-info}/RECORD +86 -69
  3. pytcl/__init__.py +4 -3
  4. pytcl/assignment_algorithms/__init__.py +28 -0
  5. pytcl/assignment_algorithms/dijkstra_min_cost.py +184 -0
  6. pytcl/assignment_algorithms/gating.py +10 -10
  7. pytcl/assignment_algorithms/jpda.py +40 -40
  8. pytcl/assignment_algorithms/nd_assignment.py +379 -0
  9. pytcl/assignment_algorithms/network_flow.py +464 -0
  10. pytcl/assignment_algorithms/network_simplex.py +167 -0
  11. pytcl/assignment_algorithms/three_dimensional/assignment.py +3 -3
  12. pytcl/astronomical/__init__.py +104 -3
  13. pytcl/astronomical/ephemerides.py +14 -11
  14. pytcl/astronomical/reference_frames.py +865 -56
  15. pytcl/astronomical/relativity.py +6 -5
  16. pytcl/astronomical/sgp4.py +710 -0
  17. pytcl/astronomical/special_orbits.py +532 -0
  18. pytcl/astronomical/tle.py +558 -0
  19. pytcl/atmosphere/__init__.py +43 -1
  20. pytcl/atmosphere/ionosphere.py +512 -0
  21. pytcl/atmosphere/nrlmsise00.py +809 -0
  22. pytcl/clustering/dbscan.py +2 -2
  23. pytcl/clustering/gaussian_mixture.py +3 -3
  24. pytcl/clustering/hierarchical.py +15 -15
  25. pytcl/clustering/kmeans.py +4 -4
  26. pytcl/containers/__init__.py +24 -0
  27. pytcl/containers/base.py +219 -0
  28. pytcl/containers/cluster_set.py +12 -2
  29. pytcl/containers/covertree.py +26 -29
  30. pytcl/containers/kd_tree.py +94 -29
  31. pytcl/containers/rtree.py +200 -1
  32. pytcl/containers/vptree.py +21 -28
  33. pytcl/coordinate_systems/conversions/geodetic.py +272 -5
  34. pytcl/coordinate_systems/jacobians/jacobians.py +2 -2
  35. pytcl/coordinate_systems/projections/__init__.py +1 -1
  36. pytcl/coordinate_systems/projections/projections.py +2 -2
  37. pytcl/coordinate_systems/rotations/rotations.py +10 -6
  38. pytcl/core/__init__.py +18 -0
  39. pytcl/core/validation.py +333 -2
  40. pytcl/dynamic_estimation/__init__.py +26 -0
  41. pytcl/dynamic_estimation/gaussian_sum_filter.py +434 -0
  42. pytcl/dynamic_estimation/imm.py +14 -14
  43. pytcl/dynamic_estimation/kalman/__init__.py +30 -0
  44. pytcl/dynamic_estimation/kalman/constrained.py +382 -0
  45. pytcl/dynamic_estimation/kalman/extended.py +8 -8
  46. pytcl/dynamic_estimation/kalman/h_infinity.py +613 -0
  47. pytcl/dynamic_estimation/kalman/square_root.py +60 -573
  48. pytcl/dynamic_estimation/kalman/sr_ukf.py +302 -0
  49. pytcl/dynamic_estimation/kalman/ud_filter.py +410 -0
  50. pytcl/dynamic_estimation/kalman/unscented.py +8 -6
  51. pytcl/dynamic_estimation/particle_filters/bootstrap.py +15 -15
  52. pytcl/dynamic_estimation/rbpf.py +589 -0
  53. pytcl/gravity/egm.py +13 -0
  54. pytcl/gravity/spherical_harmonics.py +98 -37
  55. pytcl/gravity/tides.py +6 -6
  56. pytcl/logging_config.py +328 -0
  57. pytcl/magnetism/__init__.py +7 -0
  58. pytcl/magnetism/emm.py +10 -3
  59. pytcl/magnetism/wmm.py +260 -23
  60. pytcl/mathematical_functions/combinatorics/combinatorics.py +5 -5
  61. pytcl/mathematical_functions/geometry/geometry.py +5 -5
  62. pytcl/mathematical_functions/numerical_integration/quadrature.py +6 -6
  63. pytcl/mathematical_functions/signal_processing/detection.py +24 -24
  64. pytcl/mathematical_functions/signal_processing/filters.py +14 -14
  65. pytcl/mathematical_functions/signal_processing/matched_filter.py +12 -12
  66. pytcl/mathematical_functions/special_functions/bessel.py +15 -3
  67. pytcl/mathematical_functions/special_functions/debye.py +136 -26
  68. pytcl/mathematical_functions/special_functions/error_functions.py +3 -1
  69. pytcl/mathematical_functions/special_functions/gamma_functions.py +4 -4
  70. pytcl/mathematical_functions/special_functions/hypergeometric.py +81 -15
  71. pytcl/mathematical_functions/transforms/fourier.py +8 -8
  72. pytcl/mathematical_functions/transforms/stft.py +12 -12
  73. pytcl/mathematical_functions/transforms/wavelets.py +9 -9
  74. pytcl/navigation/geodesy.py +246 -160
  75. pytcl/navigation/great_circle.py +101 -19
  76. pytcl/plotting/coordinates.py +7 -7
  77. pytcl/plotting/tracks.py +2 -2
  78. pytcl/static_estimation/maximum_likelihood.py +16 -14
  79. pytcl/static_estimation/robust.py +5 -5
  80. pytcl/terrain/loaders.py +5 -5
  81. pytcl/trackers/hypothesis.py +1 -1
  82. pytcl/trackers/mht.py +9 -9
  83. pytcl/trackers/multi_target.py +1 -1
  84. {nrl_tracker-0.22.5.dist-info → nrl_tracker-1.8.0.dist-info}/LICENSE +0 -0
  85. {nrl_tracker-0.22.5.dist-info → nrl_tracker-1.8.0.dist-info}/WHEEL +0 -0
  86. {nrl_tracker-0.22.5.dist-info → nrl_tracker-1.8.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,809 @@
1
+ """
2
+ NRLMSISE-00 Atmospheric Model
3
+
4
+ High-fidelity thermosphere/atmosphere model from the U.S. Naval Research
5
+ Laboratory. Provides density, temperature, and composition profiles for
6
+ altitudes from -5 km to 1000 km.
7
+
8
+ This implementation uses an empirical approach based on atmospheric chemistry,
9
+ radiative transfer, and geomagnetic coupling for modeling temperature and
10
+ density variations with altitude, latitude, local time, and solar/magnetic activity.
11
+
12
+ References
13
+ ----------
14
+ .. [1] Picone, J. M., A. E. Hedin, D. P. Drob, and A. C. Aikin (2002),
15
+ "NRLMSISE-00 empirical model of the atmosphere: Statistical
16
+ comparisons and scientific issues," J. Geophys. Res., 107(A12), 1468,
17
+ doi:10.1029/2002JA009430
18
+ .. [2] NASA GSFC NRLMSISE-00 Model:
19
+ https://ccmc.gsfc.nasa.gov/models/nrlmsise00
20
+ .. [3] Drob, D. P., et al. (2008), "An update to the COSPAR International
21
+ Reference Atmosphere model for the middle atmosphere," Adv. Space Res.,
22
+ 43(12), 1747–1764
23
+ """
24
+
25
+ from typing import NamedTuple
26
+
27
+ import numpy as np
28
+ from numpy.typing import ArrayLike, NDArray
29
+
30
+ # Molecular weights (g/mol)
31
+ _MW = {
32
+ "N2": 28.014,
33
+ "O2": 31.999,
34
+ "O": 15.999,
35
+ "He": 4.003,
36
+ "H": 1.008,
37
+ "Ar": 39.948,
38
+ "N": 14.007,
39
+ }
40
+
41
+ # Gas constant (J/(mol·K))
42
+ _R_GAS = 8.31447
43
+
44
+
45
+ class NRLMSISE00Output(NamedTuple):
46
+ """
47
+ Output from NRLMSISE-00 atmospheric model.
48
+
49
+ Attributes
50
+ ----------
51
+ density : float or ndarray
52
+ Total atmospheric density in kg/m³.
53
+ temperature : float or ndarray
54
+ Temperature at altitude (K).
55
+ exosphere_temperature : float or ndarray
56
+ Exospheric temperature (K).
57
+ he_density : float or ndarray
58
+ Helium density in m⁻³.
59
+ o_density : float or ndarray
60
+ Atomic oxygen density in m⁻³.
61
+ n2_density : float or ndarray
62
+ N₂ density in m⁻³.
63
+ o2_density : float or ndarray
64
+ O₂ density in m⁻³.
65
+ ar_density : float or ndarray
66
+ Argon density in m⁻³.
67
+ h_density : float or ndarray
68
+ Hydrogen density in m⁻³.
69
+ n_density : float or ndarray
70
+ Atomic nitrogen density in m⁻³.
71
+ """
72
+
73
+ density: float | NDArray[np.float64]
74
+ temperature: float | NDArray[np.float64]
75
+ exosphere_temperature: float | NDArray[np.float64]
76
+ he_density: float | NDArray[np.float64]
77
+ o_density: float | NDArray[np.float64]
78
+ n2_density: float | NDArray[np.float64]
79
+ o2_density: float | NDArray[np.float64]
80
+ ar_density: float | NDArray[np.float64]
81
+ h_density: float | NDArray[np.float64]
82
+ n_density: float | NDArray[np.float64]
83
+
84
+
85
+ class F107Index(NamedTuple):
86
+ """
87
+ Solar activity indices for NRLMSISE-00.
88
+
89
+ Attributes
90
+ ----------
91
+ f107 : float
92
+ 10.7 cm solar radio flux (daily, SFU).
93
+ f107a : float
94
+ 10.7 cm solar radio flux (81-day average, SFU).
95
+ ap : float or ndarray
96
+ Planetary magnetic index (Ap index).
97
+ ap_array : ndarray, optional
98
+ Ap values for each 3-hour interval of the day (8 values).
99
+ If not provided, derived from ap value.
100
+ """
101
+
102
+ f107: float
103
+ f107a: float
104
+ ap: float | NDArray[np.float64]
105
+ ap_array: NDArray[np.float64] | None = None
106
+
107
+
108
+ # NRLMSISE-00 Coefficients (simplified structure)
109
+ # Note: Full model requires extensive coefficient tables from NOAA
110
+ # These are placeholder structures that would be populated from data files
111
+
112
+
113
+ class NRLMSISE00:
114
+ """
115
+ NRLMSISE-00 High-Fidelity Atmosphere Model.
116
+
117
+ This is a comprehensive thermosphere model covering altitudes from
118
+ approximately -5 km to 1000 km, with detailed chemical composition
119
+ and temperature profiles.
120
+
121
+ The model implements:
122
+ - Temperature profile with solar activity and magnetic coupling
123
+ - Molecular composition for troposphere/stratosphere/mesosphere
124
+ - Atomic species for thermosphere
125
+ - Solar flux (F10.7) and magnetic activity (Ap) variations
126
+
127
+ Parameters
128
+ ----------
129
+ use_meter_altitude : bool, optional
130
+ If True, expect altitude input in meters. If False, expect km.
131
+ Default is True (meters).
132
+
133
+ Examples
134
+ --------
135
+ >>> model = NRLMSISE00()
136
+ >>> output = model(
137
+ ... latitude=np.radians(45),
138
+ ... longitude=np.radians(-75),
139
+ ... altitude=400_000, # 400 km
140
+ ... year=2024,
141
+ ... day_of_year=100,
142
+ ... seconds_in_day=43200,
143
+ ... f107=150,
144
+ ... f107a=150,
145
+ ... ap=5
146
+ ... )
147
+ >>> print(f"Density: {output.density:.2e} kg/m³")
148
+
149
+ Notes
150
+ -----
151
+ This implementation uses empirical correlations for atmospheric
152
+ properties as a function of geomagnetic and solar activity indices.
153
+ For highest accuracy, use the original NRLMSISE-00 Fortran code
154
+ from NASA/NOAA, which includes extensive coefficient tables.
155
+ """
156
+
157
+ def __init__(self, use_meter_altitude: bool = True):
158
+ """Initialize NRLMSISE-00 model."""
159
+ self.use_meter_altitude = use_meter_altitude
160
+
161
+ def __call__(
162
+ self,
163
+ latitude: ArrayLike,
164
+ longitude: ArrayLike,
165
+ altitude: ArrayLike,
166
+ year: int,
167
+ day_of_year: int,
168
+ seconds_in_day: float,
169
+ f107: float = 150.0,
170
+ f107a: float = 150.0,
171
+ ap: float | ArrayLike = 4.0,
172
+ ) -> NRLMSISE00Output:
173
+ """
174
+ Compute atmospheric density and composition.
175
+
176
+ Parameters
177
+ ----------
178
+ latitude : array_like
179
+ Geodetic latitude in radians.
180
+ longitude : array_like
181
+ Longitude in radians.
182
+ altitude : array_like
183
+ Altitude in meters (or km if use_meter_altitude=False).
184
+ year : int
185
+ Year (e.g., 2024).
186
+ day_of_year : int
187
+ Day of year (1-366).
188
+ seconds_in_day : float
189
+ Seconds since midnight (0-86400).
190
+ f107 : float, optional
191
+ 10.7 cm solar flux (daily value, SFU). Default 150.
192
+ f107a : float, optional
193
+ 10.7 cm solar flux (81-day average, SFU). Default 150.
194
+ ap : float or array_like, optional
195
+ Planetary magnetic index. Can be single value or 8-element
196
+ array of 3-hour Ap values. Default 4.0.
197
+
198
+ Returns
199
+ -------
200
+ output : NRLMSISE00Output
201
+ Atmospheric properties (density, temperature, composition).
202
+
203
+ Notes
204
+ -----
205
+ The model assumes hydrostatic equilibrium and uses empirical
206
+ correlations for density and temperature variations.
207
+ """
208
+ # Convert arrays to numpy
209
+ lat = np.atleast_1d(np.asarray(latitude, dtype=np.float64))
210
+ lon = np.atleast_1d(np.asarray(longitude, dtype=np.float64))
211
+ alt = np.atleast_1d(np.asarray(altitude, dtype=np.float64))
212
+
213
+ # Convert altitude to km if needed
214
+ if self.use_meter_altitude:
215
+ alt_km = alt / 1000.0
216
+ else:
217
+ alt_km = alt
218
+
219
+ # Broadcast arrays to common shape
220
+ try:
221
+ lat, lon, alt_km = np.broadcast_arrays(lat, lon, alt_km)
222
+ except ValueError:
223
+ raise ValueError("latitude, longitude, and altitude must be broadcastable")
224
+
225
+ # Compute Ap index as float scalar if array provided
226
+ if isinstance(ap, (list, tuple, np.ndarray)):
227
+ ap_array = np.asarray(ap, dtype=np.float64)
228
+ if len(ap_array) == 8:
229
+ ap_val = np.mean(ap_array)
230
+ else:
231
+ ap_val = ap_array[0] if len(ap_array) > 0 else 4.0
232
+ else:
233
+ ap_val = float(ap)
234
+ ap_array = None
235
+
236
+ # Constrain solar/magnetic indices
237
+ f107 = np.clip(f107, 70.0, 300.0)
238
+ f107a = np.clip(f107a, 70.0, 300.0)
239
+ ap_val = np.clip(ap_val, 0.0, 400.0)
240
+
241
+ # Calculate exosphere temperature (Texo)
242
+ texo = self._exosphere_temperature(f107, f107a, ap_val)
243
+
244
+ # Calculate temperature profile
245
+ temperature = self._temperature_profile(
246
+ alt_km, lat, lon, day_of_year, seconds_in_day, texo
247
+ )
248
+
249
+ # Calculate species densities
250
+ n2_dens = self._n2_density(alt_km, lat, temperature, f107a, ap_val)
251
+ o2_dens = self._o2_density(alt_km, lat, temperature, f107a, ap_val)
252
+ o_dens = self._o_density(alt_km, lat, temperature, f107a, ap_val)
253
+ he_dens = self._he_density(alt_km, lat, temperature, f107a, ap_val)
254
+ h_dens = self._h_density(alt_km, lat, temperature, f107a, ap_val)
255
+ ar_dens = self._ar_density(alt_km, lat, temperature, f107a, ap_val)
256
+ n_dens = self._n_density(alt_km, lat, temperature, f107a, ap_val)
257
+
258
+ # Convert number densities (m^-3) to mass density (kg/m^3)
259
+ # ρ = Σ(ni × Mi / Nₐ) where Nₐ = 6.022e23
260
+ na = 6.02214076e23 # Avogadro's number
261
+ total_density = (
262
+ (
263
+ n2_dens * _MW["N2"]
264
+ + o2_dens * _MW["O2"]
265
+ + o_dens * _MW["O"]
266
+ + he_dens * _MW["He"]
267
+ + h_dens * _MW["H"]
268
+ + ar_dens * _MW["Ar"]
269
+ + n_dens * _MW["N"]
270
+ )
271
+ / na
272
+ / 1000.0
273
+ ) # Convert g to kg
274
+
275
+ # Return as scalar if inputs were scalar
276
+ scalar_input = np.asarray(altitude).ndim == 0
277
+
278
+ if scalar_input:
279
+ total_density = float(total_density.flat[0])
280
+ temperature = float(temperature.flat[0])
281
+ texo = float(texo) if not isinstance(texo, float) else texo
282
+ n2_dens = float(n2_dens.flat[0])
283
+ o2_dens = float(o2_dens.flat[0])
284
+ o_dens = float(o_dens.flat[0])
285
+ he_dens = float(he_dens.flat[0])
286
+ h_dens = float(h_dens.flat[0])
287
+ ar_dens = float(ar_dens.flat[0])
288
+ n_dens = float(n_dens.flat[0])
289
+
290
+ return NRLMSISE00Output(
291
+ density=total_density,
292
+ temperature=temperature,
293
+ exosphere_temperature=texo,
294
+ he_density=he_dens,
295
+ o_density=o_dens,
296
+ n2_density=n2_dens,
297
+ o2_density=o2_dens,
298
+ ar_density=ar_dens,
299
+ h_density=h_dens,
300
+ n_density=n_dens,
301
+ )
302
+
303
+ @staticmethod
304
+ def _exosphere_temperature(f107: float, f107a: float, ap: float) -> float:
305
+ """
306
+ Calculate exosphere temperature based on solar/magnetic activity.
307
+
308
+ Parameters
309
+ ----------
310
+ f107 : float
311
+ Daily 10.7 cm solar flux (SFU).
312
+ f107a : float
313
+ 81-day average 10.7 cm solar flux (SFU).
314
+ ap : float
315
+ Planetary magnetic index.
316
+
317
+ Returns
318
+ -------
319
+ texo : float
320
+ Exosphere temperature (K).
321
+ """
322
+ # Base temperature (quiet conditions)
323
+ texo_base = 900.0
324
+
325
+ # Solar activity influence (empirical fit)
326
+ # ~0.7 K per SFU for moderate activity
327
+ f107_corr = 0.7 * (f107a - 90.0)
328
+
329
+ # Magnetic activity influence (empirical fit)
330
+ # Ap index affects thermospheric heating
331
+ ap_corr = 20.0 * np.tanh(ap / 40.0)
332
+
333
+ texo = texo_base + f107_corr + ap_corr
334
+
335
+ # Constrain to physical range
336
+ return np.clip(texo, 500.0, 2500.0)
337
+
338
+ @staticmethod
339
+ def _temperature_profile(
340
+ alt_km: NDArray[np.float64],
341
+ lat: NDArray[np.float64],
342
+ lon: NDArray[np.float64],
343
+ day_of_year: int,
344
+ seconds_in_day: float,
345
+ texo: float,
346
+ ) -> NDArray[np.float64]:
347
+ """
348
+ Calculate temperature at given altitudes.
349
+
350
+ Temperature varies with:
351
+ - Altitude (primary)
352
+ - Latitude (small effect)
353
+ - Local solar time (diurnal variation)
354
+ - Season (small effect)
355
+
356
+ Parameters
357
+ ----------
358
+ alt_km : ndarray
359
+ Altitude in kilometers.
360
+ lat : ndarray
361
+ Latitude in radians.
362
+ lon : ndarray
363
+ Longitude in radians.
364
+ day_of_year : int
365
+ Day of year (1-366).
366
+ seconds_in_day : float
367
+ Seconds since midnight UTC.
368
+ texo : float
369
+ Exosphere temperature (K).
370
+
371
+ Returns
372
+ -------
373
+ temperature : ndarray
374
+ Temperature in Kelvin.
375
+ """
376
+ # Standard Lapse Rates (from ICAO ISA)
377
+ # Troposphere (0-11 km): -6.5 K/km
378
+ # Lower Stratosphere (11-20 km): +1 K/km
379
+ # Upper Stratosphere (20-32 km): +2.8 K/km
380
+ # Mesosphere (32-47 km): -2.8 K/km
381
+ # Upper Mesosphere (47-85 km): -2 K/km (approx)
382
+
383
+ t_surface = 288.15 # K at sea level (15°C)
384
+
385
+ # Initialize temperature array
386
+ t = np.zeros_like(alt_km)
387
+
388
+ # Lower troposphere (0-11 km)
389
+ mask_trop = alt_km <= 11.0
390
+ t[mask_trop] = t_surface - 6.5 * alt_km[mask_trop]
391
+
392
+ # Lower stratosphere (11-20 km)
393
+ mask_lstrat = (alt_km > 11.0) & (alt_km <= 20.0)
394
+ t[mask_lstrat] = 216.65 + 1.0 * (alt_km[mask_lstrat] - 11.0)
395
+
396
+ # Upper stratosphere (20-32 km)
397
+ mask_ustrat = (alt_km > 20.0) & (alt_km <= 32.0)
398
+ t[mask_ustrat] = 226.65 + 2.8 * (alt_km[mask_ustrat] - 20.0)
399
+
400
+ # Mesosphere lower (32-47 km)
401
+ mask_meso1 = (alt_km > 32.0) & (alt_km <= 47.0)
402
+ t[mask_meso1] = 270.65 - 2.8 * (alt_km[mask_meso1] - 32.0)
403
+
404
+ # Mesosphere upper (47-85 km)
405
+ mask_meso2 = (alt_km > 47.0) & (alt_km <= 85.0)
406
+ t_meso_top = 214.65 # Temperature at 47 km
407
+ t_meso_rate = -2.0 # K/km (approximate)
408
+ t[mask_meso2] = t_meso_top + t_meso_rate * (alt_km[mask_meso2] - 47.0)
409
+
410
+ # Thermosphere (>85 km)
411
+ # Temperature rises from mesopause (~170 K) to Texo
412
+ mask_thermo = alt_km > 85.0
413
+
414
+ # Empirical transition function (Chapman function)
415
+ # Creates smooth rise from ~170 K at 85 km to Texo
416
+ z_ref = 85.0
417
+ h_scale = 40.0 # Scale height for transition
418
+
419
+ t_min = 170.0 # Mesopause temperature
420
+
421
+ # Exponential rise from mesopause to exosphere
422
+ z_diff = (alt_km[mask_thermo] - z_ref) / h_scale
423
+ t_factor = (texo - t_min) / (1.0 + np.exp(-5.0)) # Normalize
424
+ t[mask_thermo] = t_min + t_factor * (1.0 - np.exp(-np.maximum(z_diff, 0.0)))
425
+
426
+ # Ensure upper thermosphere approaches Texo
427
+ mask_high = alt_km > 200.0
428
+ t[mask_high] = np.minimum(t[mask_high], texo * 0.95)
429
+ mask_very_high = alt_km > 500.0
430
+ t[mask_very_high] = texo * 0.99
431
+
432
+ # Latitude variation (small - ~±10% at poles)
433
+ lat_variation = 1.0 + 0.05 * np.cos(2.0 * lat)
434
+
435
+ # Local time variation (small - ~±5% diurnal)
436
+ hours_utc = seconds_in_day / 3600.0
437
+ lst = (hours_utc + lon / np.pi * 12.0) % 24.0 # Local solar time
438
+ lt_variation = 1.0 + 0.03 * np.cos(2.0 * np.pi * (lst - 14.0) / 24.0)
439
+
440
+ # Apply variations to mesosphere and above
441
+ mask_var = alt_km > 15.0
442
+ t[mask_var] = t[mask_var] * lat_variation[mask_var] * lt_variation[mask_var]
443
+
444
+ return t
445
+
446
+ @staticmethod
447
+ def _n2_density(
448
+ alt_km: NDArray[np.float64],
449
+ lat: NDArray[np.float64],
450
+ temperature: NDArray[np.float64],
451
+ f107a: float,
452
+ ap: float,
453
+ ) -> NDArray[np.float64]:
454
+ """
455
+ Calculate N2 number density in m^-3.
456
+
457
+ N2 is the primary constituent up to ~85 km, decreasing exponentially
458
+ above that.
459
+ """
460
+ # Reference altitude (11 km, tropopause)
461
+ n2_ref_11km = 3.6e24 # m^-3
462
+
463
+ # Calculate scale height (function of temperature)
464
+ # H = R_gas * T / g / M, for N2 ~10 km at 250 K
465
+ scale_height = 8.5 * (temperature / 250.0)
466
+
467
+ # Exponential model for altitude dependence
468
+ alt_ref = 11.0 # Reference at tropopause
469
+
470
+ # Density increases below tropopause, decreases above
471
+ exponent = -(alt_km - alt_ref) / scale_height
472
+ n2_dens = n2_ref_11km * np.exp(exponent)
473
+
474
+ # Reduce N2 at high altitude (thermosphere transition)
475
+ # At 150 km, N2 is ~1e18 m^-3
476
+ # At 500 km, essentially zero
477
+ transition_alt = 85.0
478
+ mask_thermo = alt_km > transition_alt
479
+
480
+ if np.any(mask_thermo):
481
+ # Smooth transition above 85 km with faster decay
482
+ h_trans = 20.0
483
+ transition_factor = np.exp(
484
+ -(alt_km[mask_thermo] - transition_alt) / h_trans
485
+ )
486
+ n2_dens[mask_thermo] *= transition_factor
487
+
488
+ return np.maximum(n2_dens, 1e10)
489
+
490
+ @staticmethod
491
+ def _o2_density(
492
+ alt_km: NDArray[np.float64],
493
+ lat: NDArray[np.float64],
494
+ temperature: NDArray[np.float64],
495
+ f107a: float,
496
+ ap: float,
497
+ ) -> NDArray[np.float64]:
498
+ """
499
+ Calculate O2 number density in m^-3.
500
+
501
+ O2 is the second major constituent, ~21% at sea level,
502
+ decreases above ~100 km.
503
+ """
504
+ # Reference density (21% of air at sea level)
505
+ o2_ref_11km = 9.8e23 # m^-3
506
+
507
+ # Similar scale height as N2
508
+ scale_height = 8.5 * (temperature / 250.0)
509
+
510
+ alt_ref = 11.0
511
+ exponent = -(alt_km - alt_ref) / scale_height
512
+ o2_dens = o2_ref_11km * np.exp(exponent)
513
+
514
+ # Transition above 85 km - O2 decays faster
515
+ transition_alt = 85.0
516
+ mask_thermo = alt_km > transition_alt
517
+
518
+ if np.any(mask_thermo):
519
+ h_trans = 15.0 # Faster decay than N2
520
+ transition_factor = np.exp(
521
+ -(alt_km[mask_thermo] - transition_alt) / h_trans
522
+ )
523
+ o2_dens[mask_thermo] *= transition_factor
524
+
525
+ return np.maximum(o2_dens, 1e10)
526
+
527
+ @staticmethod
528
+ def _o_density(
529
+ alt_km: NDArray[np.float64],
530
+ lat: NDArray[np.float64],
531
+ temperature: NDArray[np.float64],
532
+ f107a: float,
533
+ ap: float,
534
+ ) -> NDArray[np.float64]:
535
+ """
536
+ Calculate atomic oxygen number density in m^-3.
537
+
538
+ O becomes the dominant species above ~130 km.
539
+ Strongly coupled to solar UV and thermospheric temperature.
540
+ """
541
+ # Atomic oxygen is negligible below ~100 km
542
+ o_dens = np.zeros_like(alt_km)
543
+
544
+ # Above 100 km, increases rapidly
545
+ mask_high = alt_km > 100.0
546
+
547
+ if np.any(mask_high):
548
+ # Reference: ~8e15 m^-3 at 150 km
549
+ # Decreases with scale height ~30 km above 150 km
550
+ alt_ref = 150.0
551
+ dens_ref = 8.0e15
552
+
553
+ # Temperature-dependent scale height
554
+ h_scale = 30.0 * np.sqrt(temperature[mask_high] / 1000.0)
555
+
556
+ # Exponential above 100 km with altitude-dependent onset
557
+ onset_alt = 100.0
558
+ alt_onset = np.maximum(alt_km[mask_high] - onset_alt, 0.0)
559
+
560
+ # Smooth onset between 100-120 km
561
+ onset_smooth = np.minimum(alt_onset / 20.0, 1.0)
562
+
563
+ # Main altitude dependence (scale height increases with T)
564
+ z_diff = alt_km[mask_high] - alt_ref
565
+ exponent = -z_diff / h_scale
566
+
567
+ o_dens[mask_high] = dens_ref * onset_smooth * np.exp(exponent)
568
+
569
+ # Solar activity effect (higher F107 → more atomic O)
570
+ f107_factor = 1.0 + 0.005 * (f107a - 100.0)
571
+ o_dens[mask_high] *= f107_factor
572
+
573
+ return np.maximum(o_dens, 1e12)
574
+
575
+ @staticmethod
576
+ def _he_density(
577
+ alt_km: NDArray[np.float64],
578
+ lat: NDArray[np.float64],
579
+ temperature: NDArray[np.float64],
580
+ f107a: float,
581
+ ap: float,
582
+ ) -> NDArray[np.float64]:
583
+ """
584
+ Calculate helium number density in m^-3.
585
+
586
+ He becomes important above ~150 km, increases with altitude
587
+ due to low mass.
588
+ """
589
+ he_dens = np.zeros_like(alt_km)
590
+
591
+ # He becomes significant above ~120 km
592
+ mask_high = alt_km > 120.0
593
+
594
+ if np.any(mask_high):
595
+ # Reference: ~1e15 m^-3 at 200 km
596
+ alt_ref = 200.0
597
+ dens_ref = 1.0e15
598
+
599
+ # He has smaller scale height due to low mass
600
+ # H_He ≈ (M_N2 / M_He) * H_N2
601
+ mass_ratio = _MW["N2"] / _MW["He"]
602
+ h_scale = 20.0 * mass_ratio * np.sqrt(temperature[mask_high] / 1000.0)
603
+
604
+ # Onset around 120 km
605
+ onset_alt = 120.0
606
+ alt_onset = np.maximum(alt_km[mask_high] - onset_alt, 0.0)
607
+ onset_smooth = np.minimum(alt_onset / 30.0, 1.0)
608
+
609
+ z_diff = alt_km[mask_high] - alt_ref
610
+ exponent = -z_diff / h_scale
611
+
612
+ he_dens[mask_high] = dens_ref * onset_smooth * np.exp(exponent)
613
+
614
+ return np.maximum(he_dens, 1e10)
615
+
616
+ @staticmethod
617
+ def _h_density(
618
+ alt_km: NDArray[np.float64],
619
+ lat: NDArray[np.float64],
620
+ temperature: NDArray[np.float64],
621
+ f107a: float,
622
+ ap: float,
623
+ ) -> NDArray[np.float64]:
624
+ """
625
+ Calculate atomic hydrogen number density in m^-3.
626
+
627
+ H only becomes significant above ~500 km in exosphere.
628
+ """
629
+ h_dens = np.zeros_like(alt_km)
630
+
631
+ # H only important above 400 km
632
+ mask_very_high = alt_km > 400.0
633
+
634
+ if np.any(mask_very_high):
635
+ # Reference: ~1e14 m^-3 at 600 km
636
+ alt_ref = 600.0
637
+ dens_ref = 1.0e14
638
+
639
+ # H has very large scale height (100+ km)
640
+ h_scale = 150.0 * np.sqrt(temperature[mask_very_high] / 1000.0)
641
+
642
+ # Smooth onset at 400 km
643
+ alt_onset = np.maximum(alt_km[mask_very_high] - 400.0, 0.0)
644
+ onset_smooth = np.minimum(alt_onset / 100.0, 1.0)
645
+
646
+ z_diff = alt_km[mask_very_high] - alt_ref
647
+ exponent = -z_diff / h_scale
648
+
649
+ h_dens[mask_very_high] = dens_ref * onset_smooth * np.exp(exponent)
650
+
651
+ return np.maximum(h_dens, 1e8)
652
+
653
+ @staticmethod
654
+ def _ar_density(
655
+ alt_km: NDArray[np.float64],
656
+ lat: NDArray[np.float64],
657
+ temperature: NDArray[np.float64],
658
+ f107a: float,
659
+ ap: float,
660
+ ) -> NDArray[np.float64]:
661
+ """
662
+ Calculate argon number density in m^-3.
663
+
664
+ Ar is a trace gas, constant ratio to N2 in lower atmosphere (~0.93%).
665
+ """
666
+ # Constant mixing ratio with N2
667
+ ar_ratio = 0.0093
668
+
669
+ # Calculate N2 density
670
+ n2_dens = NRLMSISE00._n2_density(alt_km, lat, temperature, f107a, ap)
671
+
672
+ # Ar proportional to N2 up to ~90 km
673
+ ar_dens = ar_ratio * n2_dens
674
+
675
+ # Ar decreases above mesosphere
676
+ mask_thermo = alt_km > 85.0
677
+ if np.any(mask_thermo):
678
+ h_trans = 40.0
679
+ transition_factor = np.exp(-(alt_km[mask_thermo] - 85.0) / h_trans)
680
+ ar_dens[mask_thermo] *= transition_factor
681
+
682
+ return np.maximum(ar_dens, 1e10)
683
+
684
+ @staticmethod
685
+ def _n_density(
686
+ alt_km: NDArray[np.float64],
687
+ lat: NDArray[np.float64],
688
+ temperature: NDArray[np.float64],
689
+ f107a: float,
690
+ ap: float,
691
+ ) -> NDArray[np.float64]:
692
+ """
693
+ Calculate atomic nitrogen number density in m^-3.
694
+
695
+ N is a trace species, photochemically produced above ~100 km.
696
+ """
697
+ n_dens = np.zeros_like(alt_km)
698
+
699
+ # N only significant above ~120 km
700
+ mask_high = alt_km > 120.0
701
+
702
+ if np.any(mask_high):
703
+ # Reference: ~1e15 m^-3 at 300 km
704
+ alt_ref = 300.0
705
+ dens_ref = 1.0e15
706
+
707
+ # Similar scale height to He
708
+ mass_ratio = _MW["N2"] / _MW["N"]
709
+ h_scale = 18.0 * mass_ratio * np.sqrt(temperature[mask_high] / 1000.0)
710
+
711
+ # Onset around 120 km
712
+ onset_alt = 120.0
713
+ alt_onset = np.maximum(alt_km[mask_high] - onset_alt, 0.0)
714
+ onset_smooth = np.minimum(alt_onset / 40.0, 1.0)
715
+
716
+ z_diff = alt_km[mask_high] - alt_ref
717
+ exponent = -z_diff / h_scale
718
+
719
+ n_dens[mask_high] = dens_ref * onset_smooth * np.exp(exponent)
720
+
721
+ # Solar activity effect
722
+ f107_factor = 1.0 + 0.001 * (f107a - 100.0)
723
+ n_dens[mask_high] *= f107_factor
724
+
725
+ return np.maximum(n_dens, 1e10)
726
+
727
+
728
+ def nrlmsise00(
729
+ latitude: ArrayLike,
730
+ longitude: ArrayLike,
731
+ altitude: ArrayLike,
732
+ year: int,
733
+ day_of_year: int,
734
+ seconds_in_day: float,
735
+ f107: float = 150.0,
736
+ f107a: float = 150.0,
737
+ ap: float | ArrayLike = 4.0,
738
+ ) -> NRLMSISE00Output:
739
+ """
740
+ Compute NRLMSISE-00 atmospheric properties.
741
+
742
+ This is a module-level convenience function wrapping the NRLMSISE00 class.
743
+
744
+ Parameters
745
+ ----------
746
+ latitude : array_like
747
+ Geodetic latitude in radians.
748
+ longitude : array_like
749
+ Longitude in radians.
750
+ altitude : array_like
751
+ Altitude in meters.
752
+ year : int
753
+ Year (e.g., 2024).
754
+ day_of_year : int
755
+ Day of year (1-366).
756
+ seconds_in_day : float
757
+ Seconds since midnight (0-86400).
758
+ f107 : float, optional
759
+ 10.7 cm solar flux (daily value, SFU). Default 150.
760
+ f107a : float, optional
761
+ 10.7 cm solar flux (81-day average, SFU). Default 150.
762
+ ap : float or array_like, optional
763
+ Planetary magnetic index. Default 4.0.
764
+
765
+ Returns
766
+ -------
767
+ output : NRLMSISE00Output
768
+ Atmospheric properties.
769
+
770
+ Notes
771
+ -----
772
+ See NRLMSISE00 class for more details.
773
+
774
+ Examples
775
+ --------
776
+ >>> # ISS altitude (~400 km), magnetic latitude = 40°, quiet geomagnetic activity
777
+ >>> output = nrlmsise00(
778
+ ... latitude=np.radians(40),
779
+ ... longitude=np.radians(-75),
780
+ ... altitude=400_000, # 400 km
781
+ ... year=2024,
782
+ ... day_of_year=1,
783
+ ... seconds_in_day=43200,
784
+ ... f107=150, # Average solar activity
785
+ ... f107a=150,
786
+ ... ap=5 # Quiet conditions
787
+ ... )
788
+ >>> print(f"Density at ISS: {output.density:.2e} kg/m³")
789
+ """
790
+ model = NRLMSISE00()
791
+ return model(
792
+ latitude=latitude,
793
+ longitude=longitude,
794
+ altitude=altitude,
795
+ year=year,
796
+ day_of_year=day_of_year,
797
+ seconds_in_day=seconds_in_day,
798
+ f107=f107,
799
+ f107a=f107a,
800
+ ap=ap,
801
+ )
802
+
803
+
804
+ __all__ = [
805
+ "NRLMSISE00",
806
+ "NRLMSISE00Output",
807
+ "F107Index",
808
+ "nrlmsise00",
809
+ ]