Skip to content
SciStack
Minigame Python Beginner 25 min

Orbital plane change: a 28.5° turn costs almost as much as the climb to GEO

Afterwards you can compute the Δv of a plane change, fold it into a Hohmann burn at apogee, and explain why GEO launch sites sit near the equator.

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

py-plane-change.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 pooch==1.9.0 scipy==1.18.1 matplotlib==3.11.2 jupyterlab

Play: turn a capsule's orbit onto the equator

A rocket launched due east from Cape Canaveral leaves its capsule circling 400 km up at 7.67 km/s, on a ring tilted 28.5° to the equator. The capsule is meant for the geostationary ring (GEO), flat on the equator and 42,164 km from the Earth's center, where a satellite keeps pace with the turning Earth; the game draws it as the big flat ring. The side view looks along the line where the tilted ring crosses the equator, so the tilt shows as an angle, and the top view looks down on the north pole. Sideways is a push in m/s that turns the ring; the slower the capsule, the further a push turns it, so the label gives the angle at the next equator crossing, 28.5° for the starting 3,775 m/s. Forward changes the speed. Burn at equator fires both the next time the capsule crosses the equator. Warp speeds up time; Pause, Play, and Reset do what they say. Press Burn at equator and watch the ring tip flat, while the Δv counter reads 3.78 km/s against the 3.85 km/s of the climb to GEO alone. Then reset and try to reach the big flat ring for less.

Download the game

Show code
// game.js: turn a capsule's orbit onto the equator with one burn and count what it costs.
// Units: km, km/s, s. Earth-centered axes that do not turn: x along the line where the
// starting ring crosses the equator, z toward the north pole. The notebook writes the
// constants, the start, the control ranges, the launch sites, and the land into #data.
"use strict";

const DATA = JSON.parse(document.getElementById("data").textContent);
const {mu: MU, r_e: R_E, r_geo: R_GEO, omega_e: OMEGA_E, theta0: THETA0} = DATA;
const DEG = Math.PI / 180;

// ---- small vector helpers
const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
const scale = (a, k) => [a[0] * k, a[1] * k, a[2] * k];
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
const len = (a) => Math.hypot(a[0], a[1], a[2]);
const unit = (a) => scale(a, 1 / len(a));

// ---- the orbit model
function circular(r, i, u) {                              // on a circle of radius r, inclination i, u from the node
  const v = Math.sqrt(MU / r);
  return {r: [r * Math.cos(u), r * Math.sin(u) * Math.cos(i), r * Math.sin(u) * Math.sin(i)],
    v: [-v * Math.sin(u), v * Math.cos(u) * Math.cos(i), v * Math.cos(u) * Math.sin(i)]};
}

function elements(r, v) {                                 // angular momentum, eccentricity vector, semi-latus rectum
  const h = cross(r, v);
  const e = sub(scale(cross(v, h), 1 / MU), unit(r));
  return {h, e, p: dot(h, h) / MU};
}

const incl = (r, v) => Math.acos(cross(r, v)[2] / len(cross(r, v)));   // cos i = h_z / |h|

function nextNode(s) {                                    // where the capsule next crosses the equator
  const {h, e, p} = elements(s.r, s.v);
  const n = cross([0, 0, 1], h);                          // toward the ascending node
  if (len(n) < 1e-9 * len(h)) return {pos: null, vh: len(h) / len(s.r)};
  const north = Math.abs(s.r[2]) < 1 ? s.v[2] > 0 : s.r[2] > 0;   // north of the equator: next node descending
  const dir = scale(unit(n), north ? -1 : 1);
  const r = p / (1 + dot(e, dir));
  return {pos: scale(dir, r), vh: len(h) / r};            // the horizontal speed there is h / r
}

const turnAngle = (side, vh) => 2 * Math.asin(Math.min(1, Math.abs(side) / (2 * vh)));

// The burn: turn the horizontal velocity about the radius at constant speed, add the forward
// change, and charge the length of the one push from the old velocity to the new.
function burn(s) {
  const rh = unit(s.r), vr = dot(s.v, rh);
  const vhVec = sub(s.v, scale(rh, vr)), vh = len(vhVec), t = scale(vhVec, 1 / vh);
  const east = unit(cross([0, 0, 1], rh));
  const toward = dot(cross(t, east), rh) < 0 ? -1 : 1;    // the sense of turn that brings t toward east
  const a = toward * Math.sign(s.side) * turnAngle(s.side / 1000, vh);
  const tNew = add(scale(t, Math.cos(a)), scale(cross(rh, t), Math.sin(a)));
  const vNew = add(scale(rh, vr), scale(tNew, vh + s.fwd / 1000));
  s.dv += len(sub(vNew, s.v));
  s.ghost = ring(s);
  s.v = vNew;
  s.armed = false;
}

// ---- velocity Verlet: half a kick, a drift, half a kick
const accel = (r) => scale(r, -MU / len(r) ** 3);
const stepSize = (s) => Math.min(30, 0.001 * len(s.r) / len(s.v));   // s; 6,300 steps per low orbit

function verlet(s, dt) {
  s.v = add(s.v, scale(accel(s.r), dt / 2));
  s.r = add(s.r, scale(s.v, dt));
  s.v = add(s.v, scale(accel(s.r), dt / 2));
  s.t += dt;
}

function step(s) {
  const dt = stepSize(s), z0 = s.r[2], before = {r: s.r, v: s.v, t: s.t};
  verlet(s, dt);
  const z1 = s.r[2];
  if (s.armed && z0 !== 0 && Math.sign(z1) !== Math.sign(z0)) {
    const toNode = dt * z0 / (z0 - z1);                   // redo the step in two parts, stopping on the equator
    Object.assign(s, before);
    verlet(s, toNode);
    burn(s);
    verlet(s, dt - toNode);
  }
  if (len(s.r) < R_E) Object.assign(s, {running: false, crashed: true});
}

function tick(s, dtReal) {
  const end = s.t + Math.min(dtReal, 0.05) * DATA.warps[s.warp];   // the cap stops jumps
  while (s.running && s.t < end) step(s);
}

// ---- the state: everything that changes, in one object
function fresh() {
  const c = circular(R_E + DATA.start.alt, DATA.start.inc * DEG, DATA.start.u * DEG);
  return {r: c.r, v: c.v, t: 0, warp: DATA.warp_start, side: DATA.side.preset, fwd: DATA.fwd.preset,
    armed: false, dv: 0, ghost: null, running: true, crashed: false, zoom: null};
}
const state = fresh();

function ring(s, n = 240) {                               // points of the current orbit, from its elements
  const {h, e, p} = elements(s.r, s.v), ecc = len(e);
  const P = ecc > 1e-6 ? unit(e) : unit(s.r), Q = cross(unit(h), P);
  const nuMax = ecc < 1 ? Math.PI : 0.97 * Math.acos(-1 / ecc);
  const pts = [];
  for (let k = 0; k <= n; k++) {
    const nu = -nuMax + (2 * nuMax * k) / n, r = p / (1 + ecc * Math.cos(nu));
    pts.push(add(scale(P, r * Math.cos(nu)), scale(Q, r * Math.sin(nu))));
  }
  return pts;
}

function zoomTarget(s) {                                  // half the view: 20 % beyond the farthest point
  const {e, p} = elements(s.r, s.v), ecc = len(e);
  const far = ecc < 1 ? p / (1 - ecc) : Infinity;
  return Math.min(160000, Math.max(7800, 1.2 * far));
}

// ---- drawing
const $ = (id) => document.getElementById(id);
const color = (name) => getComputedStyle(document.documentElement).getPropertyValue(`--${name}`).trim();
const FONT = "14px system-ui, sans-serif";
const views = {side: {id: "side", name: "side view", proj: (p) => [p[1], p[2], p[0]]},   // looking along x
  top: {id: "top", name: "top view", proj: (p) => [p[0], p[1], p[2]]}};                  // from above the pole

function fit(v) {                                         // size a canvas to its box, sharp on any screen
  const c = $(v.id), box = c.parentElement.getBoundingClientRect(), dpr = window.devicePixelRatio || 1;
  c.width = Math.round(box.width * dpr);
  c.height = Math.round(box.height * dpr);
  v.ctx = c.getContext("2d");
  v.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  Object.assign(v, {w: box.width, h: box.height});
}

function layout() {
  const W = document.documentElement.clientWidth, H = document.documentElement.clientHeight;
  document.body.className = H > W ? "upright" : "wide";
  fit(views.side);
  fit(views.top);
  render();
}

