Classical mechanics

Double pendulum

A pendulum hanging from another pendulum. Two rods, four numbers, and a system that no closed formula can solve. The first pendulum is predictable for centuries. Adding the second one breaks that permanently.

EXP-003

Move the cursor over the canvas — it is part of the simulation.

Drag your finger across the canvas — it is part of the simulation.

What to look for

The trail never repeats a shape. Reload the page and it will draw something else, because the accumulated floating-point error is enough to send it down a different path.

The maths

L = T − V

The Lagrangian: kinetic minus potential energy. The equations of motion come out of it rather than from drawing force diagrams.

d/dt (∂L/∂θ̇) − ∂L/∂θ = 0

Euler-Lagrange, applied to each angle. Expanding it for two coupled rods gives the pair of second-order equations the simulation integrates.

λ > 0

The Lyapunov exponent is positive, which is the formal definition of chaos: nearby trajectories separate exponentially rather than linearly.

How it is built

The expanded equations are integrated three substeps per frame for stability, with a damping factor of 0.9999 so the motion decays over minutes instead of running forever. The tip leaves a 900-point trail that fades along its length.

The code, step by step

Cut straight from lab.js. These are the real lines that run above, not a simplified version.

01

Grab it with the cursor

if (M.in) {
  a1 = Math.atan2(M.x - ox, M.y - oy);
  a2 = a1; v1 = v2 = 0; trace.length = 0;

While the pointer is over the canvas the pendulum stops integrating and simply points at it, with both velocities zeroed. Leave the canvas and it falls from wherever you left it.

02

The equations of motion

const d = 2 * m1 + m2 - m2 * c(2 * a1 - 2 * a2);
const n1 = -g * (2 * m1 + m2) * s(a1) - m2 * g * s(a1 - 2 * a2)
         - 2 * s(a1 - a2) * m2 * (v2 * v2 * l2 + v1 * v1 * l1 * c(a1 - a2));
const n2 = 2 * s(a1 - a2) * (v1 * v1 * l1 * (m1 + m2) + g * (m1 + m2) * c(a1)
         + v2 * v2 * l2 * m2 * c(a1 - a2));
v1 += (n1 / (l1 * d)) * 0.05; v2 += (n2 / (l2 * d)) * 0.05;
a1 += v1 * 0.05; a2 += v2 * 0.05;
v1 *= P.damp; v2 *= P.damp;

n1 and n2 are the expanded Euler-Lagrange numerators and d the shared denominator. Three substeps per frame keep it stable. The damping slider is that final multiplication.

03

From angles to positions

const x1 = ox + l1 * Math.sin(a1), y1 = oy + l1 * Math.cos(a1);
const x2 = x1 + l2 * Math.sin(a2), y2 = y1 + l2 * Math.cos(a2);
trace.push([x2, y2]);
if (trace.length > 900) trace.shift();

The state is two angles, not two points. Positions come out with plain trigonometry, chained: the second rod hangs from wherever the first one ended up.

Why it matters

It is the cheapest demonstration that determinism and predictability are not the same thing. Every step follows from the previous one with no randomness anywhere, and it is still impossible to say where the tip will be in thirty seconds.

The whole thing

Everything above, in one piece. This is the entire experiment as it lives in lab.js.

FULL

lab.js · pendulum

{ id: 'pendulum',
      name: L('Double pendulum', 'Péndulo doble'),
      note: L('Two rods, one equation, and no way to predict minute two.',
              'Dos barras, una ecuación, y ninguna forma de predecir el minuto dos.'),
      params: [
        { key: 'g',    label: L('Gravity', 'Gravedad'),   min: 0.1, max: 2,   step: 0.05, def: 0.6 },
        { key: 'damp', label: L('Damping', 'Amortiguación'), min: 0.99, max: 1, step: 0.0005, def: 0.9999 },
        { key: 'm2',   label: L('Lower mass', 'Masa inferior'), min: 1, max: 40, step: 1, def: 10 }
      ],
      make() {
        let a1 = Math.PI / 2 + 0.6, a2 = Math.PI / 2 + 0.4, v1 = 0, v2 = 0, trace = [];
        return {
          reset() { a1 = Math.PI / 2 + 0.6; a2 = Math.PI / 2 + 0.4; v1 = v2 = 0; trace = []; },
          step(ctx, w, h, t, acc, P, M) {
            const m1 = 10, m2 = P.m2, l1 = Math.min(w, h) * 0.20, l2 = Math.min(w, h) * 0.20, g = P.g;
            const ox = w / 2, oy = h * 0.34;
            // Con el mouse dentro se agarra el péndulo: los ángulos apuntan al cursor
            if (M.in) {
              a1 = Math.atan2(M.x - ox, M.y - oy);
              a2 = a1; v1 = v2 = 0; trace.length = 0;
            } else {
              for (let i = 0; i < 3; i++) {
                const s = Math.sin, c = Math.cos;
                const d = 2 * m1 + m2 - m2 * c(2 * a1 - 2 * a2);
                const n1 = -g * (2 * m1 + m2) * s(a1) - m2 * g * s(a1 - 2 * a2)
                         - 2 * s(a1 - a2) * m2 * (v2 * v2 * l2 + v1 * v1 * l1 * c(a1 - a2));
                const n2 = 2 * s(a1 - a2) * (v1 * v1 * l1 * (m1 + m2) + g * (m1 + m2) * c(a1)
                         + v2 * v2 * l2 * m2 * c(a1 - a2));
                v1 += (n1 / (l1 * d)) * 0.05; v2 += (n2 / (l2 * d)) * 0.05;
                a1 += v1 * 0.05; a2 += v2 * 0.05;
                v1 *= P.damp; v2 *= P.damp;
              }
            }
            const x1 = ox + l1 * Math.sin(a1), y1 = oy + l1 * Math.cos(a1);
            const x2 = x1 + l2 * Math.sin(a2), y2 = y1 + l2 * Math.cos(a2);
            trace.push([x2, y2]);
            if (trace.length > 900) trace.shift();
            for (let i = 1; i < trace.length; i++) {
              ctx.strokeStyle = `rgba(${acc},${((i / trace.length) * 0.5).toFixed(3)})`;
              ctx.lineWidth = 1;
              ctx.beginPath(); ctx.moveTo(trace[i - 1][0], trace[i - 1][1]);
              ctx.lineTo(trace[i][0], trace[i][1]); ctx.stroke();
            }
            ctx.strokeStyle = `rgba(${acc},0.85)`; ctx.lineWidth = 1.2;
            ctx.beginPath(); ctx.moveTo(ox, oy); ctx.lineTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
            ctx.fillStyle = `rgba(${acc},0.95)`;
            [[x1, y1, 2.6], [x2, y2, 2 + m2 * 0.12]].forEach(p => {
              ctx.beginPath(); ctx.arc(p[0], p[1], p[2], 0, TAU); ctx.fill();
            });
          }
        };
      }
    },