codex/contact-mode-phase-accumulation.py

contact-mode-phase-accumulation.py
"""Exact numerical frequency and accumulated phase of an undamped contact mode.

Python 3.14, NumPy and Matplotlib; see pyproject.toml. Writes its PNG to cwd.
"""
from pathlib import Path
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt


def main():
    z = np.linspace(0.001, 1.9, 800)
    central = 2 * np.arcsin(z / 2) / z - 1
    trapezoidal = 2 * np.arctan(z / 2) / z - 1
    step = np.geomspace(1e-6, 1e-3, 600)
    omega, duration = 1000.0, 0.1
    phase_c = duration * (2 * np.arcsin(step * omega / 2) / step - omega)
    phase_t = duration * (omega - 2 * np.arctan(step * omega / 2) / step)
    fig, axes = plt.subplots(1, 2, figsize=(9, 4.25), dpi=120, layout='constrained')
    fig.patch.set_facecolor('white')
    axes[0].plot(z, 100 * central, label='Central difference', color='#176da5')
    axes[0].plot(z, 100 * trapezoidal, label='Trapezoidal', color='#bf5726')
    axes[0].axhline(0, color='#777777', linewidth=0.7)
    axes[0].set(xlabel=r'Dimensionless step $h\omega$', ylabel='Numerical frequency error (%)',
                title='Both curves are within their stable ranges')
    axes[1].loglog(step * 1e6, phase_c, label='Central difference', color='#176da5')
    axes[1].loglog(step * 1e6, phase_t, label='Trapezoidal', color='#bf5726')
    axes[1].axhline(0.01, color='#555555', linestyle='--', label='0.01 rad phase target')
    axes[1].set(xlabel='Step h (microseconds)', ylabel='Absolute accumulated phase error (rad)',
                title='1000 rad/s mode over 0.1 s')
    for axis in axes:
        axis.grid(alpha=0.2, which='both')
        axis.legend(fontsize=8)
    fig.suptitle('Stable contact-mode integration can still drift in phase', fontsize=13)
    fig.savefig(Path('contact-mode-phase-accumulation.png'), facecolor='white', transparent=False)
    plt.close(fig)


if __name__ == '__main__':
    main()