Skip to content
SciStack
Minigame Python Beginner 25 min

Orbital decay: why drag makes a satellite fly faster as it falls

Afterwards you can explain why drag speeds up an orbiting satellite, and estimate its orbital lifetime from the density of the upper atmosphere.

Field
Engineering, Physics
Prerequisites
none beyond Python basics
Libraries
matplotlib 3.11.2numpy 2.4.3pymsis 0.13.0scipy 1.18.1
Download notebook Save Mark as done

py-orbital-decay.ipynb, executed with the versions above. The download needs a free account

Run it yourself. In a terminal, this installs exactly the versions above:

pip install numpy==2.4.3 scipy==1.18.1 pymsis==0.13.0 matplotlib==3.11.2 jupyterlab

Play: bring a space station down with thin air

A space station like the ISS circles the Earth at 400 km, where the air is 1.7 trillion times thinner than at sea level. That air is enough to bring it down. The game starts with drag off, and the station circles at a steady 7.673 km/s. Tap Drag on, then Warp + a few times; the warp is game time per second of real time, from 1 min/s to 100 d/s. Sun switches the upper atmosphere between a quiet Sun and an active one, Altitude − and + set the start from 200 to 500 km, and Pause and Reset do what they say. Watch the four numbers under the scene: the speed, the altitude, the sink rate in meters per day, and the day. Heights above the ground are drawn eight times taller than they are; the Earth is not. With drag on the station sinks, and its speed climbs, to 7.730 km/s by 300 km. Under a quiet Sun it re-enters after about four and a half years.

Download the game

Show code
// game.js: a space station circles the Earth in thin air, and drag brings it down.
// Units: m, m/s, s. The orbit lies in the x-y plane with the Earth at the origin; the station
// starts on the +x axis and circles counterclockwise. The notebook writes the constants and the
// density tables from NRLMSIS 2.0 into #data.
"use strict";

const DATA = JSON.parse(document.getElementById("data").textContent);
const {mu: MU, r_earth: R, b: B, dt: DT, max_steps: MAX_STEPS, h_end: H_END, stretch: STRETCH} = DATA;
const TABLE = DATA.table, DAY = 86400;

// ---- the model: gravity, and drag against the velocity
function density(h, sun) {                 // kg/m³, linear in log10 ρ between the table's rows
  const t = TABLE[sun], x = Math.min(Math.max((h / 1000 - TABLE.h_min) / TABLE.h_step, 0), t.length - 1.000001);
  const i = Math.floor(x), f = x - i;
  return 10 ** (t[i] * (1 - f) + t[i + 1] * f);
}

function accel(r, v, drag, sun) {
  const d = Math.hypot(r[0], r[1]), g = -MU / d ** 3;
  const a = [g * r[0], g * r[1]];
  if (drag) {                              // ½ ρ B v², along -v
    const k = -0.5 * density(d - R, sun) * B * Math.hypot(v[0], v[1]);
    a[0] += k * v[0];
    a[1] += k * v[1];
  }
  return a;
}

// velocity Verlet: half a kick, a drift, half a kick; the acceleration is kept for the next step
function step(s, dt) {
  const old = s.r;
  s.v = [s.v[0] + s.a[0] * dt / 2, s.v[1] + s.a[1] * dt / 2];
  s.r = [s.r[0] + s.v[0] * dt, s.r[1] + s.v[1] * dt];
  s.a = accel(s.r, s.v, s.drag, s.sun);
  s.v = [s.v[0] + s.a[0] * dt / 2, s.v[1] + s.a[1] * dt / 2];
  s.t += dt;
  const h = altitude(s.r);
  if (old[1] < 0 && s.r[1] >= 0) {         // one more revolution: the sink rate over the last one
    if (s.rev) s.sink = (s.rev.h - h) / (s.t - s.rev.t) * DAY;
    s.rev = {t: s.t, h};
  }
  if (s.t >= s.nextDot) {
    s.trail.push(s.r);
    if (s.trail.length > DATA.trail_max) s.trail.shift();
    s.nextDot += DATA.trail_every;
  }
  if (h < H_END) {
    s.ended = true;
    s.note = `re-entered after ${Math.round(s.t / DAY).toLocaleString("en-US")} days`;
  }
}

