codex/pendulum-period-scaling.py

pendulum-period-scaling.py
"""Generate pendulum-period-scaling.png in cwd.

Tested with Python 3.14.4, numpy 2.3.5, matplotlib 3.10.7.
Small-angle periods are analytic. Finite-amplitude periods use Gaussian
quadrature of the exact ideal-pendulum elliptic integral, not simulated data.
"""

from pathlib import Path
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import numpy as np


def period_ratio(theta):
    nodes,weights=np.polynomial.legendre.leggauss(256)
    phi=(nodes+1)*np.pi/4
    k=np.sin(np.asarray(theta)[...,None]/2)
    integral=np.sum(weights*(np.pi/4)/np.sqrt(1-k*k*np.sin(phi)**2),axis=-1)
    return 2*integral/np.pi


def main():
    g=9.81
    length=np.linspace(.1,4.2,400)
    degrees=np.linspace(0,150,400)
    ratio=period_ratio(np.deg2rad(degrees))
    assert np.isclose(period_ratio(0),1,atol=1e-14)
    assert np.isclose(period_ratio(np.pi/2),1.1803405990160962,atol=1e-12)
    fig,axes=plt.subplots(1,2,figsize=(10.5,4.9),dpi=120,facecolor='white')
    axes[0].plot(length,2*np.pi*np.sqrt(length/g),color='#2878ad',lw=2.3)
    for ell in (1,4):
        t=2*np.pi*np.sqrt(ell/g)
        axes[0].scatter(ell,t,c='#c05a2a',s=50,zorder=4)
        axes[0].annotate(f'{ell} m: {t:.3f} s',(ell,t),xytext=(-5,-25) if ell==4 else (10,9),
                         textcoords='offset points',ha='right' if ell==4 else 'left',fontsize=11)
    axes[0].set(xlim=(0,4.4),ylim=(0,4.7),xlabel='Length ℓ (m)',ylabel='Small-angle period (s)',
                title=r'Length scaling: $T_0=2\pi\sqrt{\ell/g}$')
    axes[1].plot(degrees,ratio,color='#24805e',lw=2.3,label='Exact ideal-pendulum period ratio')
    axes[1].axhline(1,color='#2878ad',lw=1.4,ls='--',label='Small-angle approximation')
    for deg in (10,60,90,120):
        r=float(period_ratio(np.deg2rad(deg)))
        axes[1].scatter(deg,r,s=30,c='#c05a2a',zorder=4)
        axes[1].annotate(f'{deg}°: {r:.4f}',(deg,r),xytext=(5,8),textcoords='offset points',fontsize=10)
    axes[1].set(xlim=(0,155),ylim=(.96,1.8),xlabel='Initial amplitude θ₀ (degrees)',ylabel=r'$T/T_0$',
                title='Amplitude reveals the approximation limit')
    axes[1].legend(loc='upper left',fontsize=9)
    for ax in axes:
        ax.set_facecolor('white');ax.grid(alpha=.16)
        ax.spines['top'].set_visible(False);ax.spines['right'].set_visible(False)
    fig.suptitle('One ideal model predicts scaling and its small-angle correction',fontsize=15)
    fig.text(.5,.026,'Assume a point mass, massless fixed-length support, no drag, and '+r'$g=9.81\ \mathrm{m/s^2}$'+'.\n'
             'Right: exact periods from 256-point quadrature; these curves are not experimental measurements.',ha='center',fontsize=9)
    fig.tight_layout(rect=(0,.085,1,.925))
    fig.savefig(Path.cwd()/'pendulum-period-scaling.png',facecolor='white',transparent=False)
    plt.close(fig)
    for deg in (10,60,90,120):
        print(f'Amplitude {deg} degrees: T/T0={float(period_ratio(np.deg2rad(deg))):.10f}')
    print(f'Lengths 1m and4m: {2*np.pi/np.sqrt(g):.10f}s, {4*np.pi/np.sqrt(g):.10f}s')


if __name__=='__main__':
    main()