codexintermediate-axis-body-alignment-and-phase.py
intermediate-axis-body-alignment-and-phase.py
"""Independent angular-momentum and attitude integration for torque-free tumbling.
Run in _media; tested with Python 3.14, NumPy 2.3.5, Matplotlib 3.10.7.
R maps body components into inertial components. Neither L nor R is projected.
"""
import json
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
MOMENTS = np.array([1.0, 2.0, 3.0])
def derivative(state):
momentum = state[:3]
rotation = state[3:].reshape(3, 3)
omega = momentum / MOMENTS
x, y, z = omega
cross_matrix = np.array([[0, -z, y], [z, 0, -x], [-y, x, 0]])
return np.r_[np.cross(momentum, omega), (rotation @ cross_matrix).ravel()]
def solve(step=0.005, stop=50):
t = np.linspace(0, stop, round(stop / step) + 1)
states = np.empty((len(t), 12))
states[0] = np.r_[0.01, 2, 0, np.eye(3).ravel()]
for j, h in enumerate(np.diff(t)):
a = derivative(states[j])
b = derivative(states[j] + h * a / 2)
c = derivative(states[j] + h * b / 2)
d = derivative(states[j] + h * c)
states[j + 1] = states[j] + h * (a + 2 * b + 2 * c + d) / 6
return t, states
def make_figure():
t, state = solve()
momentum = state[:, :3]
rotation = state[:, 3:].reshape(-1, 3, 3)
l0 = momentum[0]
length = np.linalg.norm(l0)
n = momentum / length
energy = (momentum**2 / MOMENTS).sum(axis=1) / 2
l2 = (momentum**2).sum(axis=1)
fixed_momentum = np.einsum("tij,tj->ti", rotation, momentum)
orthogonality = np.einsum("tji,tjk->tik", rotation, rotation) - np.eye(3)
alignment_reconstructed = rotation[:, :, 1] @ (l0 / length)
sign_changes = np.where(n[:-1, 1] * n[1:, 1] < 0)[0]
crossings = [float(t[k] - n[k, 1] * (t[k + 1] - t[k]) / (n[k + 1, 1] - n[k, 1])) for k in sign_changes]
_, fine = solve(step=0.0025)
checks = {
"energy_initial": float(energy[0]), "angular_momentum_squared_initial": float(l2[0]),
"energy_relative_drift": float(np.max(abs(energy / energy[0] - 1))),
"angular_momentum_squared_relative_drift": float(np.max(abs(l2 / l2[0] - 1))),
"space_angular_momentum_relative_error": float(np.max(np.linalg.norm(fixed_momentum - l0, axis=1)) / length),
"rotation_orthogonality_error": float(np.max(abs(orthogonality))),
"alignment_identity_error": float(np.max(abs(alignment_reconstructed - n[:, 1]))),
"step_halving_max_difference": float(np.max(abs(state - fine[::2]))),
"intermediate_axis_zero_crossings": crossings,
"omega_at_t14": (momentum[2800] / MOMENTS).tolist(),
}
assert checks["energy_relative_drift"] < 1e-9
assert checks["angular_momentum_squared_relative_drift"] < 1e-9
assert checks["space_angular_momentum_relative_error"] < 1e-8
assert checks["rotation_orthogonality_error"] < 1e-8
assert checks["alignment_identity_error"] < 1e-8
assert checks["step_halving_max_difference"] < 1e-7
assert len(crossings) >= 2
plt.rcParams.update({"font.size": 11, "figure.facecolor": "white", "axes.facecolor": "white"})
fig, (phase, alignment) = plt.subplots(1, 2, figsize=(10, 4.3), layout="constrained", gridspec_kw={"width_ratios": [1, 1.6]})
phase.plot(n[:, 0], n[:, 2], color="#2171b5", lw=2, label="Numerical orbit")
x = np.linspace(-0.51, 0.51, 200)
for s in (-1, 1):
phase.plot(x, s * np.sqrt(3) * x, "--", color="0.55", lw=1, label="Separatrix projection" if s == 1 else None)
phase.scatter(n[0, 0], n[0, 2], c="#c52f24", s=35, zorder=5)
phase.set(xlabel=r"$L_1/|L|$", ylabel=r"$L_3/|L|$", title="Body-frame momentum projection", xlim=(-0.54, 0.54), ylim=(-0.94, 0.94), aspect="equal")
phase.legend(fontsize=8, loc="upper left")
neck = phase.inset_axes([0.12, 0.13, 0.56, 0.27])
neck.plot(n[:, 0], n[:, 2], color="#2171b5", lw=1.5)
small_x = np.linspace(0, 0.014, 100)
for s in (-1, 1):
neck.plot(small_x, s * np.sqrt(3) * small_x, "--", color="0.55", lw=0.8)
neck.scatter(n[0, 0], 0, c="#c52f24", s=12, zorder=5)
neck.set(xlim=(0, 0.014), ylim=(-0.016, 0.016), title="Off separatrix")
neck.title.set_fontsize(8)
neck.tick_params(labelsize=7)
neck.set_xticks([0, 0.01])
neck.set_yticks([-0.01, 0, 0.01])
alignment.plot(t, n[:, 1], color="#c52f24", lw=2)
alignment.axhline(0, color="0.6", lw=0.8)
for crossing in crossings:
alignment.axvline(crossing, color="0.6", ls=":", lw=1)
alignment.set(xlabel="Normalized time t", ylabel=r"$c_2=\hat e_2\cdot\hat L=L_2/|L|$", title="The intermediate body axis reverses", ylim=(-1.12, 1.12))
for a in (phase, alignment):
a.grid(alpha=0.2)
fig.suptitle(r"Torque-free tumbling: $(I_1,I_2,I_3)=(1,2,3)$, $\omega(0)=(0.01,1,0)$", fontsize=13)
fig.savefig(Path(__file__).stem + ".png", dpi=130, facecolor="white")
plt.close(fig)
print(json.dumps(checks, indent=2))
if __name__ == "__main__":
make_figure()