const altitude = (r) => Math.hypot(r[0], r[1]) - R;

function advance(s, seconds) {             // at most MAX_STEPS steps of at most DT
  const n = Math.min(Math.ceil(seconds / DT), MAX_STEPS);
  const dt = Math.min(DT, seconds / Math.max(n, 1));
  for (let k = 0; k < n && !s.ended; k++) step(s, dt);
}

function tick(s, dtReal) {
  if (!s.ended) advance(s, Math.min(dtReal, 0.05) * DATA.warps[s.warp]);   // the cap stops jumps
}

// ---- the state: everything that changes, in one object
function fresh(keep = {}) {
  const s = {drag: false, sun: "quiet", h0: DATA.alt_start, warp: DATA.warp_start, running: true, ...keep};
  const a0 = R + s.h0 * 1000;
  Object.assign(s, {t: 0, r: [a0, 0], v: [0, Math.sqrt(MU / a0)], trail: [], nextDot: 0,
    rev: null, sink: null, ended: false, note: ""});
  s.a = accel(s.r, s.v, s.drag, s.sun);
  return s;
}

// ---- drawing
const $ = (id) => document.getElementById(id);
const color = (name) => getComputedStyle(document.documentElement).getPropertyValue(`--${name}`).trim();
const FONT = "14px system-ui, sans-serif";
const view = {};

function layout() {
  const W = document.documentElement.clientWidth, H = document.documentElement.clientHeight;
  document.body.className = H > W ? "upright" : "wide";
  const c = $("scene"), box = c.parentElement.getBoundingClientRect(), dpr = window.devicePixelRatio || 1;
  c.width = Math.round(box.width * dpr);
  c.height = Math.round(box.height * dpr);
  view.ctx = c.getContext("2d");
  view.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  Object.assign(view, {w: box.width, h: box.height});
  render();
}

function ring(ctx, cx, cy, r, style, width, dash = []) {
  ctx.beginPath();
  ctx.arc(cx, cy, r, 0, 2 * Math.PI);
  ctx.strokeStyle = style;
  ctx.lineWidth = width;
  ctx.setLineDash(dash);
  ctx.stroke();
  ctx.setLineDash([]);
}

function key(ctx, text, dash, x, y, style) {    // a short line in the ring's dash pattern, then its name
  ctx.font = FONT;
  const kx = x - ctx.measureText(text).width;
  ctx.beginPath();
  ctx.moveTo(kx - 26, y - 5);
  ctx.lineTo(kx - 4, y - 5);
  ctx.strokeStyle = style;
  ctx.lineWidth = 1.2;
  ctx.setLineDash(dash);
  ctx.stroke();
  ctx.setLineDash([]);
  label(ctx, text, x, y, style, "right");
}

const hexRgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(", ");

function label(ctx, text, x, y, style, align = "left") {
  ctx.font = FONT;
  ctx.textAlign = align;
  ctx.fillStyle = style;
  ctx.fillText(text, x, y);
}

