codex/torsion-balance-calibration.py

torsion-balance-calibration.py
"""Original calculated torsion calibration curves, not measured data.

Tested with Python 3.14.4, matplotlib 3.10.7, numpy 2.3.5.
Run from _media: MPLCONFIGDIR=/tmp/codex-wiki-matplotlib python3 ../torsion-balance-calibration.py
Writes the matching opaque white PNG basename to the current working directory.
"""
from pathlib import Path
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import numpy as np


def main():
    plt.rcParams.update({'font.size': 11, 'figure.facecolor': 'white', 'axes.facecolor': 'white'})
    kappa, inertia, gamma = 1e-8, 2e-6, .020
    omega0 = np.sqrt(kappa/inertia)
    omegad = np.sqrt(omega0**2-gamma**2)
    period0, periodd = 2*np.pi/omega0, 2*np.pi/omegad
    delta = gamma*periodd
    # Choose phase so t=0 and every integer damped period are true positive maxima.
    phase = -np.arctan(gamma/omegad)
    amplitude = .020/np.cos(phase)
    t = np.linspace(0, 3.1*periodd, 1600)
    theta = amplitude*np.exp(-gamma*t)*np.cos(omegad*t+phase)
    envelope = amplitude*np.exp(-gamma*t)
    fig, axes = plt.subplots(1, 2, figsize=(10.6, 4.9))
    fig.subplots_adjust(left=.085, right=.98, bottom=.25, top=.76, wspace=.32)
    fig.suptitle('Two ways to calibrate a torsion constant', fontsize=17, y=.97)
    fig.text(.5, .87, r'Toy model, not measured data: $\kappa=10^{-8}$ N m/rad', ha='center', color='#555555')
    blue, orange, gray = '#245a91', '#b74b16', '#6b7280'
    ax = axes[0]
    ax.set_title('Static: slope of known torque vs twist', fontsize=12)
    angles = np.linspace(-.022, .022, 100)
    ax.plot(angles*1e3, kappa*angles/1e-10, color=blue, lw=2)
    pts = np.array([-.020, -.010, 0, .010, .020])
    ax.scatter(pts*1e3, kappa*pts/1e-10, color=blue, s=35, zorder=3)
    ax.axhline(0, color=gray, lw=.7)
    ax.axvline(0, color=gray, lw=.7)
    ax.set_xlabel(r'Twist $\theta$ (mrad)')
    ax.set_ylabel(r'Applied torque ($10^{-10}$ N m)')
    ax.set_xlim(-24, 24)
    ax.set_ylim(-2.45, 2.45)
    ax.annotate(r'20 mrad $\to 2\times10^{-10}$ N m', xy=(20, 2), xytext=(-20, 1.2), fontsize=10,
                arrowprops={'arrowstyle': '->', 'color': gray})
    ax.grid(alpha=.17)
    ax = axes[1]
    ax.set_title('Dynamic: period plus viscous decay', fontsize=12)
    ax.plot(t, 20*np.cos(omega0*t), '--', color=gray, alpha=.6, lw=1, label='no damping')
    ax.plot(t, theta*1e3, color=orange, lw=2, label='viscous damping')
    for sign in (-1, 1):
        ax.plot(t, sign*envelope*1e3, ':', color=orange, lw=1)
    peaks_t = np.array([0., periodd, 2*periodd])
    peaks = .020*np.exp(-gamma*peaks_t)
    ax.scatter(peaks_t, peaks*1e3, color=orange, s=25, zorder=4)
    ax.annotate('', xy=(periodd, 16), xytext=(0, 16), arrowprops={'arrowstyle': '<->', 'color': orange})
    ax.text(periodd/2, 17.7, r'$T_d=92.64$ s', ha='center', color=orange, fontsize=10)
    ax.text(122, -17, r'$\delta=\ln(p_n/p_{n+1})=1.853$', fontsize=10, color=orange)
    ax.set_xlabel('Time (s)')
    ax.set_ylabel('Twist (mrad)')
    ax.set_xlim(-5, t[-1])
    ax.set_ylim(-24, 25)
    ax.legend(loc='upper right', fontsize=9, frameon=False)
    ax.grid(alpha=.17)
    fig.text(.26, .12, r'Same slope for both signs; zero intercept is assumed.', ha='center', fontsize=10)
    fig.text(.76, .12, r'$I=2\times10^{-6}$ kg m$^2$, $\gamma=0.020$ s$^{-1}$', ha='center', fontsize=10)
    fig.text(.5, .035, r'Period alone gives $0.92\kappa$; $\kappa=I(4\pi^2+\delta^2)/T_d^2$ recovers the input.', ha='center', fontsize=11)
    fig.savefig(Path(__file__).with_suffix('.png').name, dpi=110, facecolor='white', transparent=False)
    plt.close(fig)
    print(f'T0={period0:.9f} s; Td={periodd:.9f} s; delta={delta:.9f}; naive={inertia*omegad**2:.12g}; corrected={inertia*(omegad**2+gamma**2):.12g}')


if __name__ == '__main__':
    main()