function line(ctx, pts, style, width, dash = []) {
  ctx.beginPath();
  let pen = false;
  for (const p of pts) {                                  // null breaks the line
    if (!p) { pen = false; continue; }
    if (pen) ctx.lineTo(p[0], p[1]); else ctx.moveTo(p[0], p[1]);
    pen = true;
  }
  ctx.strokeStyle = style;
  ctx.lineWidth = width;
  ctx.setLineDash(dash);
  ctx.stroke();
  ctx.setLineDash([]);
}

function disk(ctx, [x, y], r, fill, stroke) {
  ctx.beginPath();
  ctx.arc(x, y, r, 0, 2 * Math.PI);
  if (fill) { ctx.fillStyle = fill; ctx.fill(); }
  if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = 2; ctx.stroke(); }
}

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

const onEarth = (lon, lat, theta) => {                    // a point on the turning globe, in the fixed axes
  const l = (lon * DEG) + theta, b = lat * DEG;
  return [R_E * Math.cos(b) * Math.cos(l), R_E * Math.cos(b) * Math.sin(l), R_E * Math.sin(b)];
};

function drawView(v, c) {
  const {ctx, w, h} = v, k = Math.min(w, h - 24) / (2 * state.zoom);   // px per km; room for the name
  const cx = w / 2, cy = 24 + (h - 24) / 2;
  const at = (p) => { const q = v.proj(p); return [cx + q[0] * k, cy - q[1] * k]; };
  const hidden = (p) => { const q = v.proj(p); return q[2] < 0 && q[0] ** 2 + q[1] ** 2 < R_E ** 2; };
  const visible = (pts) => pts.map((p) => (hidden(p) ? null : at(p)));
  ctx.clearRect(0, 0, w, h);
  const theta = THETA0 + OMEGA_E * state.t;

  // the equator and the GEO ring, flat on it
  if (v.id === "side") {
    line(ctx, [[0, cy], [w, cy]], c.muted, 1);
    line(ctx, [at([0, -R_GEO, 0]), at([0, R_GEO, 0])], c.second, 2);
  } else {
    line(ctx, [[0, cy], [w, cy]], c.muted, 1, [4, 4]);    // where the starting ring crosses the equator
    const geo = [];
    for (let a = 0; a <= 360; a += 3) geo.push(at([R_GEO * Math.cos(a * DEG), R_GEO * Math.sin(a * DEG), 0]));
    line(ctx, geo, c.second, 2);
  }
  // the globe, its land on the near side, and the launch sites
  disk(ctx, [cx, cy], R_E * k, c.globe);
  ctx.globalAlpha = 0.5;                                  // rim and land faint, so a ring close to the rim stands out
  ctx.beginPath();
  ctx.arc(cx, cy, R_E * k, 0, 2 * Math.PI);
  ctx.strokeStyle = c.ink;
  ctx.lineWidth = 1;
  ctx.stroke();
  for (const coast of DATA.land) {
    const pts = [];
    for (let j = 0; j < coast.length; j += 2) {
      const p = onEarth(coast[j], coast[j + 1], theta);
      pts.push(v.proj(p)[2] < 0 ? null : at(p));
    }
    line(ctx, pts, c.ink, 0.8);
  }
  ctx.globalAlpha = 1;
  for (const site of DATA.sites) {
    const p = onEarth(site.lon, site.lat, theta);
    if (v.proj(p)[2] >= 0) disk(ctx, at(p), 3, site.name === DATA.active_site ? c.ink : c.muted);
  }
  // the ring before the last burn, the ring now, the capsule, and the armed burn
  if (state.ghost) line(ctx, visible(state.ghost), c.muted, 1.2);
  line(ctx, visible(ring(state)), c.accent, 2);
  if (state.armed) {
    const node = nextNode(state).pos;
    if (node) {
      const [x, y] = at(node);
      disk(ctx, [x, y], 8, null, c.accent);
      label(ctx, "burn here", x + 11, y - 8, c.accent);
    }
  }
  // the capsule: a dot ringed in the background color, larger than a site; behind the globe only its outline
  if (hidden(state.r)) disk(ctx, at(state.r), 4.5, null, c.fg);
  else disk(ctx, at(state.r), 5.5, c.fg, c.bg);
  label(ctx, v.name, 6, 16, c.fg);
  if (v.id === "side") label(ctx, "GEO ring, edge on", 6, h - 6, c.second);
  else if (state.zoom < R_GEO) label(ctx, "GEO ring off view", 6, h - 6, c.second);
}

const fmt = (x, digits = 0) => x.toLocaleString("en-US", {minimumFractionDigits: digits, maximumFractionDigits: digits});

function render() {
  if (state.zoom === null) state.zoom = zoomTarget(state);
  const c = Object.fromEntries(["bg", "fg", "ink", "accent", "second", "muted", "globe"].map((n) => [n, color(n)]));
  drawView(views.side, c);
  drawView(views.top, c);
  $("incl").textContent = `${(incl(state.r, state.v) / DEG).toFixed(2)}°`;
  $("speed").textContent = `${len(state.v).toFixed(2)} km/s`;
  $("alt").textContent = state.crashed ? "hit the Earth" : `${fmt(len(state.r) - R_E)} km up`;
  $("dv").textContent = `${state.dv.toFixed(2)} km/s`;
  $("climb").textContent = `${DATA.climb.toFixed(2)} km/s`;
  $("clock").textContent = `t ${(state.t / 3600).toFixed(1)} h`;
  const node = nextNode(state);
  $("side").value = state.side;
  $("fwd").value = state.fwd;
  $("side-val").textContent = `${fmt(state.side)} m/s · turns ${(turnAngle(state.side / 1000, node.vh) / DEG).toFixed(1)}°`;
  $("fwd-val").textContent = `${state.fwd > 0 ? "+" : ""}${fmt(state.fwd)} m/s`;
  $("burn").textContent = state.armed ? "armed" : "Burn at equator";
  $("burn").classList.toggle("armed", state.armed);
  $("go").textContent = state.running ? "Pause" : "Play";
  $("warp-val").textContent = `${fmt(DATA.warps[state.warp])}×`;
}

// ---- controls, keys, and the loop
function set(partial) {
  Object.assign(state, partial);
  if (!("zoom" in partial)) state.zoom = zoomTarget(state);   // a set jumps to the new size
  render();
}
const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
const nudge = (key, sign) => set({[key]: clamp(state[key] + sign * DATA[key].step, DATA[key].min, DATA[key].max)});
const nudgeWarp = (sign) => set({warp: clamp(state.warp + sign, 0, DATA.warps.length - 1)});
const arm = () => { if (!state.crashed) set({armed: !state.armed}); };
const go = () => { if (!state.crashed) set({running: !state.running}); };
const reset = () => set(fresh());

for (const key of ["side", "fwd"]) {
  const slider = $(key);
  Object.assign(slider, {min: DATA[key].min, max: DATA[key].max, step: 1});
  slider.addEventListener("input", () => set({[key]: Number(slider.value)}));
  $(`${key}-minus`).addEventListener("click", () => nudge(key, -1));
  $(`${key}-plus`).addEventListener("click", () => nudge(key, 1));
}
$("burn").addEventListener("click", arm);
$("go").addEventListener("click", go);
$("warp-minus").addEventListener("click", () => nudgeWarp(-1));
$("warp-plus").addEventListener("click", () => nudgeWarp(1));
$("reset").addEventListener("click", reset);
document.addEventListener("keydown", (e) => {
  const actions = {b: arm, " ": go, "[": () => nudgeWarp(-1), "]": () => nudgeWarp(1), r: reset};
  const act = actions[e.key.toLowerCase()];
  if (act && !(e.target instanceof HTMLButtonElement && e.key === " ") && !(e.target instanceof HTMLInputElement)) {
    e.preventDefault();
    act();
  }
});

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);
  state.zoom += (zoomTarget(state) - state.zoom) * Math.min(1, 3 * dt);   // ease toward the new size in about 1 s
  render();
  requestAnimationFrame(frame);
}

window.addEventListener("resize", layout);
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", render);
layout();
requestAnimationFrame(frame);
window.game = {state, set, render};

Turning the plane is turning the velocity

One sideways burn of 3.78 km/s where the capsule crosses the equator, and the ring lies flat. The speed readout still says 7.67 km/s, and the counter has spent almost the whole climb to GEO. The stills come from the game's model, ported to Python in the hidden cell below, and the first shows both views before and after the burn.