// The scene: the Earth from above the orbit plane, heights stretched so that 400 km is visible
function drawScene(c) {
  const {ctx, w, h} = view, cx = w / 2, cy = h / 2;
  const top = TABLE.h_min + TABLE.h_step * (TABLE.quiet.length - 1);          // km, the table's last row
  const k = 0.47 * Math.min(w, h) / (R + STRETCH * top * 1000);                // px per m
  const rad = (hm) => (R + STRETCH * hm) * k;                                  // screen radius of an altitude in m
  const at = (p) => {                                                          // stretch the altitude, keep the angle
    const d = Math.hypot(p[0], p[1]), s = rad(d - R) / d;
    return [cx + p[0] * s, cy - p[1] * s];
  };
  ctx.clearRect(0, 0, w, h);
  // the air: one radial gradient with a stop per table row, more opaque where the air is denser
  const t = TABLE[state.sun], [lo, hi] = DATA.shade, rgb = hexRgb(state.sun === "active" ? c.accent : c.second);
  const air = ctx.createRadialGradient(cx, cy, rad(TABLE.h_min * 1000), cx, cy, rad(top * 1000));
  t.forEach((lg, i) => {
    const alpha = 0.7 * Math.min(Math.max((lg - lo) / (hi - lo), 0), 1);
    air.addColorStop(i / (t.length - 1), `rgba(${rgb}, ${alpha.toFixed(3)})`);   // inside 100 km the first stop's color fills down to the ground
  });
  ctx.fillStyle = air;
  ctx.beginPath();
  ctx.arc(cx, cy, rad(top * 1000), 0, 2 * Math.PI);
  ctx.fill();
  ctx.beginPath();
  ctx.arc(cx, cy, R * k, 0, 2 * Math.PI);
  ctx.fillStyle = c.globe;
  ctx.fill();
  ring(ctx, cx, cy, R * k, c.ink, 1.5);
  ring(ctx, cx, cy, rad(H_END), c.muted, 1.2, [5, 5]);                         // re-entry
  ring(ctx, cx, cy, rad(state.h0 * 1000), c.muted, 1, [2, 4]);                 // the start
  // the trail of the last orbits, and the station
  if (state.trail.length) {
    ctx.beginPath();
    [...state.trail, state.r].map(at).forEach((p, i) => (i ? ctx.lineTo(...p) : ctx.moveTo(...p)));
    ctx.strokeStyle = c.ink;
    ctx.globalAlpha = 0.6;
    ctx.lineWidth = 1.5;
    ctx.stroke();
    ctx.globalAlpha = 1;
  }
  const p = at(state.r);
  ctx.beginPath();
  ctx.arc(p[0], p[1], 5, 0, 2 * Math.PI);
  ctx.fillStyle = c.ink;
  ctx.fill();
  ctx.strokeStyle = c.bg;
  ctx.lineWidth = 2;
  ctx.stroke();
  label(ctx, `heights ×${STRETCH}`, 8, h - 10, c.muted);
  key(ctx, `re-entry, ${H_END / 1000} km`, [5, 5], w - 8, h - 10, c.muted);
  key(ctx, `start, ${state.h0} km`, [2, 4], w - 8, h - 30, c.muted);
}

const fmt = (x) => Math.round(x).toLocaleString("en-US");

function render() {
  const c = Object.fromEntries(["bg", "ink", "accent", "second", "muted", "globe"].map((n) => [n, color(n)]));
  drawScene(c);
  const h = altitude(state.r);
  $("speed").textContent = `${(Math.hypot(state.v[0], state.v[1]) / 1000).toFixed(3)} km/s`;
  $("alt").textContent = `${(h / 1000).toFixed(1)} km`;
  $("sink").textContent = state.sink === null ? "–" : `${state.sink < 10 ? state.sink.toFixed(1) : fmt(state.sink)} m/day`;
  $("days").textContent = `${(state.t / DAY).toFixed(state.t < 10 * DAY ? 2 : 0)}`;
  $("note").textContent = state.note || (state.drag ? "drag on: the air brakes the station" : "drag off: the orbit holds");
  $("drag").textContent = state.drag ? "Drag on" : "Drag off";
  $("sun").textContent = state.sun === "active" ? "Sun active" : "Sun quiet";
  $("sun").className = state.sun;
  $("alt-val").textContent = `${state.h0} km`;
  $("warp-val").textContent = DATA.warp_names[state.warp];
  $("go").textContent = state.running ? "Pause" : "Play";
}

