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
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 jupyterlabPlay: 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.
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()
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:
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()
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()
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:
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()
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:
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")
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,
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()
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
- H. D. Curtis, Orbital Mechanics for Engineering Students, 4th ed. (Butterworth-Heinemann, 2020), the chapter on orbital maneuvers, for Hohmann transfers, plane changes, and combined burns.
- D. A. Vallado, Fundamentals of Astrodynamics and Applications, 4th ed. (Microcosm Press, 2013), for combined maneuvers, the best split of a plane change, and bi-elliptic transfers.
- R. R. Bate, D. D. Mueller, J. E. White, and W. W. Saylor, Fundamentals of Astrodynamics, 2nd ed. (Dover, 2020), for the two-body problem and orbital elements.
- The launch sites: Arianespace, Ariane 5 User's Manual, Issue 5 Revision 2 (2016), for Kourou ELA-3; the Wikipedia articles on Space Launch Complex 40 and Launch Complex 39A at Cape Canaveral, and on Gagarin's Start, Baikonur Site 1.
- Natural Earth, for the land on the globe.
- MDN's guide to the Canvas API, with which
game.jsdraws both views. - Related tutorials on this site: Gravity assist past Jupiter: faster in the Sun's frame, not in Jupiter's, solve_ivp from the ground up: the pendulum beyond small angles, Minimization with scipy.optimize.minimize: the shape of a seven-atom cluster, Velocity Verlet: keep the Earth on its orbit for a thousand years, Symplectic integrators: why leapfrog's energy error is bounded and RK4's drifts (optional, intermediate), Map projections: why Greenland looks as large as Africa, and what each keeps, and the other Minigames, Relativistic flight at 0.9 c: aberration, Doppler colors, and the twin clocks and Earthquake location from three seismograms: circles, S minus P, and the misfit.
- Download the notebook. It was executed with the library versions in the header.
- Download the game, one HTML file of 49 kB that runs offline in any browser.