Show code
import json
import warnings
from pathlib import Path

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Arc
warnings.filterwarnings("ignore", message="IProgress not found")    # tqdm, imported by pooch, wants ipywidgets
import pooch
from scipy.integrate import solve_ivp

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"

MU = 398600.4418                      # km³/s², WGS 84
R_E = 6378.137                        # km, equatorial radius; altitudes are measured from it
R_GEO = 42164.17                      # km from the Earth's center
OMEGA_E = 2 * np.pi / 86164.0905      # rad/s, one turn per sidereal day
DEG = np.pi / 180
R_LEO, INC = R_E + 400, 28.5          # the parking orbit: km, degrees

# Launch pads, latitude and longitude in degrees. Kourou ELA-3: Arianespace, Ariane 5 User's Manual,
# Issue 5 Rev. 2 (2016), 5°13'56" N, 52°46'32" W. Cape Canaveral: 28.5°, the conventional value, at the
# longitude of SLC-40 (Wikipedia: SLC-40 at 28.56° N, 80.58° W; LC-39A at 28.61° N).
# Baikonur Site 1, Gagarin's Start (Wikipedia: 45°55'13" N, 63°20'32" E).
SITES = [dict(name="Kourou", lat=5.23, lon=-52.78),
         dict(name="Cape Canaveral", lat=28.5, lon=-80.58),
         dict(name="Baikonur", lat=45.92, lon=63.34)]

# Natural Earth 1:110m land, v5.1.2, public domain, 0.14 MB: the coastlines on the globe.
pooch.get_logger().setLevel("WARNING")
path = pooch.retrieve(
    "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/ne_110m_land.geojson",
    known_hash="sha256:9e0729ee253ca7d7a5c4ae9395fb1902264c5377c52e224d13dd85010e2835d9",
)
land = []                                                   # rings of (lon, lat) in degrees
with open(path, encoding="utf-8") as f:
    for feature in json.load(f)["features"]:
        geometry = feature["geometry"]
        polygons = geometry["coordinates"] if geometry["type"] == "MultiPolygon" else [geometry["coordinates"]]
        for polygon in polygons:
            kept = [polygon[0][0]]
            for p in polygon[0][1:]:                         # thin: a point at least 1° from the last one kept
                if np.hypot(p[0] - kept[-1][0], p[1] - kept[-1][1]) >= 1.0:
                    kept.append(p)
            if len(kept) >= 4:
                land.append(np.round(np.array(kept + [kept[0]]), 1))

# ---- the game's model, same names as in game.js
def unit(a):
    return a / np.linalg.norm(a)

def circular(r, i, u):
    """The state on a circle of radius r, inclination i, at angle u from the ascending node (radians)."""
    v = np.sqrt(MU / r)
    return (np.array([r * np.cos(u), r * np.sin(u) * np.cos(i), r * np.sin(u) * np.sin(i)]),
            np.array([-v * np.sin(u), v * np.cos(u) * np.cos(i), v * np.cos(u) * np.sin(i)]))

def incl(r, v):
    """Inclination in degrees, from cos i = h_z / |h|."""
    h = np.cross(r, v)
    return np.degrees(np.arccos(h[2] / np.linalg.norm(h)))

def elements(r, v):
    """Angular momentum h, eccentricity vector e, and semi-latus rectum p."""
    h = np.cross(r, v)
    return h, np.cross(v, h) / MU - unit(r), h @ h / MU

def turn_burn(r, v, side, fwd):
    """The game's burn: turn v_h about r toward the equator by α = 2 arcsin(side / 2v_h), add fwd. km/s."""
    rh = unit(r)
    vr = v @ rh
    t = unit(v - vr * rh)
    vh = np.linalg.norm(v - vr * rh)
    east = unit(np.cross([0.0, 0.0, 1.0], rh))
    toward = -1 if np.cross(t, east) @ rh < 0 else 1
    a = toward * np.sign(side) * 2 * np.arcsin(min(1.0, abs(side) / (2 * vh)))
    v_new = vr * rh + (vh + fwd) * (t * np.cos(a) + np.cross(rh, t) * np.sin(a))
    return v_new, np.linalg.norm(v_new - v)

def ring(r, v, n=240):
    """Points of the orbit through (r, v), from its elements."""
    h, e, p = elements(r, v)
    ecc = np.linalg.norm(e)
    P = unit(e) if ecc > 1e-6 else unit(r)
    Q = np.cross(unit(h), P)
    nu = np.linspace(-np.pi, np.pi, n + 1)
    rad = p / (1 + ecc * np.cos(nu))
    return (np.outer(rad * np.cos(nu), P) + np.outer(rad * np.sin(nu), Q)).T

def vis_viva(r, a):
    return np.sqrt(MU * (2 / r - 1 / a))

def hohmann(r1, r2):
    """The two burns from circle r1 to circle r2, and the speeds at perigee and apogee of the ellipse."""
    a = (r1 + r2) / 2
    vp, va = vis_viva(r1, a), vis_viva(r2, a)
    return vp - np.sqrt(MU / r1), np.sqrt(MU / r2) - va, vp, va

def rhs(t, y):
    return np.r_[y[3:], -MU * y[:3] / np.linalg.norm(y[:3]) ** 3]

def node(t, y):                                             # the capsule crosses the equator
    return y[2]
node.terminal = True

TOL = dict(method="DOP853", rtol=1e-11, atol=1e-9)

# ---- the game's loop, ported line by line
def fresh(inc=INC):
    r, v = circular(R_LEO, inc * DEG, -90 * DEG)            # a quarter orbit before the ascending node
    return dict(r=r, v=v, t=0.0, side=0.0, fwd=0.0, armed=False, dv=0.0, steps=0, burns=[])

def burn(s):
    s["v"], dv = turn_burn(s["r"], s["v"], s["side"] / 1000, s["fwd"] / 1000)
    s["dv"] += dv
    s["armed"] = False
    s["burns"].append(dict(t=s["t"], dv=dv, incl=incl(s["r"], s["v"]), z=s["r"][2]))

def step_size(s):
    return min(30.0, 0.001 * np.linalg.norm(s["r"]) / np.linalg.norm(s["v"]))

def verlet(s, dt):
    s["v"] = s["v"] - dt / 2 * MU * s["r"] / np.linalg.norm(s["r"]) ** 3
    s["r"] = s["r"] + dt * s["v"]
    s["v"] = s["v"] - dt / 2 * MU * s["r"] / np.linalg.norm(s["r"]) ** 3
    s["t"] += dt

def step(s):
    dt, z0, before = step_size(s), s["r"][2], (s["r"], s["v"], s["t"])
    verlet(s, dt)
    z1 = s["r"][2]
    if s["armed"] and z0 != 0 and np.sign(z1) != np.sign(z0):
        to_node = dt * z0 / (z0 - z1)                       # redo the step in two parts, stopping on the equator
        s["r"], s["v"], s["t"] = before
        verlet(s, to_node)
        burn(s)
        verlet(s, dt - to_node)
    s["steps"] += 1

V_LEO, V_GEO = np.sqrt(MU / R_LEO), np.sqrt(MU / R_GEO)
T_LEO = 2 * np.pi * np.sqrt(R_LEO**3 / MU)
THETA0 = (90 - SITES[1]["lon"]) * DEG + OMEGA_E * T_LEO / 2  # the Cape under the ring's top half an orbit ago
print(f"v_LEO = {V_LEO:.3f} km/s at {R_LEO - R_E:.0f} km, v_GEO = {V_GEO:.3f} km/s at {R_GEO:,.0f} km, "
      f"land: {len(land)} rings, {sum(map(len, land)):,} points")
v_LEO = 7.669 km/s at 400 km, v_GEO = 3.075 km/s at 42,164 km, land: 93 rings, 2,864 points
Show code
VIEWS = {"side view": (1, 2, 0), "top view": (0, 1, 2)}    # screen x, screen y, toward the viewer

def draw_globe(ax, view, theta):
    """The Earth with its land on the near side, turned by theta."""
    ix, iy, iz = VIEWS[view]
    ax.add_patch(plt.Circle((0, 0), R_E / 1e3, facecolor="#ebe9e4", edgecolor="none", zorder=2))   # no rim: a low ring hugs it
    for coast in land:
        lon, lat = np.radians(coast[:, 0]) + theta, np.radians(coast[:, 1])
        p = R_E / 1e3 * np.array([np.cos(lat) * np.cos(lon), np.cos(lat) * np.sin(lon), np.sin(lat)])
        x, y = p[ix].copy(), p[iy].copy()
        x[p[iz] < 0] = np.nan
        ax.plot(x, y, color=INK, lw=0.5, alpha=0.35, zorder=3)