// ---- controls, keys, and the loop
function set(partial) {
  Object.assign(state, partial);
  state.a = accel(state.r, state.v, state.drag, state.sun);   // the force may have changed
  render();
}
const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
const keep = () => ({drag: state.drag, sun: state.sun, h0: state.h0, warp: state.warp, running: state.running});
const reset = () => set(fresh(keep()));
const nudgeAlt = (sign) => set(fresh({...keep(), h0: clamp(state.h0 + sign * DATA.alt_step, DATA.alt_min, DATA.alt_max)}));
const nudgeWarp = (sign) => set({warp: clamp(state.warp + sign, 0, DATA.warps.length - 1)});
const toggleDrag = () => set({drag: !state.drag});
const toggleSun = () => set({sun: state.sun === "quiet" ? "active" : "quiet"});
const go = () => set({running: !state.running});

$("drag").addEventListener("click", toggleDrag);
$("sun").addEventListener("click", toggleSun);
$("alt-minus").addEventListener("click", () => nudgeAlt(-1));
$("alt-plus").addEventListener("click", () => nudgeAlt(1));
$("warp-minus").addEventListener("click", () => nudgeWarp(-1));
$("warp-plus").addEventListener("click", () => nudgeWarp(1));
$("go").addEventListener("click", go);
$("reset").addEventListener("click", reset);
document.addEventListener("keydown", (e) => {
  const actions = {d: toggleDrag, s: toggleSun, " ": go, r: reset, "[": () => nudgeWarp(-1), "]": () => nudgeWarp(1),
    "-": () => nudgeAlt(-1), "=": () => nudgeAlt(1), "+": () => nudgeAlt(1)};
  const act = actions[e.key.toLowerCase()];
  if (act && !(e.target instanceof HTMLButtonElement && e.key === " ")) {
    e.preventDefault();
    act();
  }
});

const state = fresh();
let last = null;
function frame(now) {                      // called by the browser once per screen refresh
  const dt = last === null ? 0 : (now - last) / 1000;
  last = now;
  if (state.running) tick(state, dt);
  render();
  requestAnimationFrame(frame);
}

window.addEventListener("resize", layout);
new ResizeObserver(layout).observe($("scenebox"));   // the box also changes when the readouts rewrap
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", render);
layout();
requestAnimationFrame(frame);
// for stills from the console: game.set({drag: true, sun: "active"}); game.fly(150 * 86400)
window.game = {state, set, render, fly: (seconds) => { while (!state.ended && seconds > 0) {
  const span = Math.min(seconds, MAX_STEPS * DT); advance(state, span); seconds -= span; } render(); }};

Drag makes the satellite faster

Turn drag on at 400 km under a quiet Sun and the station sinks while its speed climbs, from 7.673 km/s to 7.730 km/s at 300 km on day 1,524. The air pushes backward, and the station flies faster. The notebook draws the stills here in the game's layout, from the same data.

Show code
import json
from pathlib import Path

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.gridspec import GridSpec
from matplotlib.patches import Circle
from scipy.integrate import solve_ivp, cumulative_trapezoid
import pymsis

plt.rcParams.update({
    "figure.figsize": (7, 3.6), "figure.dpi": 110,
    "axes.spines.top": False, "axes.spines.right": False,
    "axes.grid": True, "grid.alpha": 0.25,
    "font.size": 11, "lines.linewidth": 1.8,
})
INK, ACCENT, SECOND, MUTED = "#1f2a44", "#c8553d", "#2a7f9e", "#8a8f98"
SUN_COLOR = {"quiet": SECOND, "active": ACCENT}

MU = 3.986004418e14            # m³/s², GM of the Earth
R = 6371e3                     # m, mean radius of a spherical Earth
DAY, YEAR = 86400.0, 365.25 * 86400.0
m, A, C_D = 420_000.0, 1500.0, 2.2      # kg, m², the station (round values)
B = C_D * A / m                # m²/kg, the ballistic coefficient as used here

