Atmospheric Modeling
This example demonstrates the simplified thermosphere model and drag calculations for analyzing satellite orbital decay and atmospheric interactions.
Overview
Atmospheric density models are essential for:
LEO satellite operations: Predicting orbital decay and drag
Reentry analysis: Determining vehicle heating and trajectory
Space weather effects: Understanding solar activity impacts
Mission planning: Fuel requirements for station-keeping
Key Scenarios
- ISS Altitude Profile
Atmospheric density varies significantly with solar activity level, affecting ISS orbital decay rate and reboost requirements.
- Satellite Drag
Drag coefficients depend on satellite geometry and atmospheric composition at orbital altitudes.
- Temperature Profiles
The thermosphere temperature increases dramatically with altitude and varies with solar flux (F10.7) and geomagnetic activity (Ap).
- Composition Transitions
The atmosphere transitions from molecular (N2, O2) to atomic (O, He, H) species with increasing altitude.
Orbital Decay: Atmospheric drag causes LEO satellites to gradually lose altitude, with decay rate depending on solar activity levels.
Models Covered
- Simplified Thermosphere
Barometric per-species model with solar-activity (F10.7) and geomagnetic (Ap) inputs
Usable above ~200 km (within ~2x of published NRLMSISE-00 values); below ~86 km use the US Standard Atmosphere instead (gh-79)
- US Standard Atmosphere 1976
Reference atmosphere up to 85 km
Useful for comparison and validation
Code Highlights
The example demonstrates:
Density profiles across altitude with
SimplifiedThermosphere()Species composition (N2, O2, O, He, H, Ar, N) vs altitude
Temperature profiles under various solar activity levels
Solar flux (F10.7) effects on ISS-altitude conditions
Comparison with US Standard Atmosphere 1976
Source Code
1"""
2Atmospheric Modeling and Orbital Decay Analysis
3
4Demonstrates the NRLMSISE-00 empirical atmosphere (the reference C
5implementation, compiled into pytcl) alongside the simplified
6thermosphere model and drag calculations for analyzing satellite
7orbital decay and atmospheric interactions.
8
9Key scenarios:
101. NRLMSISE-00 density and composition, quiet vs geomagnetic storm
112. ISS altitude profile across different solar activity levels
123. Satellite drag coefficient database
134. Orbit decay simulation (LEO satellite)
145. Temperature profile comparison across altitude range
15"""
16
17import os
18from pathlib import Path
19
20import numpy as np
21import plotly.graph_objects as go
22import plotly.subplots as sp
23
24from pytcl.atmosphere import (
25 SimplifiedThermosphere,
26 nrlmsise00,
27 us_standard_atmosphere_1976,
28 uses_compiled_backend,
29)
30
31
32def plot_nrlmsise00_profiles():
33 """
34 Plot NRLMSISE-00 mass density and constituents vs altitude.
35
36 Compares a quiet day against a geomagnetic storm (7-element Ap
37 history), using the same reference C code the MATLAB TCL wraps.
38 """
39 backend = "compiled C extension" if uses_compiled_backend() else "pure Python"
40 print(f"NRLMSISE-00 backend: {backend}")
41
42 altitudes_km = np.linspace(100, 1000, 90)
43 quiet_rho = []
44 storm_rho = []
45 quiet_t = []
46 storm_t = []
47 storm_ap = [148.8, 160.0, 139.0, 127.0, 118.0, 122.5, 115.4]
48 for alt in altitudes_km:
49 q = nrlmsise00(310, 50000.0, float(alt), 55.0, 10.0, 16.0, 140.0, 180.0, 4.0)
50 st = nrlmsise00(
51 310,
52 50000.0,
53 float(alt),
54 55.0,
55 10.0,
56 16.0,
57 140.0,
58 180.0,
59 48.8,
60 ap_array=storm_ap,
61 )
62 quiet_rho.append(q.d[5])
63 storm_rho.append(st.d[5])
64 quiet_t.append(q.t[1])
65 storm_t.append(st.t[1])
66
67 fig = sp.make_subplots(
68 rows=1,
69 cols=2,
70 subplot_titles=("Total mass density", "Temperature"),
71 )
72 fig.add_trace(
73 go.Scatter(x=quiet_rho, y=altitudes_km, name="Quiet (Ap 4)"), row=1, col=1
74 )
75 fig.add_trace(
76 go.Scatter(x=storm_rho, y=altitudes_km, name="Storm (Ap 48.8)"), row=1, col=1
77 )
78 fig.add_trace(
79 go.Scatter(x=quiet_t, y=altitudes_km, name="Quiet T", showlegend=False),
80 row=1,
81 col=2,
82 )
83 fig.add_trace(
84 go.Scatter(x=storm_t, y=altitudes_km, name="Storm T", showlegend=False),
85 row=1,
86 col=2,
87 )
88 fig.update_xaxes(type="log", title_text="Density (kg/m^3)", row=1, col=1)
89 fig.update_xaxes(title_text="Temperature (K)", row=1, col=2)
90 fig.update_yaxes(title_text="Altitude (km)", row=1, col=1)
91 fig.update_layout(title="NRLMSISE-00: quiet vs geomagnetic storm")
92
93 ratio_400 = None
94 for alt, qd, sd in zip(altitudes_km, quiet_rho, storm_rho):
95 if abs(alt - 400.0) < 6:
96 ratio_400 = sd / qd
97 break
98 if ratio_400 is not None:
99 print(f"Storm/quiet density ratio near 400 km: {ratio_400:.2f}x")
100 return fig
101
102
103def plot_density_vs_altitude():
104 """
105 Plot atmospheric density from the simplified thermosphere across altitude range.
106
107 Compares quiet and active solar activity conditions.
108 """
109 # Altitude range from sea level to 1000 km
110 altitudes_km = np.concatenate(
111 [
112 np.linspace(0, 100, 50), # Lower atmosphere (detailed)
113 np.linspace(100, 1000, 100), # Upper atmosphere
114 ]
115 )
116 altitudes_m = altitudes_km * 1000
117
118 # Quiet solar activity (F107=70, Ap=0)
119 model = SimplifiedThermosphere()
120 output_quiet = model(
121 latitude=np.radians(45) * np.ones_like(altitudes_m),
122 longitude=np.radians(-75) * np.ones_like(altitudes_m),
123 altitude=altitudes_m,
124 year=2024,
125 day_of_year=100,
126 seconds_in_day=43200,
127 f107=70,
128 f107a=70,
129 ap=0,
130 )
131
132 # Active solar activity (F107=200, Ap=50)
133 output_active = model(
134 latitude=np.radians(45) * np.ones_like(altitudes_m),
135 longitude=np.radians(-75) * np.ones_like(altitudes_m),
136 altitude=altitudes_m,
137 year=2024,
138 day_of_year=100,
139 seconds_in_day=43200,
140 f107=200,
141 f107a=200,
142 ap=50,
143 )
144
145 # US Standard Atmosphere for comparison (up to 85 km only)
146 altitudes_short = altitudes_km[altitudes_km <= 85]
147 output_us76 = np.array(
148 [us_standard_atmosphere_1976(h * 1000).density for h in altitudes_short]
149 )
150
151 fig = sp.make_subplots(rows=1, cols=1, specs=[[{"secondary_y": True}]])
152
153 # Log-scale density plot
154 fig.add_trace(
155 go.Scatter(
156 x=altitudes_km,
157 y=np.maximum(output_quiet.density, 1e-20),
158 name="Quiet (F107=70, Ap=0)",
159 mode="lines",
160 line=dict(color="blue", width=2),
161 hovertemplate="<b>Density (Quiet)</b><br>Alt: %{x:.1f} km<br>rho: %{y:.2e} kg/m³",
162 ),
163 secondary_y=False,
164 )
165
166 fig.add_trace(
167 go.Scatter(
168 x=altitudes_km,
169 y=np.maximum(output_active.density, 1e-20),
170 name="Active (F107=200, Ap=50)",
171 mode="lines",
172 line=dict(color="red", width=2, dash="dash"),
173 hovertemplate="<b>Density (Active)</b><br>Alt: %{x:.1f} km<br>rho: %{y:.2e} kg/m³",
174 ),
175 secondary_y=False,
176 )
177
178 # US 76 for reference
179 fig.add_trace(
180 go.Scatter(
181 x=altitudes_short,
182 y=np.maximum(output_us76, 1e-20),
183 name="US Standard 1976",
184 mode="lines",
185 line=dict(color="green", width=2, dash="dot"),
186 hovertemplate="<b>Density (US76)</b><br>Alt: %{x:.1f} km<br>rho: %{y:.2e} kg/m³",
187 ),
188 secondary_y=False,
189 )
190
191 fig.update_yaxes(type="log", title_text="Density (kg/m³)", secondary_y=False)
192
193 fig.update_xaxes(title_text="Altitude (km)", range=[0, 1000])
194
195 fig.update_layout(
196 title="Atmospheric Density vs. Altitude<br><sub>Simplified Thermosphere (Quiet vs. Active Solar Activity)</sub>",
197 hovermode="x unified",
198 height=600,
199 template="plotly_white",
200 )
201
202 return fig
203
204
205def plot_composition_profile():
206 """
207 Plot atmospheric composition (species densities) vs. altitude.
208 """
209 # Altitude range
210 altitudes_km = np.linspace(80, 500, 200)
211 altitudes_m = altitudes_km * 1000
212
213 model = SimplifiedThermosphere()
214 output = model(
215 latitude=np.zeros_like(altitudes_m),
216 longitude=np.zeros_like(altitudes_m),
217 altitude=altitudes_m,
218 year=2024,
219 day_of_year=1,
220 seconds_in_day=0,
221 f107=150,
222 f107a=150,
223 ap=5,
224 )
225
226 fig = go.Figure()
227
228 # Plot each species
229 species = [
230 ("N2", output.n2_density, "blue"),
231 ("O2", output.o2_density, "green"),
232 ("O", output.o_density, "red"),
233 ("He", output.he_density, "purple"),
234 ("H", output.h_density, "orange"),
235 ("Ar", output.ar_density, "brown"),
236 ("N", output.n_density, "pink"),
237 ]
238
239 for name, density, color in species:
240 fig.add_trace(
241 go.Scatter(
242 x=altitudes_km,
243 y=np.maximum(density, 1e8),
244 name=name,
245 mode="lines",
246 line=dict(color=color, width=2.5),
247 hovertemplate=f"<b>{name}</b><br>Alt: %{{x:.1f}} km<br>n: %{{y:.2e}} m⁻³",
248 )
249 )
250
251 fig.update_yaxes(type="log", title_text="Number Density (m^-³)")
252
253 fig.update_xaxes(title_text="Altitude (km)")
254
255 fig.update_layout(
256 title="Atmospheric Composition vs. Altitude<br><sub>Simplified Thermosphere at F107=150, Ap=5</sub>",
257 height=600,
258 hovermode="x unified",
259 template="plotly_white",
260 )
261
262 return fig
263
264
265def plot_temperature_profile():
266 """
267 Plot temperature vs. altitude across full range.
268 """
269 altitudes_km = np.concatenate(
270 [np.linspace(0, 100, 50), np.linspace(100, 1000, 100)]
271 )
272 altitudes_m = altitudes_km * 1000
273
274 model = SimplifiedThermosphere()
275
276 # Different solar activity levels
277 conditions = [
278 ("Quiet (F107=70)", 70, 70, 0, "blue"),
279 ("Moderate (F107=150)", 150, 150, 5, "green"),
280 ("Active (F107=200)", 200, 200, 50, "orange"),
281 ("Storm (F107=150, Ap=300)", 150, 150, 300, "red"),
282 ]
283
284 fig = go.Figure()
285
286 for label, f107, f107a, ap, color in conditions:
287 output = model(
288 latitude=np.zeros_like(altitudes_m),
289 longitude=np.zeros_like(altitudes_m),
290 altitude=altitudes_m,
291 year=2024,
292 day_of_year=1,
293 seconds_in_day=0,
294 f107=f107,
295 f107a=f107a,
296 ap=ap,
297 )
298
299 fig.add_trace(
300 go.Scatter(
301 x=output.temperature,
302 y=altitudes_km,
303 name=label,
304 mode="lines",
305 line=dict(color=color, width=2.5),
306 hovertemplate="<b>"
307 + label
308 + "</b><br>T: %{x:.0f} K<br>Alt: %{y:.1f} km",
309 )
310 )
311
312 fig.update_yaxes(title_text="Altitude (km)", range=[0, 500])
313
314 fig.update_xaxes(title_text="Temperature (K)", range=[150, 1100])
315
316 fig.update_layout(
317 title="Temperature Profile vs. Altitude<br><sub>Simplified Thermosphere Under Various Solar Activity Levels</sub>",
318 height=600,
319 hovermode="x unified",
320 template="plotly_white",
321 )
322
323 return fig
324
325
326def plot_solar_activity_effect():
327 """
328 Plot density response to varying solar activity index (F107).
329 """
330 # Fixed altitude ISS orbit
331 iss_altitude = 408_000 # meters
332
333 # Vary F107 from quiet to stormy
334 f107_range = np.linspace(50, 300, 50)
335
336 model = SimplifiedThermosphere()
337 densities = []
338 temperatures = []
339
340 for f107 in f107_range:
341 output = model(
342 latitude=np.radians(51.6), # ISS inclination
343 longitude=np.radians(0),
344 altitude=iss_altitude,
345 year=2024,
346 day_of_year=1,
347 seconds_in_day=0,
348 f107=f107,
349 f107a=f107,
350 ap=5,
351 )
352 densities.append(output.density)
353 temperatures.append(output.temperature)
354
355 fig = sp.make_subplots(
356 rows=1, cols=2, specs=[[{"secondary_y": False}, {"secondary_y": False}]]
357 )
358
359 # Density vs F107
360 fig.add_trace(
361 go.Scatter(
362 x=f107_range,
363 y=densities,
364 name="Density",
365 mode="lines+markers",
366 line=dict(color="blue", width=2),
367 marker=dict(size=4),
368 hovertemplate="F107: %{x:.0f} SFU<br>rho: %{y:.2e} kg/m³",
369 ),
370 row=1,
371 col=1,
372 )
373
374 # Temperature vs F107
375 fig.add_trace(
376 go.Scatter(
377 x=f107_range,
378 y=temperatures,
379 name="Temperature",
380 mode="lines+markers",
381 line=dict(color="red", width=2),
382 marker=dict(size=4),
383 hovertemplate="F107: %{x:.0f} SFU<br>T: %{y:.0f} K",
384 ),
385 row=1,
386 col=2,
387 )
388
389 fig.update_xaxes(title_text="Solar Flux F107 (SFU)", row=1, col=1)
390 fig.update_xaxes(title_text="Solar Flux F107 (SFU)", row=1, col=2)
391 fig.update_yaxes(title_text="Density at 408 km (kg/m³)", type="log", row=1, col=1)
392 fig.update_yaxes(title_text="Temperature at 408 km (K)", row=1, col=2)
393
394 fig.update_layout(
395 title_text="Effect of Solar Activity on Atmospheric Conditions at ISS Altitude",
396 height=500,
397 hovermode="x unified",
398 template="plotly_white",
399 )
400
401 return fig
402
403
404def plot_composition_transitions():
405 """
406 Plot composition transition from molecular to atomic atmosphere.
407 """
408 altitudes_km = np.linspace(70, 300, 300)
409 altitudes_m = altitudes_km * 1000
410
411 model = SimplifiedThermosphere()
412 output = model(
413 latitude=np.zeros_like(altitudes_m),
414 longitude=np.zeros_like(altitudes_m),
415 altitude=altitudes_m,
416 year=2024,
417 day_of_year=1,
418 seconds_in_day=0,
419 f107=150,
420 f107a=150,
421 ap=5,
422 )
423
424 # Calculate composition percentages
425 total_dens = (
426 output.n2_density
427 + output.o2_density
428 + output.o_density
429 + output.he_density
430 + output.h_density
431 + output.ar_density
432 + output.n_density
433 )
434
435 fig = go.Figure()
436
437 # Stacked area chart (as percentages)
438 species_data = [
439 ("N2", output.n2_density / total_dens * 100, "blue"),
440 ("O2", output.o2_density / total_dens * 100, "green"),
441 ("O", output.o_density / total_dens * 100, "red"),
442 ("He", output.he_density / total_dens * 100, "purple"),
443 ("H", output.h_density / total_dens * 100, "orange"),
444 ("Other", (output.ar_density + output.n_density) / total_dens * 100, "gray"),
445 ]
446
447 for name, fractions, color in species_data:
448 fig.add_trace(
449 go.Scatter(
450 x=altitudes_km,
451 y=fractions,
452 name=name,
453 mode="lines",
454 line=dict(width=0.5, color=color),
455 fillcolor=color,
456 fill="tonexty" if name != "N2" else "tozeroy",
457 hovertemplate=f"<b>{name}</b><br>Alt: %{{x:.1f}} km<br>Fraction: %{{y:.1f}}%",
458 )
459 )
460
461 fig.update_yaxes(title_text="Composition (%)", range=[0, 100])
462
463 fig.update_xaxes(title_text="Altitude (km)")
464
465 fig.update_layout(
466 title="Atmospheric Composition Transition<br><sub>Molecular -> Atomic Atmosphere (70-300 km)</sub>",
467 height=600,
468 hovermode="x unified",
469 template="plotly_white",
470 )
471
472 return fig
473
474
475if __name__ == "__main__":
476 print("Generating thermosphere modeling visualizations...")
477
478 # Create output directory
479 output_dir = os.path.join(os.path.dirname(__file__), "output")
480 os.makedirs(output_dir, exist_ok=True)
481
482 # Generate all plots
483 fig0 = plot_nrlmsise00_profiles()
484 output_path0 = os.path.join(output_dir, "nrlmsise00_profiles.html")
485 fig0.write_html(
486 output_path0, include_plotlyjs="cdn", div_id=Path(output_path0).stem
487 )
488 print(f"OK Saved: {output_path0}")
489
490 fig1 = plot_density_vs_altitude()
491 output_path1 = os.path.join(output_dir, "thermosphere_density.html")
492 fig1.write_html(
493 output_path1, include_plotlyjs="cdn", div_id=Path(output_path1).stem
494 )
495 print(f"OK Saved: {output_path1}")
496
497 fig2 = plot_composition_profile()
498 output_path2 = os.path.join(output_dir, "thermosphere_composition.html")
499 fig2.write_html(
500 output_path2, include_plotlyjs="cdn", div_id=Path(output_path2).stem
501 )
502 print(f"OK Saved: {output_path2}")
503
504 fig3 = plot_temperature_profile()
505 output_path3 = os.path.join(output_dir, "thermosphere_temperature.html")
506 fig3.write_html(
507 output_path3, include_plotlyjs="cdn", div_id=Path(output_path3).stem
508 )
509 print(f"OK Saved: {output_path3}")
510
511 fig4 = plot_solar_activity_effect()
512 output_path4 = os.path.join(output_dir, "thermosphere_solar_activity.html")
513 fig4.write_html(
514 output_path4, include_plotlyjs="cdn", div_id=Path(output_path4).stem
515 )
516 print(f"OK Saved: {output_path4}")
517
518 fig5 = plot_composition_transitions()
519 output_path5 = os.path.join(output_dir, "thermosphere_composition_transition.html")
520 fig5.write_html(
521 output_path5, include_plotlyjs="cdn", div_id=Path(output_path5).stem
522 )
523 print(f"OK Saved: {output_path5}")
524
525 print("\nAll visualizations complete!")
526 print("View the HTML files in a browser to interact with the plots.")
Running the Example
python examples/atmospheric_modeling.py
See Also
Orbital Mechanics - Orbital propagation
Relativistic Effects - Relativistic corrections
High-Precision Ephemeris - Planetary positions