def draw_ring(ax, view, pts, **kw):
    """An orbit, with the part behind the globe left out."""
    ix, iy, iz = VIEWS[view]
    x, y = pts[ix] / 1e3, pts[iy] / 1e3
    hidden = (pts[iz] < 0) & (x**2 + y**2 < (R_E / 1e3) ** 2)
    x, y = np.where(hidden, np.nan, x), np.where(hidden, np.nan, y)
    ax.plot(x, y, zorder=4, **kw)

def two_views(extent, figsize=(7, 3.6)):
    fig, axes = plt.subplots(1, 2, figsize=figsize)
    for ax, view in zip(axes, VIEWS):
        ax.set(xlim=(-extent, extent), ylim=(-extent, extent), aspect="equal",
               xlabel="y / 1000 km" if view == "side view" else "x / 1000 km",
               ylabel="z / 1000 km" if view == "side view" else "y / 1000 km")
        ax.text(0.02, 0.98, view, transform=ax.transAxes, va="top", color=INK)
    return fig, axes

r_node, v_node = circular(R_LEO, INC * DEG, 0.0)
v_flat, dv_turn = turn_burn(r_node, v_node, 2 * V_LEO * np.sin(INC / 2 * DEG), 0.0)
theta_burn = THETA0 + OMEGA_E * T_LEO / 4
fig, axes = two_views(9)
for ax, view in zip(axes, VIEWS):
    ax.axhline(0, color=MUTED, lw=1, ls="--" if view == "top view" else "-", zorder=1)
    draw_globe(ax, view, theta_burn)
    draw_ring(ax, view, ring(r_node, v_node), color=MUTED, lw=1.6)
    draw_ring(ax, view, ring(r_node, v_flat), color=ACCENT)
    ix, iy, _ = VIEWS[view]
    ax.plot(r_node[ix] / 1e3, r_node[iy] / 1e3, "o", ms=6, color=INK, zorder=5)
axes[0].text(-0.4, 0.5, "burn here", color=INK, ha="right", va="bottom", zorder=6)
axes[0].text(6.6, -0.45, "after", color=ACCENT, ha="right", va="top", zorder=6)
axes[0].text(-6.2, -3.5, "before", color=MUTED, ha="right", va="top", zorder=6)
axes[1].text(R_LEO / 1e3 - 0.5, 0.4, "burn here", color=INK, ha="right", va="bottom", zorder=6)
for ax in axes:
    ax.set(xticks=[-5, 0, 5], yticks=[-5, 0, 5])
axes[0].add_patch(Arc((0, 0), 16, 16, theta1=0, theta2=INC, color=MUTED, lw=1.2))
axes[0].text(8.3 * np.cos(np.radians(INC / 2)), 8.3 * np.sin(np.radians(INC / 2)), f"{INC}°", color=MUTED, va="center")
fig.tight_layout()
plt.show()
Side view and top view of the Earth, in thousands of km. Before the burn the ring is tilted 28.5° to the equator (gray); after one sideways burn at the crossing point it lies flat on the equator (red). In the top view the tilted ring is foreshortened to an ellipse.

The two points where the ring crosses the equator are the nodes, the ascending node where the capsule moves north and the descending node where it moves south, and the line through them is the line of nodes. An orbit's plane holds the position and the velocity. At a node the position lies on the equator already, so turning the plane means turning the velocity about the radius vector. The sideways slider does that: it turns the velocity and keeps the speed. The counter charges the length of the one push that takes the old velocity to the new one.

Old and new velocity have the same length \(v\) and differ by the angle \(\Delta i\), so they form an isosceles triangle whose base is the push:

\[\Delta v = 2v\sin\frac{\Delta i}{2}.\]

At 60° the triangle is equilateral and the push equals the orbital speed.

Show code
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(7, 3.4), gridspec_kw=dict(width_ratios=[1, 1.4]))
a = np.radians(INC)
v_old, v_new = V_LEO * np.array([np.cos(a), np.sin(a)]), V_LEO * np.array([1.0, 0.0])
for vec in [v_old, v_new]:
    ax1.annotate("", vec, xytext=(0, 0), arrowprops=dict(arrowstyle="-|>", color=INK, lw=1.8, shrinkA=0, shrinkB=0))
ax1.annotate("", v_new, xytext=v_old, arrowprops=dict(arrowstyle="-|>", color=ACCENT, lw=1.8, shrinkA=0, shrinkB=0))
ax1.text(*(v_old * 0.55 + [-0.3, 0.35]), "v before", color=INK, ha="right")
ax1.text(3.8, -0.45, "v after", color=INK, ha="center", va="top")
ax1.text(*((v_old + v_new) / 2 + [0.15, 0.1]), f"Δv = {dv_turn:.2f}", color=ACCENT, ha="left")
ax1.text(2.0, 0.42, f"{INC}°", color=INK)
ax1.set(xlim=(-0.5, 9.0), ylim=(-1.3, 4.5), aspect="equal", xlabel="v$_\\parallel$ / (km/s)", ylabel="v$_\\perp$ / (km/s)")
di = np.linspace(0, 90, 181)
climb = sum(hohmann(R_LEO, R_GEO)[:2])
ax2.plot(di, 2 * V_LEO * np.sin(np.radians(di) / 2), color=ACCENT)
ax2.axhline(climb, color=SECOND, lw=1, ls="--")
ax2.text(88, climb + 0.2, f"climb to GEO, {climb:.2f}", color=SECOND, ha="right", va="bottom")
for d, text in [(INC, f"{dv_turn:.2f}"), (60, f"{V_LEO:.2f} = orbital speed")]:
    y = 2 * V_LEO * np.sin(np.radians(d) / 2)
    ax2.plot(d, y, "o", ms=6, color=ACCENT)
    ax2.text(d + 3, y - 0.15, text, color=ACCENT, va="top")
ax2.set(xlim=(0, 90), ylim=(0, 11), xlabel="Δi / degrees", ylabel="Δv / (km/s)")
fig.tight_layout()
plt.show()
Left: the velocity before and after the turn, both 7.67 km/s, 28.5° apart, and the push Δv along the base of the triangle. Right: Δv in km/s against the turn angle in degrees. The curve reaches 3.78 km/s at 28.5°, just under the 3.85 km/s climb to GEO, and 7.67 km/s, the orbital speed, at 60°.

The closed form, and the burn flown by solve_ivp. Its right-hand side rhs is Newton's law for the capsule, \(\ddot{\mathbf r} = -\mu\mathbf r/r^3\), written as six first-order equations for position and velocity. An event is a function of the state whose zero solve_ivp locates: node returns \(z\), the height above the equator, and because it is marked terminal the run stops at the crossing, whose time and state land in t_events and y_events. The run starts a quarter orbit before the ascending node, stops there for the burn, and goes on for one orbit:

for d in [INC, 60]:
    print(f"turn by {d:4.1f}° at {V_LEO:.3f} km/s:  Δv = 2 v sin(Δi/2) = {2 * V_LEO * np.sin(d / 2 * DEG):.3f} km/s")

r0, v0 = circular(R_LEO, INC * DEG, -90 * DEG)
before = solve_ivp(rhs, (0, T_LEO), np.r_[r0, v0], events=node, **TOL)
t_n, y_n = before.t_events[0][0], before.y_events[0][0]
v_turned, dv = turn_burn(y_n[:3], y_n[3:], 2 * V_LEO * np.sin(INC / 2 * DEG), 0.0)
after = solve_ivp(rhs, (t_n, t_n + T_LEO), np.r_[y_n[:3], v_turned], **TOL)
y_end = after.y[:, -1]
print(f"solve_ivp: node after {t_n / 60:.1f} min, inclination {incl(y_n[:3], y_n[3:]):.3f}° -> "
      f"{incl(y_end[:3], y_end[3:]):.1e}° one orbit later, push {dv:.3f} km/s")
print(f"speed {np.linalg.norm(y_n[3:]):.3f} km/s before, {np.linalg.norm(y_end[3:]):.3f} km/s after")
turn by 28.5° at 7.669 km/s:  Δv = 2 v sin(Δi/2) = 3.775 km/s
turn by 60.0° at 7.669 km/s:  Δv = 2 v sin(Δi/2) = 7.669 km/s
solve_ivp: node after 23.1 min, inclination 28.500° -> 0.0e+00° one orbit later, push 3.775 km/s
speed 7.669 km/s before, 7.669 km/s after

