Coriolis force on a merry-go-round: why a throw curves only for the rider
Afterwards you can write Newton's law in a rotating frame with Coriolis and centrifugal forces, and compute a throw's miss and an inertial loop's period.
- Field
- Geology, Physics
- Prerequisites
- none beyond Python basics
- Libraries
matplotlib 3.11.2numpy 2.4.3scipy 1.18.1
py-coriolis-carousel.ipynb, executed with the versions above. The download needs a free account
Run it yourself. In a terminal, this installs exactly the versions above:
pip install numpy==2.4.3 scipy==1.18.1 matplotlib==3.11.2 jupyterlabPlay: throw a ball across a turning merry-go-round
Two children sit opposite each other on the rim of a merry-go-round 4 m across, which turns counterclockwise once every 4 s, so the rim moves at 3.14 m/s. One throws a ball at 10 m/s straight at the other. Press Throw for that throw, or flick anywhere in either view to throw your own, aimed as the thrower feels it, relative to the hand. Watch the rider's view, where the platform stands still and the ground turns, against the ground view. The game opens on the stated throw: it misses the friend by 2.23 m, and on the ground its path is straight. Dish turns the platform into a bowl that rises toward the rim, with the ball waiting on a mark 1 m from the center; Throw then nudges it inward at 1 m/s, and Flat makes it flat again. Period sets the turn, Time the slow motion (a quarter of real speed at the start), Reset puts the ball back, and the keys T, D, R, [ and ] work too.
Show code
// game.js: throw a ball across a turning merry-go-round and watch it in two frames. Units: m, m/s, s.
// The platform turns counterclockwise seen from above. The ball moves in the ground frame: a straight
// line on the flat platform, a pull -Ω² p on the dish. The rider's view rotates the same state by
// -angle, so it never integrates anything. The notebook writes the constants into the #data element.
"use strict";
const DATA = JSON.parse(document.getElementById("data").textContent);
const {radius: R, h: H, half: HALF, gear: GEAR, v_max: V_MAX, periods: PERIODS, slows: SLOWS} = DATA;
const POSTS = 16; // fence posts on the ground, outside the rim
// ---- small vector helpers
const add = (a, b) => [a[0] + b[0], a[1] + b[1]];
const sub = (a, b) => [a[0] - b[0], a[1] - b[1]];
const scale = (a, k) => [a[0] * k, a[1] * k];
const len = (a) => Math.hypot(a[0], a[1]);
const rotate = (a, th) => [Math.cos(th) * a[0] - Math.sin(th) * a[1], Math.sin(th) * a[0] + Math.cos(th) * a[1]];
const toRider = (p, angle) => rotate(p, -angle); // ground coordinates into the platform's
// ---- the state: everything that changes, in one object
const DEFAULTS = {t: 0, angle: 0, period: 4, dish: false, slow: 0.25, p: [-R, 0], v: [0, 0],
flying: false, tThrow: 0, angleThrow: 0, tStop: null, miss: null, trail: []};
const state = {...DEFAULTS, trail: []};
const omega = () => (state.period ? (2 * Math.PI) / state.period : 0); // period 0: at rest
const start = () => DATA.throws[state.dish ? "dish" : "flat"].start;
function hold() { // the ball at rest in the hand, turning with it
const w = omega();
state.p = rotate(start(), state.angle);
state.v = [-w * state.p[1], w * state.p[0]];
}
// ---- one velocity Verlet step in the ground frame
function step(h) {
const w = omega(), k = state.dish ? -w * w : 0; // the dish's slope pulls with -Ω² p
state.v = add(state.v, scale(state.p, (k * h) / 2)); // half a kick
state.p = add(state.p, scale(state.v, h)); // a drift
state.v = add(state.v, scale(state.p, (k * h) / 2)); // half a kick
state.angle += w * h;
state.t += h;
}
// ---- the throw: u is the ball's velocity relative to the thrower's hand, in the platform's axes
function throwBall(u) {
hold();
const w = omega(), p = state.p;
state.v = add(rotate(u, state.angle), [-w * p[1], w * p[0]]); // plus the hand's own Ω ẑ × p
Object.assign(state, {flying: true, tThrow: state.t, angleThrow: state.angle, tStop: null, miss: null, trail: []});
record();
}
const throwStated = () => throwBall(DATA.throws[state.dish ? "dish" : "flat"].u);
function record() { // one point of the path, in both frames
state.trail.push([...toRider(state.p, state.angle), ...state.p]);
if (state.trail.length > 3000) state.trail.shift(); // a loop on the dish runs on; keep the last 6 s
}
function stop() {
state.flying = false;
state.tStop = state.t;
}
// ---- the rim: where in the last step the ball crossed it, and how far the friend was
function crossRim(prev) {
const f = (R - len(prev)) / (len(state.p) - len(prev));
const late = H * (1 - f), angle = state.angle - omega() * late;
const hit = add(prev, scale(sub(state.p, prev), f));
const friend = rotate([R, 0], angle);
state.miss = {d: len(sub(hit, friend)), t: state.t - late - state.tThrow, hit, angle,
side: toRider(hit, angle)[1] < 0 ? "right" : "left"};
if (state.dish) { // the ball stops at the dish's edge
state.p = hit;
stop();
}
}
function advance() {
if (!state.flying) {
state.angle += omega() * H;
state.t += H;
hold();
return;
}
const prev = state.p;
step(H);
if (state.miss === null && len(state.p) >= R) crossRim(prev);
record();
if (len(state.p) > 1.5 * HALF) stop(); // off the ground view on the flat platform
}
let carry = 0; // game time not yet stepped
function tick(dt) {
carry += Math.min(dt, 0.05) * state.slow; // the cap stops jumps after a hidden tab
while (carry >= H) {
carry -= H;
advance();
}
}
// ---- drawing
const $ = (id) => document.getElementById(id);
const color = (name) => getComputedStyle(document.documentElement).getPropertyValue(`--${name}`).trim();
const FONT = "14px system-ui, sans-serif";
const views = {};
function fit(id) { // size a canvas to its box, sharp on any screen
const c = $(id), box = c.parentElement.getBoundingClientRect(), dpr = window.devicePixelRatio || 1;
c.width = Math.round(box.width * dpr);
c.height = Math.round(box.height * dpr);
const ctx = c.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const top = box.width > box.height + 120 ? 0 : 18; // a wide box keeps the title beside the ground
const k = Math.min(box.width, box.height - top) / (2 * HALF); // px per m
return {ctx, w: box.width, h: box.height, k, cx: box.width / 2, cy: top + (box.height - top) / 2};
}
function layout() {
const W = document.documentElement.clientWidth, Hh = document.documentElement.clientHeight;
document.body.className = Hh > 1.1 * W ? "upright" : "wide";
for (const id of ["rider", "ground"]) views[id] = fit(id);
render();
}
const px = (v, [x, y]) => [v.cx + x * v.k, v.cy - y * v.k];
function line(ctx, pts, style, width, dash = []) {
ctx.beginPath();
pts.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
ctx.strokeStyle = style;
ctx.lineWidth = width;
ctx.setLineDash(dash);
ctx.stroke();
ctx.setLineDash([]);
}
function circle(ctx, [x, y], r, style, width, dash = []) {
ctx.beginPath();
ctx.arc(x, y, r, 0, 2 * Math.PI);
ctx.strokeStyle = style;
ctx.lineWidth = width;
ctx.setLineDash(dash);
ctx.stroke();
ctx.setLineDash([]);
}
function disk(ctx, [x, y], r, style) {
ctx.beginPath();
ctx.arc(x, y, r, 0, 2 * Math.PI);
ctx.fillStyle = style;
ctx.fill();
}
function label(ctx, text, [x, y], style, align = "left", base = "alphabetic") {
ctx.font = FONT;
ctx.textAlign = align;
ctx.textBaseline = base;
ctx.strokeStyle = color("bg"); // a halo, so lines under the text do not cut it
ctx.lineWidth = 3;
ctx.lineJoin = "round";
ctx.strokeText(text, x, y);
ctx.fillStyle = style;
ctx.fillText(text, x, y);
ctx.textBaseline = "alphabetic";
}
// One drawing routine for both views: the platform turned by aP, the ground by aG.
// Ground view: aP = angle, aG = 0. Rider's view: aP = 0, aG = -angle.
function drawView(id, c) {
const v = views[id], ctx = v.ctx, rider = id === "rider";
const aP = rider ? 0 : state.angle, aG = rider ? -state.angle : 0;
const at = (p) => px(v, p);
ctx.clearRect(0, 0, v.w, v.h);
for (let i = 0; i < POSTS; i++) { // the ground: posts of a fence
const th = aG + (2 * Math.PI * i) / POSTS, u = [Math.cos(th), Math.sin(th)];
line(ctx, [at(scale(u, R + 0.45)), at(scale(u, R + (i ? 0.75 : 1.0)))], c.muted, i ? 1.5 : 3);
}
if (state.dish) for (const r of [0.5, 1, 1.5]) circle(ctx, at([0, 0]), r * v.k, c.muted, 1, [3, 4]);
for (let i = 0; i < 6; i++) { // spokes, so the platform's turn shows
const th = aP + (Math.PI * i) / 3;
line(ctx, [at([0, 0]), at([R * Math.cos(th), R * Math.sin(th)])], c.muted, 1);
}
circle(ctx, at([0, 0]), R * v.k, c.ink, 2);
if (state.dish) { // the start mark, where a ball at rest stays
const [x, y] = at(rotate(DATA.throws.dish.start, aP));
line(ctx, [[x - 6, y - 6], [x + 6, y + 6]], c.muted, 2);
line(ctx, [[x - 6, y + 6], [x + 6, y - 6]], c.muted, 2);
label(ctx, "start", [x, y - 10], c.muted, "center"); // above: the dish loop runs below the mark
}
const m = state.miss;
if (!rider && state.trail.length && !state.dish) { // the friend's arc since the throw
const a1 = m ? m.angle : state.angle, pts = [];
for (let i = 0; i <= 40; i++) {
const th = state.angleThrow + ((a1 - state.angleThrow) * i) / 40;
pts.push(at([R * Math.cos(th), R * Math.sin(th)]));
}
line(ctx, pts, c.muted, 4);
}
for (const [p, name] of [[[-R, 0], "thrower"], [[R, 0], "friend"]]) {
const u = rotate(p, aP);
disk(ctx, at(u), 6, c.ink);
if (state.dish) continue; // on the dish the people only mark the rim
// The name sits inside the rim beside the person's spoke: above a spoke that lies nearly flat (the throw
// goes below it), else on the side where the text runs away from the spoke, further in where the rim curves.
const e = u[0] < 0 ? 1 : -1; // e: the direction the text runs, toward the center
let n = [-u[1] / R, u[0] / R]; // perpendicular to the spoke
if (Math.abs(n[0]) < 0.5 ? n[1] < 0 : n[0] * e < 0) n = scale(n, -1);
const a = at(scale(u, 1 - (14 + 14 * Math.abs(u[1] / R)) / (R * v.k)));
label(ctx, name, [a[0] + 14 * n[0], a[1] - 14 * n[1]], c.ink, e > 0 ? "left" : "right", "middle");
}
const k0 = rider ? 0 : 2, trail = state.trail.map((p) => at([p[k0], p[k0 + 1]]));
line(ctx, trail, rider ? c.accent : c.second, 2.5);
if (m) { // the miss, as a segment to the friend
const hit = rider ? toRider(m.hit, m.angle) : m.hit, friend = rider ? [R, 0] : rotate([R, 0], m.angle);
line(ctx, [at(hit), at(friend)], c.muted, 1.5, [4, 3]);
disk(ctx, at(friend), 4, c.muted);
const near = add(friend, scale(sub(hit, friend), 0.3)), q = at(near); // near the friend, on the inner side
const e = near[0] < 0 ? 1 : -1;
label(ctx, `${m.d.toFixed(2)} m`, [q[0] + 8 * e, q[1]], c.muted, e > 0 ? "left" : "right", "middle");
}
disk(ctx, at(rotate(state.p, aG)), 6, rider ? c.accent : c.second);
if (drag && drag.id === id && len([drag.dx, drag.dy]) > 8) { // the flick, drawn from the ball
const b = at(rotate(state.p, aG));
line(ctx, [b, [b[0] + drag.dx, b[1] + drag.dy]], c.ink, 2);
disk(ctx, [b[0] + drag.dx, b[1] + drag.dy], 4, c.ink);
}
label(ctx, rider ? "rider's view" : "ground view", [6, 15], c.fg);
}
function render() {
const c = Object.fromEntries(["fg", "ink", "accent", "second", "muted"].map((n) => [n, color(n)]));
drawView("rider", c);
drawView("ground", c);
const w = omega(), p = state.p, m = state.miss;
const vRider = sub(state.v, [-w * p[1], w * p[0]]); // ground velocity minus the platform's there
$("v-rider").textContent = `${len(vRider).toFixed(2)} m/s`;
$("v-ground").textContent = `${len(state.v).toFixed(2)} m/s`;
const off = state.dish && m ? "off the dish" : m ? `${m.d.toFixed(2)} m ${m.side}, rim at ${m.t.toFixed(2)} s` : "none";
$("miss").textContent = off;
const since = state.flying ? state.t - state.tThrow : state.tStop !== null ? state.tStop - state.tThrow : 0;
$("clock").textContent = `${since.toFixed(2)} s`;
$("period-val").textContent = state.period ? `${state.period} s` : "at rest";
$("slow-val").textContent = `×${state.slow}`;
$("flat").setAttribute("aria-pressed", String(!state.dish));
$("dish").setAttribute("aria-pressed", String(state.dish));
}
// ---- controls, keys, flicks, and the loop
function set(partial) {
Object.assign(state, partial);
if ("period" in partial || "dish" in partial) { // a new platform: the ball back in the hand
Object.assign(state, {flying: false, tStop: null, miss: null, trail: []});
hold();
}
render();
}
function nudge(list, key, sign) {
const i = Math.min(list.length - 1, Math.max(0, list.indexOf(state[key]) + sign));
set({[key]: list[i]});
}
const reset = () => set({...DEFAULTS, trail: []});
$("period-minus").addEventListener("click", () => nudge(PERIODS, "period", -1));
$("period-plus").addEventListener("click", () => nudge(PERIODS, "period", 1));
$("slow-minus").addEventListener("click", () => nudge(SLOWS, "slow", -1));
$("slow-plus").addEventListener("click", () => nudge(SLOWS, "slow", 1));
$("flat").addEventListener("click", () => set({dish: false}));
$("dish").addEventListener("click", () => set({dish: true}));
$("throw").addEventListener("click", () => { throwStated(); render(); });
$("reset").addEventListener("click", reset);
document.addEventListener("keydown", (e) => {
const actions = {t: throwStated, d: () => set({dish: !state.dish}), r: reset,
"[": () => nudge(PERIODS, "period", -1), "]": () => nudge(PERIODS, "period", 1)};
const act = actions[e.key.toLowerCase()];
if (act) {
e.preventDefault();
act();
render();
}
});
// A flick anywhere in a view throws the ball: the drag's vector, GEAR m/s per pixel, is the throw
// relative to the hand. In the ground view it is turned into the platform's axes first.
let drag = null;
for (const id of ["rider", "ground"]) {
const canvas = $(id);
canvas.addEventListener("pointerdown", (e) => {
drag = {id, x: e.clientX, y: e.clientY, dx: 0, dy: 0};
canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener("pointermove", (e) => {
if (!drag) return;
drag.dx = e.clientX - drag.x;
drag.dy = e.clientY - drag.y;
});
canvas.addEventListener("pointerup", () => {
if (drag && len([drag.dx, drag.dy]) > 8) {
let u = scale([drag.dx, -drag.dy], GEAR); // screen y points down
if (len(u) > V_MAX) u = scale(u, V_MAX / len(u));
throwBall(id === "ground" ? rotate(u, -state.angle) : u);
}
drag = null;
});
canvas.addEventListener("pointercancel", () => { drag = null; });
}
let last = null;
function frame(now) { // called by the browser once per screen refresh
const dt = last === null ? 0 : (now - last) / 1000;
last = now;
tick(dt);
render();
requestAnimationFrame(frame);
}
window.addEventListener("resize", layout);
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", render);
hold();
throwStated(); // open on the stated throw in flight
layout();
requestAnimationFrame(frame);
window.game = {state, set, render, throw: throwStated};
The ball curves to the right, and for nobody else
The stills are drawn from the game's own model, set up in the hidden cell.
Show code
from pathlib import Path
import json
import numpy as np
import matplotlib.pyplot as plt
from scipy.integrate import solve_ivp
plt.rcParams.update({
"figure.figsize": (7.5, 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"
R, T = 2.0, 4.0 # the platform's radius / m and rotation period / s
V_THROW, V_PUSH = 10.0, 1.0 # the throw at the friend, the push on the dish, relative to the hand / (m/s)
OMEGA = 2 * np.pi / T # rad/s, counterclockwise seen from above
OMEGA_E = 7.2921159e-5 # the Earth's rotation, sidereal / (rad/s)
HALF = 3.2 # half-width of the game's views / m
def rot(theta):
"""The rotation matrix Q(θ): turns a vector counterclockwise by θ."""
return np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]])
print(f"Ω = 2π/T = {OMEGA:.3f} rad/s, rim speed ΩR = {OMEGA * R:.2f} m/s, "
f"the throw on the ground {np.hypot(V_THROW, OMEGA * R):.2f} m/s")
Ω = 2π/T = 1.571 rad/s, rim speed ΩR = 3.14 m/s, the throw on the ground 10.48 m/s
Press Throw on the flat platform with the period at 4 s. The rider's view is on the left, the ground view on the right as the ball reaches the rim:
Show code
def scene(ax, a_p, a_g, dish=False):
"""The game's drawing: fence posts on the ground turned by a_g, the platform by a_p."""
for i in range(16):
u = rot(a_g + 2 * np.pi * i / 16) @ [1.0, 0.0]
ax.plot(*np.outer([R + 0.45, R + (0.75 if i else 1.0)], u).T, color=MUTED, lw=1.2 if i else 2.4)
for i in range(6):
u = rot(a_p + np.pi * i / 3) @ [1.0, 0.0]
ax.plot(*np.outer([0, R], u).T, color=MUTED, lw=0.8, alpha=0.6)
th = np.linspace(0, 2 * np.pi, 300)
ax.plot(R * np.cos(th), R * np.sin(th), color=INK, lw=1.6)
if dish:
for r in [0.5, 1.0, 1.5]:
ax.plot(r * np.cos(th), r * np.sin(th), color=MUTED, lw=0.8, ls="--")
ax.set(xlim=(-HALF, HALF), ylim=(-HALF, HALF), aspect="equal", xlabel="x / m")
def people(ax, a_p, alpha=1.0, names=None):
"""The two children; names "inside" the rim above their spoke, or "outside" it, away from the fence posts."""
for p, name in [([-R, 0.0], "thrower"), ([R, 0.0], "friend")]:
q = rot(a_p) @ p
ax.plot(*q, "o", ms=6, color=INK, alpha=alpha, zorder=4)
sx, sy = np.sign(q[0]), (1.0 if q[1] >= 0 else -1.0)
if names == "inside":
ax.text(0.93 * q[0], q[1] + 0.12, name, color=INK, ha="right" if sx > 0 else "left", va="bottom")
elif names == "outside":
ax.text(q[0] + 0.15 * sx, q[1] + 0.2 * sy, name, color=INK,
ha="left" if sx > 0 else "right", va="bottom" if sy > 0 else "top")
p_hand = np.array([-R, 0.0])
v_ground = np.array([V_THROW, 0.0]) + OMEGA * np.array([-p_hand[1], p_hand[0]])
t_cross = -2 * p_hand @ v_ground / (v_ground @ v_ground) # |p + v t| = R again
t = np.linspace(0, t_cross, 200)
ground_path = p_hand[:, None] + v_ground[:, None] * t
rider_path = np.array([rot(-OMEGA * ti) @ ground_path[:, i] for i, ti in enumerate(t)]).T
fig, (ax1, ax2) = plt.subplots(1, 2, sharey=True)
scene(ax1, 0.0, -OMEGA * t_cross)
people(ax1, 0.0, names="inside")
ax1.plot(*rider_path, color=ACCENT)
hit = rider_path[:, -1]
ax1.plot(*np.column_stack([hit, [R, 0]]), color=MUTED, lw=1.2, ls="--")
near = np.array([R, 0]) + 0.25 * (hit - [R, 0]) # the miss, named near the friend's end
ax1.text(near[0] - 0.12, near[1], f"{np.hypot(*(hit - [R, 0])):.2f} m", color=MUTED, ha="right", va="center",
bbox=dict(fc="white", ec="none", pad=1))
ax1.set(ylabel="y / m")
a_cross = OMEGA * t_cross
scene(ax2, a_cross, 0.0)
people(ax2, 0.0, alpha=0.35) # where they sat at the throw
people(ax2, a_cross, names="outside")
th = np.linspace(0, a_cross, 50)
ax2.plot(R * np.cos(th), R * np.sin(th), color=MUTED, lw=3) # the friend's arc during the flight
ax2.plot(*ground_path, color=SECOND)
plt.show()
From the ground nothing happens that Newton would not expect. The ball leaves the hand with the throw plus the hand's own 3.14 m/s along the rim, so it flies straight at 10.48 m/s, already aimed right of the friend, while the friend moves on. It misses because both moved.
On the platform the ball curves with no force behind it, and Newton's law needs two more terms. Write the ground position as \(\mathbf r = Q(\Omega t)\,\mathbf r'\), with \(Q\) the rotation matrix. Differentiate once for \(\mathbf v = Q(\mathbf v' + \boldsymbol\Omega\times\mathbf r')\), once more, and they appear:
Primes mark the rider's coordinates, and \(\boldsymbol\Omega = \Omega\hat{\mathbf z}\). The first new term, the Coriolis force, is perpendicular to the velocity: it bends the path, to the right for a counterclockwise turn, without changing the speed. The centrifugal force \(m\Omega^2\mathbf r'\) points outward. In components, with no real force:
From the ground, over the straight-across time \(t \approx 2R/v\) = 0.4 s, the friend moves on by \(\Omega R t\) = 1.26 m, and the ball drifts sideways with the hand by another \(\Omega R t\). The rider sees a sideways acceleration \(2\Omega v\) instead, which in that time moves the ball by \(\tfrac12(2\Omega v)t^2 = \Omega v t^2\). With \(t = 2R/v\) both give \(4\Omega R^2/v\) = 2.51 m: one deflection, said in two frames. The centrifugal force, left out here, points mostly along the path and adds only 0.10 m to the integrated miss of 2.23 m.
The cell integrates these equations with solve_ivp, which takes a function returning the rates of change of the state \((x', y', \dot x', \dot y')\). An event function, \(|\mathbf r'| - R\), marked terminal, stops it at the rim; t_events and y_events hold that moment, and solve_ivp from the ground up has the rest. Then it turns the ground line into the rider's frame:
def rider(t, s):
"""Newton's law on the turning platform: Coriolis and centrifugal accelerations, no real force."""
x, y, vx, vy = s
return [vx, vy, 2 * OMEGA * vy + OMEGA**2 * x, -2 * OMEGA * vx + OMEGA**2 * y]
def rim(t, s):
return np.hypot(s[0], s[1]) - R
rim.terminal = True
rim.direction = 1 # only on the way out: the throw starts on the rim
sol = solve_ivp(rider, (0, 2), [-R, 0, V_THROW, 0], events=rim, rtol=1e-11, atol=1e-12)
t_rim, hit = sol.t_events[0][0], sol.y_events[0][0][:2]
miss = np.linalg.norm(hit - [R, 0])
def to_rider(p, t):
return rot(-OMEGA * t) @ p # ground coordinates, seen from the platform turned by Ωt
p0 = np.array([-R, 0.0])
v0 = np.array([V_THROW, 0.0]) + OMEGA * np.array([-p0[1], p0[0]]) # the throw plus the hand's Ω ẑ × r
gap = np.linalg.norm(to_rider(p0 + v0 * t_rim, t_rim) - hit)
estimate = 4 * OMEGA * R**2 / V_THROW
print(f"the ball reaches the rim after {t_rim:.3f} s, {miss:.2f} m to the friend's {'right' if hit[1] < 0 else 'left'}")
print(f"the straight ground line, turned into the rider's frame, lands {gap:.0e} m from that point")
print(f"estimate 4ΩR²/v = {estimate:.2f} m, the miss {100 * (1 - miss / estimate):.0f} % under it; "
f"the friend's arc in 2R/v: {OMEGA * R * 2 * R / V_THROW:.2f} m")
the ball reaches the rim after 0.364 s, 2.23 m to the friend's right the straight ground line, turned into the rider's frame, lands 4e-12 m from that point estimate 4ΩR²/v = 2.51 m, the miss 11 % under it; the friend's arc in 2R/v: 1.26 m
The ball crosses the rim after 0.364 s, 2.23 m to the friend's right. The miss is 11 % under the estimate because the ball reaches the rim before 0.4 s, on a chord shorter than the diameter.
On a dish the ball comes back
The dish is a bowl, the paraboloid \(z = \Omega^2 r^2/(2g)\), 0.50 m deep for the 4 s turn. Switch to Dish and press Throw: nudged inward at 1 m/s from its mark 1 m out, the ball runs a small clockwise circle back to the mark at 2.00 s. On the flat platform the same push reaches the rim after 1.26 s:
Show code
p_mark = np.array([-R / 2, 0.0])
w_mark = np.array([V_PUSH, 0.0]) + OMEGA * np.array([-p_mark[1], p_mark[0]]) # ground velocity of the push
a_loop = V_PUSH / (2 * OMEGA)
def circle_at(t): # the rider's view: velocity turning at 2Ω
return np.array([p_mark[0] + a_loop * np.sin(2 * OMEGA * t), a_loop * (np.cos(2 * OMEGA * t) - 1)])
def ellipse_at(t): # the ground view: a two-dimensional spring
return np.outer(p_mark, np.cos(OMEGA * t)) + np.outer(w_mark / OMEGA, np.sin(OMEGA * t))
qa, qb, qc = w_mark @ w_mark, 2 * p_mark @ w_mark, p_mark @ p_mark - R**2
t_flat = (-qb + np.sqrt(qb**2 - 4 * qa * qc)) / (2 * qa) # the flat push reaches the rim
tf = np.linspace(0, t_flat, 200)
flat_path = np.array([rot(-OMEGA * ti) @ (p_mark + w_mark * ti) for ti in tf]).T
fig, (ax1, ax2) = plt.subplots(1, 2, sharey=True)
for ax, a_p in [(ax1, 0.0), (ax2, 0.0)]:
scene(ax, a_p, 0.0, dish=True)
people(ax, a_p)
ax.plot(*p_mark, "x", ms=8, mew=2, color=MUTED, zorder=4)
clear = dict(fc="white", ec="none", pad=1) # labels on the dish's guide circles stay readable
ax1.text(p_mark[0] + 0.12, 0.08, "start", color=MUTED, ha="left", va="bottom", bbox=clear)
ax1.plot(*flat_path, color=MUTED, lw=1.2, ls="--")
ax1.text(*(flat_path[:, -1] + [0.22, 0.1]), f"flat, {t_flat:.2f} s", color=MUTED, va="bottom", bbox=clear)
ax1.plot(*circle_at(np.linspace(0, 2, 200)), color=ACCENT)
ax1.plot(*circle_at(np.arange(0, 2, 0.25)), "o", ms=4, color=ACCENT)
ax1.text(p_mark[0], -2 * a_loop - 0.15, f"{np.pi / OMEGA:.2f} s", color=ACCENT, ha="center", va="top", bbox=clear)
ax1.set(ylabel="y / m")
ax2.plot(*ellipse_at(np.linspace(0, 4, 300)), color=SECOND)
ax2.plot(*ellipse_at(np.arange(0, 4, 0.25)), "o", ms=4, color=SECOND)
ax2.text(0, -1.08, f"{2 * np.pi / OMEGA:.2f} s", color=SECOND, ha="center", va="top", bbox=clear)
for ax in (ax1, ax2):
ax.set(xlim=(-2.4, 2.4), ylim=(-2.4, 2.4)) # the dish and what happens on it
plt.show()
The slope pulls the ball inward with \(g\,dz/dr = \Omega^2 r\), exactly the centrifugal term, so a ball at rest on it stays at rest anywhere. The game keeps only this horizontal pull on a frictionless point and ignores its rise and fall with the bowl, which is fair while the slope is gentle: on the exact bowl the loop takes 2.08 s instead of 2.00 s and closes within 2 cm.
In the rider's frame only the Coriolis force is left. It turns the velocity at the constant rate \(2\Omega\) without changing its length, and such a velocity traces a circle, of radius \(v/(2\Omega)\), closed when the velocity has turned once: after \(2\pi/(2\Omega) = \pi/\Omega\), half a rotation period. The turn is to the right, so the circle runs clockwise, against the platform.
From the ground the ball is a mass on a two-dimensional spring, \(\mathbf a = -\Omega^2\mathbf r\), and runs an ellipse about the axis once per turn. After 2 s, half of it, the ball is opposite its start and the platform has turned half a turn, so for the rider it is back where it began: one motion, two periods.
The Earth is a dish: its flattened shape makes effective gravity, the pull and the centrifugal force together, perpendicular to the ground. Of the rotation, only the part about the local vertical, \(\Omega_E\sin\varphi\) at latitude \(\varphi\), turns horizontal motion sideways; the rest pushes up or down. So water set moving and left alone runs the loop above, clockwise in the north, in \(\pi/(\Omega_E\sin\varphi)\): the ocean's inertial oscillations.
The cell drops the centrifugal term. An integer terminal stops at that occurrence of the event, here the second time the ball moves along the push, the push itself being the first. It also runs the ground spring, the flat push, and the Earth's period at five latitudes:
def dish(t, s):
"""On the dish the slope cancels the centrifugal term: Coriolis alone."""
x, y, vx, vy = s
return [vx, vy, 2 * OMEGA * vy, -2 * OMEGA * vx]
def heading(t, s):
return s[3] # the y-velocity falls through zero where the ball moves along +x
heading.direction = -1
heading.terminal = 2 # stop at the second time; the push itself is the first
start = [-R / 2, 0, V_PUSH, 0]
loop = solve_ivp(dish, (0, 3), start, events=heading, dense_output=True, rtol=1e-11, atol=1e-12)
t_loop, back = loop.t_events[0][1], loop.y_events[0][1][:2]
r_rider = np.hypot(*loop.sol(np.linspace(0, t_loop, 2001))[:2]).max()
def spring(t, s): # the same ball from the ground
return [s[2], s[3], -OMEGA**2 * s[0], -OMEGA**2 * s[1]]
p1 = np.array(start[:2])
w1 = np.array(start[2:]) + OMEGA * np.array([-p1[1], p1[0]])
ground = solve_ivp(spring, (0, T), [*p1, *w1], dense_output=True, rtol=1e-11, atol=1e-12)
r_ground = np.hypot(*ground.sol(np.linspace(0, T, 4001))[:2]).max()
flat = solve_ivp(rider, (0, 3), start, events=rim, rtol=1e-11, atol=1e-12)
assert max(r_rider, r_ground) < R
print(f"dish: back on the mark after {t_loop:.2f} s ({np.linalg.norm(back - p1):.0e} m off), "
f"radius v/(2Ω) = {V_PUSH / (2 * OMEGA):.3f} m")
print(f"farthest from the center: {r_rider:.2f} m on the platform, {r_ground:.2f} m on the ground")
print(f"the ground ellipse closes after {T:.2f} s ({np.linalg.norm(ground.y[:2, -1] - p1):.0e} m off)")
print(f"flat platform: the same push reaches the rim after {flat.t_events[0][0]:.2f} s")
lat = [15, 30, 45, 60, 90]
print("inertial period π/(Ω_E sin φ): " + ", ".join(
f"{p}° {np.pi / (OMEGA_E * np.sin(np.radians(p))) / 3600:.1f} h" for p in lat))
dish: back on the mark after 2.00 s (4e-12 m off), radius v/(2Ω) = 0.318 m farthest from the center: 1.37 m on the platform, 1.37 m on the ground the ground ellipse closes after 4.00 s (7e-12 m off) flat platform: the same push reaches the rim after 1.26 s inertial period π/(Ω_E sin φ): 15° 46.2 h, 30° 23.9 h, 45° 16.9 h, 60° 13.8 h, 90° 12.0 h
The loop closes after 2.00 s at a radius of 0.318 m, the ground ellipse after 4.00 s, and both stay within 1.37 m of the center. On the Earth the same loop takes 16.9 h at 45°.
How the game computes it
The state. Everything that changes lives in one object, in m, m/s, and s, with the ball's position p and velocity v in the ground frame:
const DEFAULTS = {t: 0, angle: 0, period: 4, dish: false, slow: 0.25, p: [-R, 0], v: [0, 0],
flying: false, tThrow: 0, angleThrow: 0, tStop: null, miss: null, trail: []};
One tick. The browser calls frame once per refresh through requestAnimationFrame. tick caps the elapsed time at 50 ms so a tab back from the background does not fling the ball, multiplies it by the slow-motion factor, and advances in fixed steps of 2 ms: the angle by \(\Omega h\), the ball by velocity Verlet in the ground frame, half a kick (the velocity changes by \(\mathbf a h/2\)), a drift (the position by \(\mathbf v h\)), half a kick. The acceleration \(\mathbf a\) is \(-\Omega^2\mathbf p\) on the dish and zero on the flat platform. On the dish the ball is a spring, and a plain Euler step would let the loop grow turn by turn, while Verlet keeps it closed; Velocity Verlet: keep the Earth on its orbit for a thousand years explains why.
function step(h) {
const w = omega(), k = state.dish ? -w * w : 0; // the dish's slope pulls with -Ω² p
state.v = add(state.v, scale(state.p, (k * h) / 2)); // half a kick
state.p = add(state.p, scale(state.v, h)); // a drift
state.v = add(state.v, scale(state.p, (k * h) / 2)); // half a kick
state.angle += w * h;
state.t += h;
}
The throw. A flick's vector, 0.1 m/s per pixel of drag and at most 15 m/s, is the throw relative to the hand. throwBall turns it from the platform's axes into the ground's by the current angle and adds the hand's own velocity \(\boldsymbol\Omega\times\mathbf p\), the one line where the rider's motion enters:
let u = scale([drag.dx, -drag.dy], GEAR); // screen y points down
function throwBall(u) {
hold();
const w = omega(), p = state.p;
state.v = add(rotate(u, state.angle), [-w * p[1], w * p[0]]); // plus the hand's own Ω ẑ × p
Two views from one state. The ground view draws p as it is and the platform turned by angle; the rider's view draws p turned back by angle and the fence posts turned the other way. Neither view integrates anything, so they agree by construction. The turn is one function, the twin of to_rider in the first effect's cell:
const toRider = (p, angle) => rotate(p, -angle); // ground coordinates into the platform's
The miss. When the ball first passes the rim, the game finds where in the last step it crossed, by linear interpolation of its distance from the center, and measures from there to the friend's ground position at that moment. On the dish a ball that reaches the rim stops there, which only a hard flick does: the stated push stays within 1.37 m.
const f = (R - len(prev)) / (len(state.p) - len(prev));
const late = H * (1 - f), angle = state.angle - omega() * late;
const hit = add(prev, scale(sub(state.p, prev), f));
From the notebook into the game. A notebook cell puts the radius, the periods and slow-motion steps, the stated throws with their start points, \((-R, 0)\) on the flat platform and \((-R/2, 0)\) on the dish, the step size, the views' half-width, 3.2 m, and the flick's gearing as JSON into a <script type="application/json" id="data"> element, so the game fetches nothing. The cell below ports step and throw_ball to Python line for line and runs the stated throw and the dish loop with them:
H = 0.002 # s, the game's step
def step(state, h=H):
w = 2 * np.pi / state["period"]
k = -w * w if state["dish"] else 0.0 # the dish's slope pulls with -Ω² p
state["v"] = state["v"] + state["p"] * k * h / 2 # half a kick
state["p"] = state["p"] + state["v"] * h # a drift
state["v"] = state["v"] + state["p"] * k * h / 2 # half a kick
state["angle"] += w * h
state["t"] += h
def throw_ball(state, start, u):
w = 2 * np.pi / state["period"]
p = state["p"] = rot(state["angle"]) @ start # the hand, turned with the platform
state["v"] = rot(state["angle"]) @ u + w * np.array([-p[1], p[0]]) # plus the hand's own Ω ẑ × p
game = dict(t=0.0, angle=0.0, period=T, dish=False)
throw_ball(game, np.array([-R, 0.0]), np.array([V_THROW, 0.0]))
while np.linalg.norm(game["p"]) < R or game["t"] == 0:
prev = game["p"]
step(game)
f = (R - np.linalg.norm(prev)) / (np.linalg.norm(game["p"]) - np.linalg.norm(prev))
late = H * (1 - f)
friend = rot(game["angle"] - OMEGA * late) @ [R, 0]
miss_game = np.linalg.norm(prev + f * (game["p"] - prev) - friend)
loop_game = dict(t=0.0, angle=0.0, period=T, dish=True)
throw_ball(loop_game, np.array([-R / 2, 0.0]), np.array([V_PUSH, 0.0]))
r_game = 0.0
for n in range(round(T / H)): # one ground ellipse, 4 s
step(loop_game)
r_game = max(r_game, np.linalg.norm(loop_game["p"]))
if n + 1 == round(t_loop / H):
back_game = to_rider(loop_game["p"], loop_game["t"])
assert r_game < R
print(f"throw: rim after {game['t'] - late:.4f} s, miss {miss_game:.4f} m; "
f"against solve_ivp {1e6 * abs(game['t'] - late - t_rim):.2f} μs and {1e6 * abs(miss_game - miss):.2f} μm")
print(f"dish: after {round(t_loop / H)} steps the ball is {1e3 * np.linalg.norm(back_game - p1):.3f} mm from the mark; "
f"farthest {r_game:.2f} m from the center")
throw: rim after 0.3641 s, miss 2.2266 m; against solve_ivp 0.03 μs and 0.18 μm dish: after 1000 steps the ball is 0.002 mm from the mark; farthest 1.37 m from the center
The game's step puts the rim crossing within 0.03 μs and the miss within 0.18 μm of solve_ivp, and after 1,000 steps on the dish the ball is back on its mark to 0.002 mm.
The cell that writes the game. Keys in sorted order and fixed rounding make two runs write the same bytes, and </ written as <\/ keeps the browser from ending the script element inside the data. The HTML page is a template string:
Show code
TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Coriolis force: throw a ball across a merry-go-round</title>
<style>
:root { --bg: #ffffff; --fg: #1f2a44; --ink: #1f2a44; --accent: #c8553d; --second: #2a7f9e; --muted: #8a8f98; }
@media (prefers-color-scheme: dark) {
:root { --bg: #141a2a; --fg: #e9ecf3; --ink: #c9d0e3; --accent: #e3826b; --second: #6ec1de; --muted: #8e97ab; }
}
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: 6px 10px; padding: 6px; box-sizing: border-box;
grid-template: "rider ground" minmax(0, 1fr) "read read" auto "ctrl ctrl" auto / minmax(0, 1fr) minmax(0, 1fr); }
body.upright { font-size: 14px; gap: 4px; padding: 4px 6px;
grid-template: "rider" minmax(0, 1fr) "ground" minmax(0, 1fr) "read" auto "ctrl" auto / minmax(0, 1fr); }
.box { position: relative; min-height: 0; }
.box canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }
#riderbox { grid-area: rider; } #groundbox { grid-area: ground; }
#read { grid-area: read; display: flex; flex-wrap: wrap; gap: 2px 16px; font-variant-numeric: tabular-nums; }
.num { font-weight: 600; }
.acc { color: var(--accent); } .sec { color: var(--second); }
#ctrl { grid-area: ctrl; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; }
.group { display: flex; align-items: center; gap: 6px; }
#period-val, #slow-val { min-width: 3.6em; 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.9em; touch-action: manipulation; }
button.step { padding: 0; width: 2em; }
button[aria-pressed="false"] { color: var(--second); background: transparent; box-shadow: inset 0 0 0 1.5px var(--second); }
#throw { background: var(--accent); }
@media (pointer: coarse) { button { min-height: 44px; min-width: 44px; } }
</style>
</head>
<body>
<div class="box" id="riderbox"><canvas id="rider" aria-label="The rider's view: the platform still, the ground turning"></canvas></div>
<div class="box" id="groundbox"><canvas id="ground" aria-label="The ground view: the platform turning"></canvas></div>
<div id="read">
<span class="acc">rider <span class="num" id="v-rider"></span></span>
<span class="sec">ground <span class="num" id="v-ground"></span></span>
<span>miss <span class="num" id="miss"></span></span>
<span>time <span class="num" id="clock"></span></span>
</div>
<div id="ctrl">
<div class="group"><span>Period</span><button class="step" id="period-minus" title="Shorter period ([)"
aria-label="Shorter period">−</button><span id="period-val"></span><button class="step" id="period-plus"
title="Longer period (])" aria-label="Longer period">+</button></div>
<div class="group"><button id="flat" title="Flat platform (D)">Flat</button><button id="dish" title="Dish (D)">Dish</button></div>
<div class="group"><button id="throw" title="Throw (T)">Throw</button><button id="reset" title="Reset (R)">Reset</button></div>
<div class="group"><span>Time</span><button class="step" id="slow-minus" title="Slower"
aria-label="Slower">−</button><span id="slow-val"></span><button class="step" id="slow-plus" title="Faster"
aria-label="Faster">+</button></div>
</div>
<script type="application/json" id="data">{{DATA}}</script>
<script>{{SCRIPT}}</script>
</body>
</html>
"""
game_data = {
"radius": R, "h": H, "half": HALF, "gear": 0.1, "v_max": 15.0,
"periods": [2, 3, 4, 6, 8, 12, 0], # s; 0 is at rest
"slows": [0.1, 0.25, 0.5, 1],
"throws": {"flat": {"start": [-R, 0.0], "u": [V_THROW, 0.0]},
"dish": {"start": [-R / 2, 0.0], "u": [V_PUSH, 0.0]}},
}
# 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/carousel/game.js").read_text(encoding="utf-8")
Path("assets").mkdir(exist_ok=True)
Path("assets/carousel.html").write_text(TEMPLATE.replace("{{DATA}}", data).replace("{{SCRIPT}}", script),
encoding="utf-8", newline="\n")
print(f"assets/carousel.html: {Path('assets/carousel.html').stat().st_size / 1024:.0f} kB")
assets/carousel.html: 18 kB
Variations
- The southern hemisphere. Turn the platform clockwise, with a negative \(\Omega\): the throw bends to the left, and the dish loop runs counterclockwise.
- A low on the platform. Add a constant pull toward the center to the dish's kick, a stand-in for the pressure gradient around a low, a center of low pressure. A ball let go at rest 1 m out starts inward and is turned to the right, but the pull keeps acting, so it moves across the pull with the center on its left: with 0.3 m/s² it circles 21° counterclockwise in 4 s and comes no closer than 0.94 m, as air circles a northern low.
- Friction on the dish. Add a drag \(-k\mathbf v'\), on the velocity relative to the platform, to the kick: the loop becomes a spiral that comes to rest on the dish. With the pull above as well, the ball now spirals into the center, as friction near the ground lets real air spiral into a low.
- A Foucault pendulum on the platform. Make the dish's kick \(-\omega_0^2\mathbf p\) with \(\omega_0\) larger than \(\Omega\), the stiffness of a pendulum hung over the center, and push the ball from the center. In the rider's view its swing plane turns once per rotation, 4 s here, and at latitude \(\varphi\) once per \(2\pi/(\Omega_E\sin\varphi)\), 33.8 h at 45°.
Further reading
- J. R. Taylor, Classical Mechanics (University Science Books, 2005), chapter 9, for the Coriolis and centrifugal forces derived in a rotating frame, and for the Foucault pendulum.
- J. Marshall and R. A. Plumb, Atmosphere, Ocean, and Climate Dynamics (Academic Press, 2008), chapter 6, for the rotating tank with a parabolic surface, the laboratory twin of the dish, and for inertial circles.
- The two browser interfaces under
game.js, as MDN documents them: the Canvas API, which paints the platform and the ball on every frame, and pointer events, the interface that reports a drag by finger or by mouse in the same way. - Related tutorials on this site: solve_ivp from the ground up: the pendulum beyond small angles, Velocity Verlet: keep the Earth on its orbit for a thousand years, Draw a phase portrait of a two-variable ODE system, 2D turbulence with a pseudo-spectral solver: eddies merge into vortices, whose beta-plane Variation adds the Coriolis parameter, and the frame-change Minigame Gravity assist past Jupiter: faster in the Sun's frame, not in Jupiter's.
- Download the notebook. It was executed with the library versions in the header.
- Download the game, one HTML file, script and data inside, that runs offline in any browser.