codexdamped-pendulum-nonlinear-energy.py
damped-pendulum-nonlinear-energy.py
"""Nonlinear versus linear pendulum and exact energy loss; illustrative only.
Tested with Python 3.14.4, NumPy 2.3.5 and Matplotlib 3.10.7.
Run from _media: MPLCONFIGDIR=/tmp/codex-wiki-matplotlib python ../damped-pendulum-nonlinear-energy.py
Standalone fixed-step RK4 integrates angle, velocity, and dissipated energy.
The PNG is written to the current working directory.
"""
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
C = 0.2
def solve(angle, step=0.005, duration=25.0):
time = np.linspace(0, duration, round(duration/step) + 1)
step = time[1] - time[0]
state = np.empty((len(time), 3))
state[0] = (angle, 0, 0)
def rhs(y):
theta, velocity, _ = y
return np.array((velocity, -C*velocity - np.sin(theta), C*velocity*velocity))
for i in range(len(time)-1):
y = state[i]
k1 = rhs(y)
k2 = rhs(y + step*k1/2)
k3 = rhs(y + step*k2/2)
k4 = rhs(y + step*k3)
state[i+1] = y + step*(k1 + 2*k2 + 2*k3 + k4)/6
return time, state
def linear(angle, time):
wd = np.sqrt(1 - C*C/4)
theta = angle*np.exp(-C*time/2)*(np.cos(wd*time) + C/(2*wd)*np.sin(wd*time))
velocity = -angle*np.exp(-C*time/2)*np.sin(wd*time)/wd
return theta, velocity
def main():
plt.rcParams.update({"font.size": 10, "axes.spines.top": False,
"axes.spines.right": False, "figure.facecolor": "white",
"axes.facecolor": "white", "savefig.facecolor": "white"})
fig, axs = plt.subplots(2, 2, figsize=(10, 6.8), layout="constrained")
fig.suptitle("Damped pendulum: small angles, finite angles, and energy loss", fontsize=15)
results = {}
for degrees, ax in zip((10, 60), axs[0]):
angle = np.deg2rad(degrees)
time, state = solve(angle)
approx, approx_v = linear(angle, time)
ax.plot(time, np.rad2deg(state[:, 0]), color="#2563eb", label="Nonlinear: sin θ")
ax.plot(time, np.rad2deg(approx), color="#d97706", ls="--", label="Linear: θ")
ax.axhline(0, color="#cbd5e1", lw=0.8)
ax.set(title=f"Release from rest at {degrees}°", xlabel="Dimensionless time τ", ylabel="Angle (degrees)", xlim=(0, 25))
ax.legend(fontsize=9, loc="upper right")
results[degrees] = (time, state, approx, approx_v)
time, state, approx, approx_v = results[60]
kinetic = state[:, 1]**2/2
potential = 2*np.sin(state[:, 0]/2)**2
energy = kinetic + potential
energy0 = energy[0]
loss = state[:, 2]
ax = axs[1, 0]
ax.plot(time, energy/energy0, color="#111827", lw=2, label="Remaining E/E₀")
ax.plot(time, kinetic/energy0, color="#2563eb", lw=1, label="Kinetic / E₀")
ax.plot(time, potential/energy0, color="#16a34a", lw=1, label="Potential / E₀")
ax.plot(time, loss/energy0, color="#9333ea", lw=1.5, label="Dissipated Q/E₀")
ax.set(title="Exact energy balance (60°): E + Q = E₀", xlabel="Dimensionless time τ", ylabel="Fraction of initial nonlinear energy", xlim=(0, 25), ylim=(0, 1.05))
ax.legend(fontsize=8, loc="upper right")
ax = axs[1, 1]
ax.semilogy(time, energy/energy0, color="#2563eb", label="Nonlinear E/E₀ (60°)")
linear_energy = (approx*approx + approx_v*approx_v)/2
ax.semilogy(time, linear_energy/linear_energy[0], color="#d97706", ls="--", label="Linear E/E₀ (60°)")
ax.semilogy(time, np.exp(-C*time), color="#64748b", ls=":", label="Weak-damping average exp(−cτ)")
ax.set(title="Energy decay is not an exact exponential", xlabel="Dimensionless time τ", ylabel="Remaining fraction (log scale)", xlim=(0, 25))
ax.legend(fontsize=8, loc="upper right")
for ax in axs.flat:
ax.grid(alpha=0.17)
fig.supxlabel("Chosen model: θ″ + 0.20 θ′ + sin θ = 0. Numerical solutions, not measured data.", fontsize=10)
output = Path.cwd() / (Path(__file__).stem + ".png")
fig.savefig(output, dpi=130, facecolor="white", transparent=False)
plt.close(fig)
for degrees, (time, state, approx, _) in results.items():
energy = state[:, 1]**2/2 + 2*np.sin(state[:, 0]/2)**2
residual = np.max(np.abs(energy + state[:, 2] - energy[0]))
error = np.max(np.abs(state[:, 0] - approx))
print(f"{degrees} deg: max angle difference={error:.10g} rad; max |E+Q-E0|={residual:.3g}; E(25)/E0={energy[-1]/energy[0]:.9f}")
if __name__ == "__main__":
main()