A turn of 28.5° costs 3.78 km/s, and 60° costs 7.67 km/s. The integration reaches the node after 23.1 min at 28.500°, and one orbit after the burn the inclination is zero to the last digit while the speed stays at 7.669 km/s.

Burning forward makes the capsule slower at the top

Set Sideways to 0 and Forward to 2,397 m/s, the first burn of the climb worked out below, and fire at the node. The ring stretches into a tilted ellipse whose far end touches the GEO ring. The capsule arrives at the top moving at only 1.62 km/s, slower than the 3.07 km/s of the GEO ring it has reached:

Show code
dv1, dv2, v_p, v_a = hohmann(R_LEO, R_GEO)
v_transfer = turn_burn(r_node, v_node, 0.0, dv1)[0]
fig, axes = two_views(50)
geo = R_GEO / 1e3 * np.array([np.cos(np.linspace(0, 2 * np.pi, 361)), np.sin(np.linspace(0, 2 * np.pi, 361)), 0 * np.ones(361)])
for ax, view in zip(axes, VIEWS):
    ax.axhline(0, color=MUTED, lw=1, ls="--" if view == "top view" else "-", zorder=1)
    draw_globe(ax, view, theta_burn)
    draw_ring(ax, view, ring(r_node, v_node), color=MUTED, lw=1.2)
    draw_ring(ax, view, geo * 1e3, color=SECOND)
    draw_ring(ax, view, ring(r_node, v_transfer), color=ACCENT)
axes[1].annotate(f"apogee\n{v_a:.2f} km/s", (-R_GEO / 1e3, 0), xytext=(-28, -24), ha="center", va="top", color=ACCENT,
                 arrowprops=dict(arrowstyle="-", color=ACCENT, lw=0.8, shrinkA=2, shrinkB=3))
axes[1].text(8, -6, f"perigee\n{v_p:.2f} km/s", ha="left", va="top", color=ACCENT)
axes[1].text(30, 33, "GEO", color=SECOND)
fig.tight_layout()
plt.show()
Side view and top view in thousands of km. The parking ring (gray) is a small circle around the Earth; the transfer ellipse (red), still tilted 28.5°, runs from the perigee burn at 10.07 km/s to the apogee on the flat GEO ring (blue), where the capsule moves at 1.62 km/s.

The top of the climb is the farthest point of the ellipse, the apogee; the nearest, where the burn happened, is the perigee. Climbing against gravity trades speed for height, as it does for a ball thrown upward. On a circle, gravity supplies exactly the centripetal force, \(\mu/r^2 = v^2/r\), so the circular speed is \(v = \sqrt{\mu/r}\) with \(\mu = GM\) of the Earth. On an ellipse the energy per kilogram, kinetic plus potential, stays fixed at \(-\mu/2a\), with \(a\) the semi-major axis, half the length of the ellipse. Solved for the speed this is the vis-viva equation:

\[v^2 = \mu\left(\frac{2}{r} - \frac{1}{a}\right).\]

For the ellipse from 400 km up to GEO, \(a = (r_\text{LEO} + r_\text{GEO})/2\). The capsule must speed up from its circular speed to \(v_p\) at perigee, and at apogee from \(v_a\) to the speed of the GEO ring. The check flies the ellipse with a second event, \(\mathbf r\cdot\mathbf v\), which passes from positive to negative (direction = -1) where the capsule stops climbing:

print(f"circular: v_LEO = {V_LEO:.2f} km/s, v_GEO = {V_GEO:.2f} km/s")
print(f"ellipse:  v_p = {v_p:.2f} km/s at perigee, v_a = {v_a:.2f} km/s at apogee")
print(f"burns:    {v_p:.2f} - {V_LEO:.2f} = {dv1:.2f},  {V_GEO:.2f} - {v_a:.2f} = {dv2:.2f},  climb = {dv1 + dv2:.2f} km/s")
a_t = (R_LEO + R_GEO) / 2
print(f"half the transfer orbit: {np.pi * np.sqrt(a_t**3 / MU) / 3600:.2f} h")
print(f"turn in low orbit plus climb, done separately: {dv_turn:.2f} + {dv1 + dv2:.2f} = {dv_turn + dv1 + dv2:.2f} km/s")

def apogee(t, y):                                           # radial velocity turns from outward to inward
    return y[:3] @ y[3:]
apogee.terminal, apogee.direction = True, -1
up = solve_ivp(rhs, (0, 1e5), np.r_[r_node, v_transfer], events=apogee, **TOL)
y_a = up.y_events[0][0]
print(f"solve_ivp at apogee after {up.t_events[0][0] / 3600:.2f} h: r = {np.linalg.norm(y_a[:3]):,.0f} km, "
      f"speed {np.linalg.norm(y_a[3:]):.3f} km/s")
circular: v_LEO = 7.67 km/s, v_GEO = 3.07 km/s
ellipse:  v_p = 10.07 km/s at perigee, v_a = 1.62 km/s at apogee
burns:    10.07 - 7.67 = 2.40,  3.07 - 1.62 = 1.46,  climb = 3.85 km/s
half the transfer orbit: 5.29 h
turn in low orbit plus climb, done separately: 3.78 + 3.85 = 7.63 km/s
solve_ivp at apogee after 5.29 h: r = 42,164 km, speed 1.618 km/s

The two burns are 2.40 and 1.46 km/s, together 3.85 km/s. This is the Hohmann transfer, the cheapest two-burn climb between two circles. The turn alone in low orbit, 3.78 km/s, costs almost as much, and done one after the other the two cost 7.63 km/s.

Far out, the turn is cheap

Fly the transfer; at the top, where the capsule crawls at 1.62 km/s, set Sideways to 797 m/s and Forward to 1,456 m/s for one more burn. The ring comes out flat and nearly round, just inside the GEO ring, and the counter adds only 1.82 km/s.

Show code
# the slider settings: Forward 2,397 m/s at the ascending node, then Sideways 797 and Forward 1,456 m/s at apogee
v_up = turn_burn(r_node, v_node, 0.0, 2.397)[0]
h_up, e_up, p_up = elements(r_node, v_up)
r_top = -unit(e_up) * p_up / (1 - np.linalg.norm(e_up))   # apogee, on the far side, at the descending node
v_top = np.linalg.norm(h_up) / np.linalg.norm(r_top) * np.cross(unit(h_up), unit(r_top))
v_round = turn_burn(r_top, v_top, 0.797, 1.456)[0]
theta_top = theta_burn + OMEGA_E * np.pi * np.sqrt((p_up / (1 - np.linalg.norm(e_up)**2))**3 / MU)
fig, axes = two_views(50)
for ax, view in zip(axes, VIEWS):
    ax.axhline(0, color=MUTED, lw=1, ls="--" if view == "top view" else "-", zorder=1)
    draw_globe(ax, view, theta_top)
    draw_ring(ax, view, ring(r_top, v_top), color=MUTED, lw=1.6)
    draw_ring(ax, view, ring(r_top, v_round), color=ACCENT)
axes[1].plot(r_top[0] / 1e3, r_top[1] / 1e3, "o", ms=6, color=INK, zorder=5)
axes[1].text(r_top[0] / 1e3 + 3, 3, "burn here", color=INK, ha="left", va="bottom", zorder=6)
axes[0].text(-12, -12, "before", color=MUTED, ha="right", va="top", zorder=6)
axes[0].text(40, -3, "after", color=ACCENT, ha="right", va="top", zorder=6)
axes[1].text(-18, -17, "before", color=MUTED, ha="center", va="top", zorder=6)
axes[1].text(48, 44, "after, on GEO", color=ACCENT, ha="right", va="bottom")
fig.tight_layout()
plt.show()
Side view and top view in thousands of km, at the apogee of the transfer. Before the burn the transfer ellipse is tilted 28.5° (gray); after one burn of Sideways 797 and Forward 1,456 m/s the ring is flat and round on the GEO ring (red).

Sideways 797 m/s turns a velocity of 1.62 km/s by 28.5°, and Forward 1,456 m/s raises the speed to 3.07 km/s. The counter charges the one push from old velocity to new, so the settings neither add (2.25) nor combine at right angles (1.66). The triangle of the turn in low orbit returns with two different sides, \(v_a\) and \(v_c\), the speed of the GEO ring:

