codexintermediate-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()