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