\[\Delta v = \sqrt{v_a^2 + v_c^2 - 2 v_a v_c \cos\Delta i}.\]
v_c, d_i = V_GEO, INC * DEG
combined = np.sqrt(v_a**2 + v_c**2 - 2 * v_a * v_c * np.cos(d_i))
side_a = 2 * v_a * np.sin(d_i / 2)
print(f"at apogee: v_a = {v_a:.2f} km/s, v_c = {v_c:.2f} km/s; Sideways {1e3 * side_a:,.0f} m/s, Forward {1e3 * dv2:,.0f} m/s")
print(f"the settings added: {side_a + dv2:.2f} km/s, at right angles: {np.hypot(side_a, dv2):.2f} km/s")
print(f"circularize alone {dv2:.2f} km/s, with the turn {combined:.2f} km/s: the turn adds {combined - dv2:.2f} km/s")
print(f"the trip: {dv1:.2f} + {combined:.2f} = {dv1 + combined:.2f} km/s, against {dv_turn + dv1 + dv2:.2f} done separately")

y_burn = up.y_events[0][0]
v_geo, dv_c = turn_burn(y_burn[:3], y_burn[3:], side_a, dv2)
geo_run = solve_ivp(rhs, (0, 86164), np.r_[y_burn[:3], v_geo], **TOL)
h, e, p = elements(geo_run.y[:3, -1], geo_run.y[3:, -1])
print(f"solve_ivp, one day after the burn: e = {np.linalg.norm(e):.1e}, i = {incl(geo_run.y[:3, -1], geo_run.y[3:, -1]):.1e}°, "
      f"r = {np.linalg.norm(geo_run.y[:3, -1]):,.0f} km; the push {dv_c:.3f} km/s")

# the same trip at the slider settings, whole meters per second: 2,397, then 797 and 1,456 m/s
up_s = solve_ivp(rhs, (0, 1e5), np.r_[r_node, turn_burn(r_node, v_node, 0.0, 2.397)[0]], events=apogee, **TOL)
y_s = up_s.y_events[0][0]
v_s, dv_s = turn_burn(y_s[:3], y_s[3:], 0.797, 1.456)
h, e, p = elements(y_s[:3], v_s)
print(f"slider settings: ring {p / (1 + np.linalg.norm(e)):,.0f} × {p / (1 - np.linalg.norm(e)):,.0f} km, "
      f"i = {incl(y_s[:3], v_s):.4f}°, the push {dv_s:.3f} km/s")
at apogee: v_a = 1.62 km/s, v_c = 3.07 km/s; Sideways 797 m/s, Forward 1,456 m/s
the settings added: 2.25 km/s, at right angles: 1.66 km/s
circularize alone 1.46 km/s, with the turn 1.82 km/s: the turn adds 0.37 km/s
the trip: 2.40 + 1.82 = 4.22 km/s, against 7.63 done separately
solve_ivp, one day after the burn: e = 5.7e-12, i = 0.0e+00°, r = 42,164 km; the push 1.824 km/s
slider settings: ring 42,108 × 42,136 km, i = 0.0051°, the push 1.824 km/s

That is 1.82 km/s against 1.46 for circularizing alone: the turn adds 0.37 km/s at the top instead of 3.78 in low orbit, and the trip costs 4.22 km/s instead of 7.63. With the exact burns solve_ivp finds the ring round at 42,164 km a day later; the sliders' whole meters per second leave it at 42,108 × 42,136 km.

Why the top? The turn is about the radius vector, so the radial part of the velocity lies on the axis and does not turn; only the horizontal speed \(v_h\) does. With the angular momentum per kilogram \(h = r v_h\), a turn by \(\Delta i\) costs \(2(h/r)\sin(\Delta i/2)\), the first formula again on a circle, where \(h/r = v\). It is largest at perigee, where the capsule is closest, and smallest at apogee, where it is slowest. The game fires every burn at a node, but a rocket need not: a forward burn off the equator keeps the plane and puts the perigee there, so where the perigee burn happens decides which point of the ellipse is the node. The figure places each point by the true anomaly, the angle from perigee seen from the Earth's center:

Show code
h_t = R_LEO * v_p
e_t = (R_GEO - R_LEO) / (R_GEO + R_LEO)
nu = np.linspace(0, np.pi, 361)
r_nu = h_t**2 / MU / (1 + e_t * np.cos(nu))
cost = 2 * h_t / r_nu * np.sin(d_i / 2)
fig, ax = plt.subplots()
ax.plot(np.degrees(nu), cost, color=ACCENT)
ax.axhline(dv_turn, color=MUTED, lw=1, ls="--")
ax.text(178, dv_turn + 0.12, f"turn in low orbit, {dv_turn:.2f}", color=MUTED, ha="right", va="bottom")
ax.text(3, cost[0] + 0.12, f"{cost[0]:.2f}", color=ACCENT, va="bottom")
ax.text(178, cost[-1] + 0.15, f"{cost[-1]:.2f}", color=ACCENT, ha="right", va="bottom")
ax.plot(180, combined - dv2, "o", ms=6, color=ACCENT)
ax.text(174, combined - dv2, f"folded into the apogee burn: +{combined - dv2:.2f}", color=ACCENT, ha="right", va="center")
ax.set(xlim=(0, 185), ylim=(0, 5.6), xticks=range(0, 181, 30),
       xlabel="true anomaly / degrees  (0 perigee, 180 apogee)", ylabel="cost of the turn / (km/s)")
plt.show()
print(f"a 28.5° turn on the transfer: {cost[0]:.2f} km/s at perigee, {cost[-1]:.2f} km/s at apogee")
Cost of a 28.5° plane change at each point of the transfer ellipse, in km/s, against the true anomaly from 0° (perigee) to 180° (apogee). It falls from 4.96 km/s at perigee, above the 3.78 km/s of the turn in low orbit (gray dashed line), to 0.80 km/s at apogee. A dot at apogee marks the 0.37 km/s that the turn adds to the circularizing burn.
a 28.5° turn on the transfer: 4.96 km/s at perigee, 0.80 km/s at apogee

At perigee the capsule outruns the parking orbit, so the turn costs 4.96 km/s there, and at apogee 0.80. Turn the plane where the capsule is slowest, in the burn that changes its speed anyway.

The launch site sets the tilt

Kourou, Cape Canaveral, and Baikonur are marked on the turning globe, the Cape darker than the other two. A rocket leaves its site moving due east, so the ring it reaches passes through the site.

The site lies on the ring, and a tilted ring reaches no higher latitude than its inclination, so the inclination is at least the site's latitude \(\varphi\). Due east puts the site at the ring's highest point, and then \(i = \varphi\). For any launch azimuth \(A\), the direction measured clockwise from north,

\[\cos i = \cos\varphi\,\sin A,\]

and due east, \(A = 90°\), gives the smallest tilt. The Earth's rotation adds \(\omega R_E\cos\varphi\) toward the east, which is largest at the equator too. For the Cape, 28.5° is the conventional value; two of its pads, SLC-40 and LC-39A, lie at 28.56° and 28.61° N.

print(f"{'site':15s} {'lat':>6s} {'i, due east':>12s} {'east boost':>11s} {'to GEO':>10s} {'extra':>6s}")
for site in SITES:
    phi = site["lat"] * DEG
    r_pad = R_E * np.array([np.cos(phi), 0.0, np.sin(phi)])
    v_pad = np.array([0.0, V_LEO, 0.0])                     # horizontal, due east
    i_pad = incl(r_pad, v_pad)
    boost = OMEGA_E * R_E * np.cos(phi)
    site["gap"] = abs(i_pad - site["lat"])
    site["cost"] = dv1 + np.sqrt(v_a**2 + v_c**2 - 2 * v_a * v_c * np.cos(np.radians(i_pad)))
    print(f"{site['name']:15s} {site['lat']:5.2f}° {i_pad:11.2f}° {boost:7.2f} km/s {site['cost']:5.2f} km/s "
          f"{site['cost'] - dv1 - dv2:5.2f}")
