Pendulum - Action angle coordinates

Author

Etienne Meunier

Published

November 3, 2026

Show imports
import numpy as np
import torch
import torch.nn as nn
import matplotlib.pyplot as plt
from scipy.integrate import odeint
from scipy.special import ellipj, ellipk, ellipe, ellipkinc
from matplotlib.animation import FuncAnimation
from IPython.display import HTML

plt.rcParams["figure.dpi"] = 72          # animations are embedded frame by frame, keep them light

\[ \ddot\theta = -\sin\theta \qquad\Longrightarrow\qquad \begin{cases} \dot\theta = \omega \\ \dot\omega = -\sin\theta \end{cases} \]

That’s the pendulum equation (with \(m = g = L = 1\)), we can integrate it numerically to get trajectories from an initial state \((\theta_0, \dot\theta_0)\).

Code
def pendulum(x):
    """x = (..., 2) states (theta, thetadot)  ->  d/dt x = (thetadot, -sin(theta))"""
    theta, thetadot = x[..., 0], x[..., 1]
    return np.stack([thetadot, -np.sin(theta)], -1)


def integrate(x0, t):
    """odeint works on a flat state, so we ravel the batch of initial conditions on the
    way in and reshape on the way out -> (time, n_traj, 2)."""
    x0 = np.asarray(x0, float)
    flat = odeint(lambda x, _: pendulum(x.reshape(-1, 2)).ravel(), x0.ravel(), t,
                  rtol=1e-10, atol=1e-12)
    return flat.reshape(len(t), *x0.shape)


theta0 = np.array([0.6, 1.4, 2.2, 2.8])
x0 = np.stack([theta0, np.zeros_like(theta0)], -1)
t = np.linspace(0, 20, 401)

traj = integrate(x0, t)          # (time, traj, 2)
traj.shape
(401, 4, 2)
Show plotting code
COLORS = [plt.get_cmap("viridis")(v) for v in np.linspace(.15, .85, len(theta0))]
STRIDE = 5          # animate every 5th time step


def pendulum_animation(t, theta, rows, colors=None, stride=STRIDE, interval=60):
    """Left: the pendulums drawn in 2d.  Right: one panel per signal, where `rows` is a
    list of (label, array of shape (time, n_traj)), with a cursor following the animation."""
    colors = COLORS if colors is None else colors
    n = theta.shape[1]
    fig = plt.figure(figsize=(11, 1.6 * len(rows) + 1.1))
    gs = fig.add_gridspec(len(rows), 2, width_ratios=[1, 1.7], hspace=.15, wspace=.22)

    ax = fig.add_subplot(gs[:, 0])
    ax.set(xlim=(-1.4, 1.4), ylim=(-1.4, 1.4), xticks=[], yticks=[], title="the pendulum")
    ax.set_aspect("equal")
    ax.plot(0, 0, "k+", ms=8)
    rods = [ax.plot([], [], "-", color=colors[i], lw=1.5)[0] for i in range(n)]
    bobs = [ax.plot([], [], "o", color=colors[i], ms=11)[0] for i in range(n)]

    cursors, dots, axes = [], [], []
    for r, (label, y) in enumerate(rows):
        a = fig.add_subplot(gs[r, 1])
        for i in range(n):
            a.plot(t, y[:, i], color=colors[i], lw=1.2)
            dots.append((a.plot([], [], "o", color=colors[i], ms=6)[0], y[:, i]))
        cursors.append(a.axvline(t[0], color="k", lw=.8, alpha=.5))
        a.set(ylabel=label, xlim=(t[0], t[-1]))
        a.tick_params(labelbottom=(r == len(rows) - 1))
        if r == len(rows) - 1:
            a.set_xlabel("t")
        axes.append(a)

    def update(f):
        for i, (rod, bob) in enumerate(zip(rods, bobs)):
            px, py = np.sin(theta[f, i]), -np.cos(theta[f, i])
            rod.set_data([0, px], [0, py])
            bob.set_data([px], [py])
        for c in cursors:
            c.set_xdata([t[f], t[f]])
        for dot, y in dots:
            dot.set_data([t[f]], [y[f]])
        return rods + bobs + cursors + [d for d, _ in dots]

    anim = FuncAnimation(fig, update, frames=range(0, len(t), stride),
                         interval=interval, blit=False)
    plt.close(fig)
    return anim, axes