# NRLMSIS 2.0: the area-weighted global mean density on one date, for a quiet and an active Sun
SUNS = {"quiet": 70.0, "active": 200.0}  # F10.7 and its 81-day mean F10.7a, solar flux units
AP = 4.0                       # daily Ap: a quiet geomagnetic field
ALT_KM = np.arange(100, 801, 5)
lons, lats = np.arange(0, 360, 15), np.arange(-87.5, 90, 5)
RHO, RHO_SEA = {}, {}
for sun, f107 in SUNS.items():
    out = pymsis.calculate(np.datetime64("2024-03-20T12:00"), lons, lats, np.r_[0, ALT_KM],
                           f107s=[f107], f107as=[f107], aps=[[AP] * 7], version=2.0)
    rho = out[0, ..., pymsis.Variable.MASS_DENSITY].mean(axis=0)          # mean over longitude
    rho = np.average(rho, axis=0, weights=np.cos(np.radians(lats)))       # area-weighted over latitude
    RHO_SEA[sun], RHO[sun] = rho[0], rho[1:]
LN_RHO = {sun: np.log(RHO[sun]) for sun in SUNS}

def density(h, sun):
    """kg/m³ at altitude h in m, linear in ln ρ between the 5 km rows."""
    return np.exp(np.interp(h / 1e3, ALT_KM, LN_RHO[sun]))

# The game's scene: the Earth from above the orbit plane, heights stretched
STRETCH, SHADE, EDGE = 8, (-13.5, -6.5), -12.0     # log10 ρ: transparent, full shade; the shell's edge

def show(x, y):
    """Stretch altitudes for the drawing; positions in m, the result in Mm."""
    d = np.hypot(x, y)
    s = (R + STRETCH * (d - R)) / d / 1e6
    return x * s, y * s

def scene(ax, sun, h0_km, edge_of=None, window=None):
    """The game's view: the shell shaded by density, the Earth, the 100 km line, a key below."""
    rad = lambda h_km: (R + STRETCH * h_km * 1e3) / 1e6
    lim = 1.02 * rad(ALT_KM[-1])
    x0, x1, y0, y1 = window or (-lim, lim, -lim, lim)
    X, Y = np.meshgrid(np.linspace(x0, x1, 600), np.linspace(y0, y1, 600))
    h_px = (np.hypot(X, Y) * 1e6 - R) / STRETCH / 1e3            # km, the true altitude of each pixel
    lo, hi = SHADE
    alpha = 0.7 * np.clip((np.interp(h_px, ALT_KM, np.log10(RHO[sun])) - lo) / (hi - lo), 0, 1)
    shade = np.zeros(h_px.shape + (4,))
    shade[..., :3] = plt.matplotlib.colors.to_rgb(SUN_COLOR[sun])
    shade[..., 3] = np.where(h_px <= ALT_KM[-1], alpha, 0)         # below 100 km the first row reaches the ground
    ax.imshow(shade, extent=(x0, x1, y0, y1), origin="lower", interpolation="bilinear", zorder=1)
    ax.add_patch(Circle((0, 0), R / 1e6, facecolor="#ebe9e4", edgecolor=INK, lw=1.2, zorder=2))
    ax.add_patch(Circle((0, 0), rad(100), fill=False, edgecolor=MUTED, lw=1.2, ls="--", zorder=3))
    ax.plot([], [], color=MUTED, lw=1.2, ls="--", label="re-entry, 100 km")
    if h0_km:                              # the start
        ax.add_patch(Circle((0, 0), rad(h0_km), fill=False, edgecolor=MUTED, lw=1, ls=":", zorder=3))
        ax.plot([], [], color=MUTED, lw=1, ls=":", label=f"start, {h0_km} km")
    if edge_of:                            # where the other Sun's air would end
        h_edge = ALT_KM[np.argmax(np.log10(RHO[edge_of]) < EDGE)]
        ax.add_patch(Circle((0, 0), rad(h_edge), fill=False, edgecolor=SUN_COLOR[edge_of], lw=1.2, ls="--", zorder=3))
        ax.plot([], [], color=SUN_COLOR[edge_of], lw=1.2, ls="--", label=f"10⁻¹² kg/m³, {edge_of} Sun")
    ax.set(xlim=(x0, x1), ylim=(y0, y1), aspect="equal")
    ax.set_axis_off()
    ax.legend(loc="upper right", bbox_to_anchor=(1, 0), frameon=False, handlelength=1.6, borderaxespad=0)
    ax.text(0, -0.01, f"heights ×{STRETCH}", transform=ax.transAxes, color=MUTED, va="top")