print(f"{'climb alone':15s} {'':>6s} {'':>12s} {'':>11s} {dv1 + dv2:5.2f} km/s")
print(f"largest |i - latitude| for a due-east launch: {max(site['gap'] for site in SITES):.0e}°")
site               lat  i, due east  east boost     to GEO  extra
Kourou           5.23°        5.23°    0.46 km/s  3.87 km/s  0.01
Cape Canaveral  28.50°       28.50°    0.41 km/s  4.22 km/s  0.37
Baikonur        45.92°       45.92°    0.32 km/s  4.67 km/s  0.81
climb alone                                      3.85 km/s
largest |i - latitude| for a due-east launch: 3e-14°
Show code
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(7, 3.6), gridspec_kw=dict(width_ratios=[1.3, 1]))
ax1.axhline(0, color=MUTED, lw=1, zorder=1)
ax1.add_patch(plt.Circle((0, 0), R_E / 1e3, facecolor="#ebe9e4", edgecolor=INK, lw=1, zorder=2))
for site, alpha in zip(SITES, [0.45, 1.0, 0.45]):
    phi = site["lat"] * DEG
    ax1.plot(*(R_LEO / 1e3 * np.array([[-np.cos(phi), np.cos(phi)], [-np.sin(phi), np.sin(phi)]])),
             color=ACCENT, alpha=alpha, zorder=3)
    ax1.plot(R_E / 1e3 * np.cos(phi), R_E / 1e3 * np.sin(phi), "o", ms=5, color=INK, zorder=4)
    ax1.text(R_LEO / 1e3 * np.cos(phi) + 0.3, R_LEO / 1e3 * np.sin(phi), site["name"], color=INK, va="center")
ax1.set(xlim=(-7.5, 13.5), ylim=(-7.5, 7.5), aspect="equal", xlabel="horizontal / 1000 km", ylabel="z / 1000 km")
names = [s["name"] for s in SITES][::-1]
costs = np.array([s["cost"] for s in SITES])[::-1]
ax2.barh(names, dv1 + dv2, color=SECOND, height=0.55)
ax2.barh(names, costs - dv1 - dv2, left=dv1 + dv2, color=ACCENT, height=0.55)
for y, c in enumerate(costs):
    ax2.text(c + 0.05, y, f"{c:.2f}", va="center", color=INK)
ax2.text(0.15, 2, f"climb alone\n{dv1 + dv2:.2f}", color="white", va="center", linespacing=1.1)
ax2.set(xlim=(0, 5.4), xlabel="Δv from 400 km to GEO / (km/s)")
ax2.grid(axis="y", visible=False)
ax2.set_axisbelow(True)
fig.tight_layout()
plt.show()
Left: side view with the rings of due-east launches from Kourou (5.2°), Cape Canaveral (28.5°), and Baikonur (45.9°), each through its site. Right: the cost from 400 km to GEO by site, in km/s: the climb alone, 3.85 (blue), plus the plane change folded into the apogee burn (red), for totals of 3.87, 4.22, and 4.67.

Launched due east, each capsule's ring is tilted by its site's latitude, to rounding error. From Kourou the way to GEO costs 3.87 km/s, 0.01 more than the climb alone; from the Cape 0.37 more, and from Baikonur 0.81 more, a fifth of the climb again. The rotation gives a rocket at Kourou 0.46 km/s for free, one at Baikonur 0.32. This is why launchers bound for GEO want to sit on the equator.

How the game computes it

The state. One object holds whatever changes, in km, km/s, and s: position r and velocity v in axes that do not turn with the Earth, the time t, from which the Earth's rotation angle follows, the two settings in m/s, the armed flag, the Δv spent, and the ring before the last burn:

function fresh() {
  const c = circular(R_E + DATA.start.alt, DATA.start.inc * DEG, DATA.start.u * DEG);
  return {r: c.r, v: c.v, t: 0, warp: DATA.warp_start, side: DATA.side.preset, fwd: DATA.fwd.preset,
    armed: false, dv: 0, ghost: null, running: true, crashed: false, zoom: null};
}

One tick. requestAnimationFrame calls frame before each repaint, and frame hands the elapsed time, capped at 50 ms and scaled by the warp, to tick, which takes velocity Verlet steps until the capsule's clock catches up. A kick changes the velocity by the acceleration times half a step, a drift moves the position by the new velocity times a whole step, and a step is kick, drift, kick. Gravity points along \(\mathbf r\), so a kick adds a vector parallel to \(\mathbf r\) and a drift one parallel to \(\mathbf v\): neither changes \(\mathbf r\times\mathbf v\), and the inclination readout stays put between burns (Velocity Verlet has more). The step \(\Delta t\) is 0.1 % of the time the capsule needs to cover its distance from the center, at most 30 s:

function verlet(s, dt) {
  s.v = add(s.v, scale(accel(s.r), dt / 2));
  s.r = add(s.r, scale(s.v, dt));
  s.v = add(s.v, scale(accel(s.r), dt / 2));
  s.t += dt;
}
const stepSize = (s) => Math.min(30, 0.001 * len(s.r) / len(s.v));

The burn at the node. When an armed step takes \(z\) from \(z_0\) to \(z_1\) across zero, the game redoes it in two parts, the first \(\Delta t\,z_0/(z_0 - z_1)\) long, and burns on the equator in between. The burn turns the horizontal part of the velocity about \(\hat{\mathbf r}\) by \(\alpha = 2\arcsin(\Delta v_s/2v_h)\) toward the equator, with \(\Delta v_s\) the Sideways setting, adds the forward change, and charges the length of the difference:

  const rh = unit(s.r), vr = dot(s.v, rh);
  const vhVec = sub(s.v, scale(rh, vr)), vh = len(vhVec), t = scale(vhVec, 1 / vh);
  const east = unit(cross([0, 0, 1], rh));
  const toward = dot(cross(t, east), rh) < 0 ? -1 : 1;    // the sense of turn that brings t toward east
  const a = toward * Math.sign(s.side) * turnAngle(s.side / 1000, vh);
  const tNew = add(scale(t, Math.cos(a)), scale(cross(rh, t), Math.sin(a)));
  const vNew = add(scale(rh, vr), scale(tNew, vh + s.fwd / 1000));
  s.dv += len(sub(vNew, s.v));

Drawing. Both views are parallel projections, along the line of nodes and from above the pole. The whole ring is drawn from the current r and v, the land is turned by the rotation angle and drawn on the near side only, and the views ease toward 1.2 times the farthest point of the orbit.

From the notebook into the game. The notebook writes the constants, the start, the ranges of the two settings, the climb alone, the sites, and the land as JSON into a <script type="application/json" id="data"> element, so the game fetches nothing. The land is Natural Earth's 1:110m land, version 5.1.2, public domain, a 0.14 MB file downloaded with pooch, pinned by its SHA-256, and thinned to points 1° apart. The first hidden cell ports step and burn to Python; here they fly the turn in low orbit and the transfer at the slider settings, against solve_ivp:

def fly(settings, t_after):
    """The game from its start: each burn armed in turn with its (side, fwd) in m/s, then t_after more seconds."""
    s = fresh()
    for side, fwd in settings:
        s.update(side=side, fwd=fwd, armed=True)
        while s["armed"]:
            step(s)
    t_end = s["t"] + t_after
    while s["t"] < t_end:
        step(s)
    return s

def exact(settings, t_end):
    """solve_ivp from the same start: to each node in turn (ascending, then descending), the same burn, then t_end."""
    r, v = circular(R_LEO, INC * DEG, -90 * DEG)
    y, t = np.r_[r, v], 0.0
    for k, (side, fwd) in enumerate(settings):
        def crossing(t, y):
            return y[2]
        crossing.terminal, crossing.direction = True, 1 if k % 2 == 0 else -1
        sol = solve_ivp(rhs, (t, t + 1e5), y, events=crossing, **TOL)
        t, y = sol.t_events[0][0], sol.y_events[0][0]
        y = np.r_[y[:3], turn_burn(y[:3], y[3:], side / 1000, fwd / 1000)[0]]
    return solve_ivp(rhs, (t, t_end), y, **TOL).y[:, -1]

print(f"Sideways 3,775 m/s at {V_LEO:.3f} km/s turns the ring by {2 * np.degrees(np.arcsin(3.775 / (2 * V_LEO))):.3f}°")
for name, settings, t_after in [("turn in low orbit", [(3775, 0)], T_LEO),
                                ("transfer, combined burn", [(0, 2397), (797, 1456)], 86164)]:
    game = fly(settings, t_after)
    y = exact(settings, game["t"])
    tilts = ", ".join(f"{b['incl']:.4f}°" for b in game["burns"])
    print(f"{name}: {game['steps']:,} steps over {game['t'] / 3600:.1f} h, Δv used {game['dv']:.3f} km/s; "
          f"inclination after each burn {tilts}")
    print(f"   game against solve_ivp at the end: {1e6 * np.linalg.norm(game['v'] - y[3:]):.1f} mm/s, "
          f"{1e3 * np.linalg.norm(game['r'] - y[:3]):.0f} m; r = {np.linalg.norm(game['r']):,.0f} km")