def save_gif(anim, path, fps=16):
    """Save a FuncAnimation to a gif, in place of the notebook's HTML(anim.to_jshtml())."""
    anim.save(path, writer="pillow", fps=fps)


anim, _ = pendulum_animation(t, traj[..., 0],
                             [(r"$\theta$", traj[..., 0]),
                              (r"$\dot\theta$", traj[..., 1])])
save_gif(anim, "images/anim-pendulum.gif")

Four pendulums released from rest at \(\theta_0 = 0.6, 1.4, 2.2, 2.8\), with \(\theta\) and \(\dot\theta\) traced alongside.

1 Energy and period

We can first derive \(\kH\), the energy of the system :

\[ \kH = \underbrace{\tfrac{1}{2}\dot\theta^2}_{\text{kinetic}} \;+\; \underbrace{1 - \cos\theta}_{\text{potential}} \;\in\; [0, 2) \]

The energy is dependent of \(\theta\) and \(\dot \theta\) and as the system is conservative is going to be preserved through all the trajectory.

The period, which indicates the time that takes for the pendulum to do a full revolution is also a property of the trajectory. We can compute it with the elliptic integral of the first kind:

\[ T(k) = 4\,F\!\left(\tfrac{\pi}{2}, k\right), \qquad k = \sqrt{\kH / 2}, \qquad F(\varphi, k) = \int_0^{\varphi} \frac{dt}{\sqrt{1 - k^2 \sin^2 t}} \]

\(F(\tfrac{\pi}{2}, k) = K(k)\) is the complete elliptic integral of the first kind (scipy.special.ellipk)

So from a point we get the energy of the system and from the energy we get the period of this system.

Code
def energy(x):
    """H = 1/2 thetadot^2 + 1 - cos(theta), the kinetic plus the potential term."""
    theta, thetadot = x[..., 0], x[..., 1]
    return 0.5 * thetadot ** 2 + 1 - np.cos(theta)


def period(H):
    """T = 4 F(pi/2, k) = 4 K(k) with k = sqrt(H / 2). scipy's ellipk takes m = k ** 2."""
    return 4 * ellipk(np.asarray(H) / 2)


H = energy(traj)            # (time, traj), and it should not depend on time at all
Show plotting code
anim, axes = pendulum_animation(t, traj[..., 0],
                                [(r"$\theta$", traj[..., 0]),
                                 (r"$\dot\theta$", traj[..., 1]),
                                 (r"$H$", H)])
axes[-1].set_ylim(0, 2.05)          # full range of H, so its flatness is honest
save_gif(anim, "images/anim-energy.gif")

Same four pendulums, with their energy \(\kH\) added — flat, as expected of a conserved quantity.

Show plotting code
H0 = H[0]                     # energy is constant along each trajectory, take it at t = 0
T0 = period(H0)

fig = plt.figure(figsize=(11, 4.6))
gs = fig.add_gridspec(1, 2, width_ratios=[1, 1.4], wspace=.25)

ax = fig.add_subplot(gs[0, 0])
ax.set(xlim=(-1.4, 1.4), ylim=(-1.4, 1.4), xticks=[], yticks=[], title="four energy levels")
ax.set_aspect("equal")
ax.plot(0, 0, "k+", ms=8)
rods = [ax.plot([], [], "-", color=c, lw=1.5)[0] for c in COLORS]
bobs = [ax.plot([], [], "o", color=c, ms=11)[0] for c in COLORS]

axc = fig.add_subplot(gs[0, 1])
Hg = np.linspace(1e-4, 1.9999, 400)
axc.plot(Hg, period(Hg), "k-", lw=1.2, alpha=.6)
axc.axhline(2 * np.pi, color="k", ls=":", lw=.8)
marks = [axc.plot([h], [T], "o", color=c, ms=12)[0] for h, T, c in zip(H0, T0, COLORS)]
for h, T in zip(H0, T0):
    axc.annotate(f"T = {T:.2f}", (h, T), textcoords="offset points", xytext=(9, -3), fontsize=8)
axc.set(xlabel="energy $H$", ylabel="period $T$", ylim=(5.5, 16),
        title="one energy level, one period")


def update(f):
    for i, (rod, bob) in enumerate(zip(rods, bobs)):
        px, py = np.sin(traj[f, i, 0]), -np.cos(traj[f, i, 0])
        rod.set_data([0, px], [0, py])
        bob.set_data([px], [py])
    # each marker blinks at its own period, in sync with its pendulum
    for i, m in enumerate(marks):
        m.set_alpha(.35 + .65 * (.5 + .5 * np.cos(2 * np.pi * t[f] / T0[i])))
    return rods + bobs + marks