print(f"B = {B:.4f} m²/kg; NRLMSIS 2.0 global mean at 400 km: "
      + ", ".join(f"{sun} {density(400e3, sun):.2e} kg/m³" for sun in SUNS))
B = 0.0079 m²/kg; NRLMSIS 2.0 global mean at 400 km: quiet 7.32e-13 kg/m³, active 7.80e-12 kg/m³
Show code
def circular(h_km):
    a = R + h_km * 1e3
    v = np.sqrt(MU / a)
    return a, v, 0.5 * v**2, -MU / a

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(9, 3.8), gridspec_kw=dict(width_ratios=[1, 1.3]))
scene(ax1, "quiet", 400)
for h_km, angle, ha in [(400, 35, "left"), (300, 145, "right")]:
    a, v, _, _ = circular(h_km)
    p = show(a * np.cos(np.radians(angle)), a * np.sin(np.radians(angle)))
    ax1.plot(*p, "o", ms=6, color=INK, mec="white", zorder=5)
    ax1.text(p[0] + (0.4 if ha == "left" else -0.4), p[1] + 0.3, f"{h_km} km\n{v / 1e3:.3f} km/s",
             color=INK, ha=ha, va="bottom")
h = np.linspace(400, 100, 200)
_, _, K, U = circular(h)
_, _, K0, U0 = circular(400)
for y, name, col, lw in [(K - K0, "kinetic", INK, 1.8), (U - U0, "potential", MUTED, 1.8),
                         (K + U - K0 - U0, "total", INK, 1.0)]:
    ax2.plot(h, y / 1e6, color=col, lw=lw)
    ax2.text(95, y[-1] / 1e6, name, color=col, va="center", ha="left")
ax2.set(xlim=(400, 40), xticks=np.arange(400, 99, -50), xlabel="altitude / km",
        ylabel="change since 400 km / (MJ/kg)")
fig.tight_layout()
plt.show()
Left: the game at quiet Sun, start 400 km, drag on: the station at 400 km at 7.673 km/s and later at 300 km at 7.730 km/s. Right: change of energy per kg in MJ/kg as the orbit sinks from 400 to 100 km: kinetic up, potential down twice as fast, total down.

The air the station sweeps up pushes back with

\[F = \tfrac12 \rho\, C_D A\, v^2,\]

with \(\rho\) the air density, \(A\) the area facing the flow, and \(C_D\) the drag coefficient. Per kilogram that is a deceleration of \(\tfrac12\rho B v^2\) with \(B = C_D A/m\). I call \(B\) the ballistic coefficient; the more common convention gives that name to its inverse, \(m/(C_D A)\), so read a larger \(B\) here as a faster fall. For the station, the round values \(m\) = 420,000 kg, \(A\) = 1,500 m², and \(C_D\) = 2.2 give \(B\) = 0.0079 m²/kg.

In a circular orbit gravity supplies exactly the pull toward the center, \(v^2/a = \mu/a^2\), with \(a\) the radius of the orbit and \(\mu = GM\) the Earth's gravitational parameter. So \(v = \sqrt{\mu/a}\), and a lower orbit is a faster orbit. Per kilogram, the kinetic, potential, and total energies are

\[K = \frac{\mu}{2a}, \qquad U = -\frac{\mu}{a}, \qquad E = K + U = -\frac{\mu}{2a} = -K.\]

Drag takes energy out, so \(E\) falls. Because \(E = -K\), the kinetic energy rises by exactly what drag took, and the potential energy pays for both by falling twice as far. The cell checks this, then the same balance as forces.

print(" h / km    v / (km/s)   K / (MJ/kg)   U / (MJ/kg)   E / (MJ/kg)")
for h_km in [400, 300, 200, 100]:
    a, v, K, U = circular(h_km)
    print(f"{h_km:6d}    {v / 1e3:9.3f}    {K / 1e6:10.3f}    {U / 1e6:10.3f}    {(K + U) / 1e6:10.3f}")
