codex/intermediate-axis-perturbation-growth.py

intermediate-axis-perturbation-growth.py
"""Original normalized Euler dynamics; run in _media to write the PNG.

Tested with Python 3.14, NumPy 2.3.5, Matplotlib 3.10.7.
Only classical fourth-order Runge--Kutta is used; no invariant projection.
"""

import json
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np

INERTIA = np.array([1.0, 2.0, 3.0])


def rhs(w):
    x, y, z = w
    return np.array([-y * z, z * x, -x * y / 3])


def integrate(w0, stop=14.0, step=0.005):
    t = np.linspace(0, stop, round(stop / step) + 1)
    w = np.empty((len(t), 3))
    w[0] = w0
    for i, h in enumerate(np.diff(t)):
        a = rhs(w[i])
        b = rhs(w[i] + h * a / 2)
        c = rhs(w[i] + h * b / 2)
        d = rhs(w[i] + h * c)
        w[i + 1] = w[i] + h * (a + 2 * b + 2 * c + d) / 6
    return t, w


def invariant_errors(w):
    e = (w**2 * INERTIA).sum(axis=1) / 2
    l2 = (w**2 * INERTIA**2).sum(axis=1)
    return {"energy_relative_drift": float(np.max(abs(e / e[0] - 1))),
            "angular_momentum_squared_relative_drift": float(np.max(abs(l2 / l2[0] - 1)))}


def make_figure():
    plt.rcParams.update({"font.size": 11, "figure.facecolor": "white", "axes.facecolor": "white"})
    fig, (ax, early) = plt.subplots(1, 2, figsize=(10, 4.3), layout="constrained")
    checks = {}
    colors = ["#2171b5", "#c52f24", "#238b45"]
    for axis in range(3):
        transverse = [i for i in range(3) if i != axis]
        w0 = np.zeros(3)
        w0[axis] = 1
        w0[transverse[0]] = 0.01
        t, w = integrate(w0)
        norm = np.linalg.norm(w[:, transverse], axis=1)
        ax.semilogy(t, norm, color=colors[axis], label=f"Axis {axis + 1}, ε = 0.01")
        checks[f"axis_{axis+1}"] = invariant_errors(w)
        _, fine = integrate(w0, step=0.0025)
        checks[f"axis_{axis+1}"]["step_halving_max_difference"] = float(np.max(abs(w - fine[::2])))
    for eps, color in [(0.01, "#c52f24"), (0.001, "#8c6bb1")]:
        t, w = integrate([eps, 1, 0], stop=8)
        norm = np.hypot(w[:, 0], w[:, 2])
        early.semilogy(t, norm, color=color, label=f"Nonlinear, ε = {eps:g}")
        exact_linear = eps * np.sqrt(np.cosh(t / np.sqrt(3))**2 + np.sinh(t / np.sqrt(3))**2 / 3)
        early.semilogy(t, exact_linear, "--", color=color, label=f"Linear, ε = {eps:g}")
        mask = t <= 4
        checks[f"intermediate_eps_{eps}"] = {
            **invariant_errors(w),
            "early_linear_relative_error_through_t4": float(np.max(abs(norm[mask] / exact_linear[mask] - 1)))}
    ax.set(title="Two bounded wobbles; one growing disturbance", xlabel="Normalized time t", ylabel="Transverse angular-velocity norm")
    early.set(title="Linear prediction before the first tumble", xlabel="Normalized time t", ylabel=r"$\sqrt{\omega_1^2+\omega_3^2}$")
    for a in (ax, early):
        a.grid(alpha=0.2, which="both")
        a.legend(fontsize=9, loc="upper left")
    fig.suptitle(r"Torque-free rotation: $(I_1,I_2,I_3)=(1,2,3)$, initial main component = 1", fontsize=13)
    fig.savefig(Path(__file__).stem + ".png", dpi=130, facecolor="white")
    plt.close(fig)
    assert max(v.get("energy_relative_drift", 0) for v in checks.values()) < 1e-9
    assert max(v.get("angular_momentum_squared_relative_drift", 0) for v in checks.values()) < 1e-9
    assert max(v.get("step_halving_max_difference", 0) for v in checks.values()) < 1e-7
    print(json.dumps(checks, indent=2))


if __name__ == "__main__":
    make_figure()