anim = FuncAnimation(fig, update, frames=range(0, len(t), STRIDE), interval=60, blit=False)
plt.close(fig)
save_gif(anim, "images/anim-period.gif")

Each pendulum blinks in sync with its own period — heavier swings (higher \(\kH\)) take longer.

2 Action - Angle

2.1 The phase-state

Now we saw that each trajectory is going to keep a constant energy and go around with it’s period we can draw it’s trajectory for a full period. This will link several points of the physical space together ! Those points share the same energy and then will always belong to the same trajectories.

Code
def level_set(H, n=200):
    """The H level set of the phase space: thetadot = +- sqrt(2 (H - 1 + cos theta)),
    walked out along the top branch and back along the bottom one, so the curve closes."""
    th = np.linspace(-np.arccos(1 - H), np.arccos(1 - H), n) # Min-max theta values 
    v = np.sqrt(2 * (H - 1 + np.cos(th))) # (theta, H) -> theta dot
    v[0] = v[-1] = 0 # We have numerical erros at the extrema ~ nan
    return np.concatenate([th, th[::-1]]), np.concatenate([v, -v[::-1]])
Show plotting code
stride = 7          # four trajectories run one after the other here, so animate coarser
dt = t[1] - t[0]

fig = plt.figure(figsize=(11, 4.8))
gs = fig.add_gridspec(1, 2, width_ratios=[1, 1.4], wspace=.25)

ax = fig.add_subplot(gs[0, 0])
ax.set(xlim=(-1.4, 1.4), ylim=(-1.4, 1.4), xticks=[], yticks=[], title="one swing at a time")
ax.set_aspect("equal")
ax.plot(0, 0, "k+", ms=8)
rod, = ax.plot([], [], "-", lw=1.5)
bob, = ax.plot([], [], "o", ms=11)

axp = fig.add_subplot(gs[0, 1])
axp.plot(*level_set(1.9999), "k--", lw=1, alpha=.5)
axp.annotate("separatrix, $H = 2$", (0, 2), fontsize=8, ha="center", va="bottom")
fills = [axp.plot([], [], "-", color="0.75", lw=.7, zorder=0)[0] for _ in range(24)]
fill_H = np.linspace(0.05, 1.95, len(fills))
curves = [axp.plot([], [], "-", color=c, lw=1.6)[0] for c in COLORS]
points = [axp.plot([], [], "o", color=c, ms=8)[0] for c in COLORS]
axp.set(xlim=(-np.pi, np.pi), ylim=(-2.35, 2.35), xlabel=r"$\theta$", ylabel=r"$\dot\theta$",
        title="phase space: one trajectory, one closed curve")

# swing each pendulum for exactly one period, tracing its curve, then keep the curve and
# move to the next one. Once the four are drawn, fill in the levels in between.
frames = [("run", i, j) for i in range(len(theta0))
          for j in list(range(0, int(T0[i] / dt), stride)) + [int(T0[i] / dt)]]
frames += [("fill", c, 0) for c in range(len(fills))]
frames += [("fill", len(fills) - 1, 0)] * 8          # hold on the finished picture


def update(frame):
    kind, a, b = frame
    if kind == "run":
        i, j = a, min(b, len(t) - 1)
        rod.set_data([0, np.sin(traj[j, i, 0])], [0, -np.cos(traj[j, i, 0])])
        bob.set_data([np.sin(traj[j, i, 0])], [-np.cos(traj[j, i, 0])])
        rod.set_color(COLORS[i])
        bob.set_color(COLORS[i])
        curves[i].set_data(traj[:j + 1, i, 0], traj[:j + 1, i, 1])
        points[i].set_data([traj[j, i, 0]], [traj[j, i, 1]])
    else:
        for p in points:          # freeze the last pendulum, drop the moving dots
            p.set_data([], [])
        for c in range(a + 1):
            fills[c].set_data(*level_set(fill_H[c]))
    return [rod, bob] + curves + points + fills


anim = FuncAnimation(fig, update, frames=frames, interval=120, blit=False)
plt.close(fig)
save_gif(anim, "images/anim-phase-space.gif")

Each pendulum swings for exactly one period, tracing its closed curve in phase space; the remaining energy levels are filled in once all four are drawn.