(_, _, K4, U4), (_, _, K3, U3) = circular(400), circular(300)
print(f"400 -> 300 km: dK = {(K3 - K4) / 1e6:+.2f}, dU = {(U3 - U4) / 1e6:+.2f}, "
      f"dE = {(K3 + U3 - K4 - U4) / 1e6:+.2f} MJ/kg")

a, v, _, _ = circular(400)
rho = density(400e3, "quiet")
drag = 0.5 * rho * B * v**2                    # m/s², along -v
dadt = 0.5 * rho * B * v**3 / (MU / (2 * a**2))  # m/s: energy lost per second over dE/da, energy per meter
gamma = dadt / v                               # rad, the downward tilt of the path
print(f"at 400 km, quiet Sun: drag {drag:.2e} m/s², path tilted down by {gamma:.1e} rad, "
      f"gravity along the path {MU / a**2 * np.sin(gamma):.2e} m/s² = {MU / a**2 * np.sin(gamma) / drag:.3f} x drag")
 h / km    v / (km/s)   K / (MJ/kg)   U / (MJ/kg)   E / (MJ/kg)
   400        7.673        29.434       -58.869       -29.434
   300        7.730        29.876       -59.751       -29.876
   200        7.788        30.330       -60.661       -30.330
   100        7.848        30.799       -61.598       -30.799
400 -> 300 km: dK = +0.44, dU = -0.88, dE = -0.44 MJ/kg
at 400 km, quiet Sun: drag 1.69e-07 m/s², path tilted down by 3.9e-08 rad, gravity along the path 3.38e-07 m/s² = 2.000 x drag

From 400 to 300 km drag takes 0.44 MJ/kg, the kinetic energy gains 0.44, and the potential energy drops 0.88. Every kilogram of the station ends up faster for having been braked.

As forces: the sinking path tilts down by a tiny angle \(\gamma\), the sink rate over the speed, so gravity, \(g = \mu/a^2\), pulls along it with \(g\sin\gamma\). The cell finds the sink rate as the energy drag takes per second, \(\tfrac12\rho B v^3\), over the energy per meter of radius, \(dE/da = \mu/2a^2\). At 400 km \(\gamma\) is \(3.9 \times 10^{-8}\) rad, and \(g\sin\gamma\) is \(3.4 \times 10^{-7}\) m/s², twice the drag of \(1.7 \times 10^{-7}\): half cancels the drag, half speeds the station up. Physics teaching knows this as the satellite orbit paradox, though it stops being one once you count the energy.

Free account

The second half of this tutorial needs a free account

Sign in, or create an account with your email address, and the page goes on right here. The account costs nothing and also brings the notebook download, collections, and done marks.

Still to come

  1. The lower it gets, the faster it falls
  2. An active Sun brings it down years early
  3. How the game computes it
  4. Variations
  5. Further reading

Was this tutorial helpful? Sign in to tell the author with one click.

Found a mistake, or something unclear? Report a problem (with a free account).

Cite this tutorial

SciStack (2026). Orbital decay: why drag makes a satellite fly faster as it falls. https://scistack.dev/t/py-orbital-decay/ (accessed 2026-10-20).

@online{scistack-py-orbital-decay,
  author  = {{SciStack}},
  title   = {Orbital decay: why drag makes a satellite fly faster as it falls},
  date    = {2026-10-20},
  url     = {https://scistack.dev/t/py-orbital-decay/},
  urldate = {2026-10-20},
  note    = {numpy 2.4.3, scipy 1.18.1, pymsis 0.13.0, matplotlib 3.11.2}
}

Tags

atmospheric-dragcanvasjavascriptmatplotlibnrlmsisnumpyorbital-decayorbital-mechanicspymsisscipy.integratesolve_ivpvelocity-verlet

Comments

No comments yet.

Sign in to comment, with a free account.