Sideways 3,775 m/s at 7.669 km/s turns the ring by 28.498°
turn in low orbit: 7,855 steps over 1.9 h, Δv used 3.775 km/s; inclination after each burn 0.0022°
   game against solve_ivp at the end: 18.3 mm/s, 14 m; r = 6,778 km
transfer, combined burn: 11,613 steps over 29.6 h, Δv used 4.221 km/s; inclination after each burn 28.5000°, 0.0052°
   game against solve_ivp at the end: 22.7 mm/s, 302 m; r = 42,136 km

The turn in low orbit ends 14 m from solve_ivp after 7,855 steps, and the transfer, with a day in GEO after the combined burn, 302 m after 11,613. The tilt that remains, 0.0022° and 0.0052°, is not the integrator's: the sliders move in whole meters per second, and 3,775 m/s turns the ring by 28.498°.

The cell that writes the game. Sorted keys make every run write the same bytes, and escaping </ as <\/ keeps the data from ending the script element. The page is a template string, in the collapsed cell:

Show code
TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Orbital plane change: turn a capsule's orbit onto the equator</title>
<style>
:root { --bg: #ffffff; --fg: #1f2a44; --ink: #1f2a44; --accent: #c8553d; --second: #2a7f9e; --muted: #8a8f98;
  --globe: #ebe9e4; }
@media (prefers-color-scheme: dark) {
  :root { --bg: #141a2a; --fg: #e9ecf3; --ink: #c9d0e3; --accent: #e3826b; --second: #6ec1de; --muted: #8e97ab;
    --globe: #232c42; }
}
html, body { margin: 0; height: 100%; overflow: hidden; background: var(--bg); color: var(--fg);
  font: 15px/1.3 system-ui, sans-serif; -webkit-text-size-adjust: 100%; }
body { display: grid; gap: 4px 10px; padding: 6px; box-sizing: border-box;
  grid-template: "side top" minmax(0, 1fr) "read read" auto "ctrl ctrl" auto / minmax(0, 1fr) minmax(0, 1fr); }
body.upright { font-size: 14px; padding: 4px 6px; }
.box { position: relative; min-height: 0; }
.box canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#sidebox { grid-area: side; } #topbox { grid-area: top; }
#read { grid-area: read; display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 14px;
  font-variant-numeric: tabular-nums; }
.big { font-size: 1.35em; font-weight: 600; }
.acc { color: var(--accent); } .sec { color: var(--second); } .dim { color: var(--muted); }
#ctrl { grid-area: ctrl; display: grid; grid-template-columns: 1fr 1fr; gap: 2px 14px; }
body.upright #ctrl { grid-template-columns: 1fr; }
.slider .cap { display: flex; justify-content: space-between; gap: 8px; font-variant-numeric: tabular-nums; }
.row { display: flex; align-items: center; gap: 6px; }
.row input { flex: 1; min-width: 0; accent-color: var(--accent); }
#buttons { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; }
.group { display: flex; align-items: center; gap: 4px; }
#warp-val { min-width: 4.2em; text-align: center; font-variant-numeric: tabular-nums; }
button { font: inherit; color: var(--bg); background: var(--second); border: 0; border-radius: 4px; padding: 0.3em 0.8em;
  touch-action: manipulation; }
button.step { padding: 0; width: 2em; }
#burn { background: var(--accent); min-width: 9.5em; }
#burn.armed { background: var(--bg); color: var(--accent); box-shadow: inset 0 0 0 2px var(--accent); }
input { touch-action: manipulation; }
@media (pointer: coarse) { button { min-height: 44px; min-width: 44px; } input[type=range] { min-height: 44px; } }
</style>
</head>
<body>
<div class="box" id="sidebox"><canvas id="side" aria-label="Side view along the line where the ring crosses the equator"></canvas></div>
<div class="box" id="topbox"><canvas id="top" aria-label="Top view from above the north pole"></canvas></div>
<div id="read">
  <span class="acc">inclination <span class="big" id="incl"></span></span>
  <span>speed <span id="speed"></span></span>
  <span class="dim" id="alt"></span>
  <span>Δv used <span class="big" id="dv"></span></span>
  <span class="sec">climb to GEO alone <span id="climb"></span></span>
  <span class="dim" id="clock"></span>
</div>
<div id="ctrl">
  <div class="slider"><div class="cap"><span>Sideways</span><span id="side-val"></span></div>
    <div class="row"><button class="step" id="side-minus" title="25 m/s less" aria-label="Sideways 25 m/s less">−</button><input
      type="range" id="side" aria-label="Sideways, m/s"><button class="step" id="side-plus" title="25 m/s more"
      aria-label="Sideways 25 m/s more">+</button></div></div>
  <div class="slider"><div class="cap"><span>Forward</span><span id="fwd-val"></span></div>
    <div class="row"><button class="step" id="fwd-minus" title="25 m/s less" aria-label="Forward 25 m/s less">−</button><input
      type="range" id="fwd" aria-label="Forward, m/s"><button class="step" id="fwd-plus" title="25 m/s more"
      aria-label="Forward 25 m/s more">+</button></div></div>
  <div id="buttons">
    <button id="burn" title="Fire at the next equator crossing (B)"></button>
    <button id="go" title="Play or pause (Space)"></button>
    <div class="group"><span>Warp</span><button class="step" id="warp-minus" title="Slower ([)" aria-label="Slower">−</button><span
      id="warp-val"></span><button class="step" id="warp-plus" title="Faster (])" aria-label="Faster">+</button></div>
    <button id="reset" title="Reset (R)">Reset</button>
  </div>
</div>
<script type="application/json" id="data">{{DATA}}</script>
<script>{{SCRIPT}}</script>
</body>
</html>
"""
game_data = {
    "mu": MU, "r_e": R_E, "r_geo": R_GEO, "omega_e": round(OMEGA_E, 12), "theta0": round(THETA0, 6),
    "start": {"alt": 400, "inc": INC, "u": -90},
    "side": {"min": -8000, "max": 8000, "step": 25, "preset": 3775},
    "fwd": {"min": -3000, "max": 3000, "step": 25, "preset": 0},
    "warps": [60, 200, 600, 2000, 20000], "warp_start": 1,
    "climb": round(dv1 + dv2, 3),
    "sites": [{"name": s["name"], "lat": s["lat"], "lon": s["lon"]} for s in SITES], "active_site": "Cape Canaveral",
    "land": [[float(x) for x in coast.ravel()] for coast in land],
}
# sorted keys and fixed rounding: two runs write the same bytes; "<\/" keeps "</" out of the script element
data = json.dumps(game_data, sort_keys=True, separators=(",", ":")).replace("</", "<\\/")
script = Path("minigame/turn/game.js").read_text(encoding="utf-8")
Path("assets").mkdir(exist_ok=True)
Path("assets/turn.html").write_text(TEMPLATE.replace("{{DATA}}", data).replace("{{SCRIPT}}", script),
                                    encoding="utf-8", newline="\n")
print(f"assets/turn.html: {sum(map(len, land)):,} land points, {Path('assets/turn.html').stat().st_size / 1024:.0f} kB")
assets/turn.html: 2,864 land points, 49 kB

Variations

  • Split the turn. Arm 2.2° worth of Sideways with the perigee burn and the rest at the top. The perigee burn grows by only 24 m/s, because a small turn is nearly at right angles to a large change of speed, and the trip drops by 25 m/s. A grid over the split, or a one-line minimize_scalar (Minimization with scipy.optimize.minimize), finds the best one.
  • A bi-elliptic plane change. For a 60° turn in low orbit, burn forward to an apogee of 100,000 km, turn there, and come back down. That costs 6.36 km/s against 7.67 for the direct turn, and takes 1.4 days at the 20,000× warp step.
  • Launch northeast to the ISS. Set the start inclination in the JSON to 51.6°: from the Cape that is a launch at azimuth 45.0°. The eastward boost along the track drops from 0.41 to 0.29 km/s, while the same tilt by a turn in orbit would cost 3.07 km/s.
  • Burn off the equator. Remove the arming and fire at once. The same Sideways setting fired 45° past the node lowers the inclination only to 21.0° and swings the line of nodes by 70°.

Further reading