codex/cyclotron-energy-radius-and-phase-slip.py

cyclotron-energy-radius-and-phase-slip.py
"""Classical radius and relativistic timing diagnostic for a chosen proton model.

Python 3.14, matplotlib 3.10.7, numpy 2.3.5. Run from _media.
The prescribed energy ramp diagnoses timing error; it omits cos(phase) feedback.
"""
from pathlib import Path
import numpy as np
import matplotlib.pyplot as plt

E_CHARGE = 1.602176634e-19
PROTON_MASS = 1.67262192595e-27
C = 299792458.0
B = 1.0
KICK_EV = 10000.0


def model(n):
    kinetic_j = np.asarray(n) * KICK_EV * E_CHARGE
    radius_classical = np.sqrt(2 * PROTON_MASS * kinetic_j) / (E_CHARGE * B)
    radius_relativistic = np.sqrt(kinetic_j * (kinetic_j + 2 * PROTON_MASS * C**2)) / (E_CHARGE * B * C)
    coast_error_deg = 180 * kinetic_j / (PROTON_MASS * C**2)
    cumulative_deg = 180 * KICK_EV * E_CHARGE / (PROTON_MASS * C**2) * np.asarray(n) * (np.asarray(n) + 1) / 2
    return kinetic_j / E_CHARGE / 1e6, radius_classical, radius_relativistic, coast_error_deg, cumulative_deg


def main():
    plt.rcParams.update({'font.size': 11, 'figure.facecolor': 'white', 'savefig.facecolor': 'white'})
    n = np.arange(201)
    energy, rc, rr, step, total = model(n)
    fig, axes = plt.subplots(1, 3, figsize=(11.4, 4.8), dpi=130)
    axes[0].plot(energy, rc * 100, label='Classical', lw=2.6, c='#286d9f')
    axes[0].plot(energy, rr * 100, '--', label='Relativistic', lw=1.5, c='#be5d29')
    axes[0].set_ylabel('Curvature radius (cm)')
    axes[0].set_title('Larger orbit', fontsize=13)
    axes[0].legend(frameon=False, loc='lower right', fontsize=10)
    axes[0].annotate('Curves nearly overlap\nat these energies', (1.15, 15.5), (0.45, 5),
                     arrowprops={'arrowstyle': '->'}, fontsize=9)
    axes[1].plot(energy, step, c='#26846b', lw=2)
    axes[1].set_ylabel('Excess drive phase per half-turn (degrees)')
    axes[1].set_title('Small error per coast', fontsize=13)
    axes[2].plot(energy, total, c='#9c437c', lw=2)
    axes[2].set_ylabel('Accumulated excess phase (degrees)')
    axes[2].set_title('Many coasts add up', fontsize=13)
    values = (rc[100] * 100, step[100], total[100])
    labels = ('14.450 cm', '0.19184°', '9.688° after 100 coasts')
    for ax, value, label in zip(axes, values, labels):
        ax.scatter([1], [value], c='#202a34', s=27, zorder=4)
        ax.axvline(1, ls=':', c='#7d8790', lw=1)
        ax.annotate(label, (1, value), (0.1, 0.82), textcoords='axes fraction', fontsize=10,
                    arrowprops={'arrowstyle': '->', 'color': '#56616b'})
        ax.set_xlabel('Kinetic energy (MeV)')
        ax.set_xlim(0, 2)
        ax.grid(alpha=0.2)
    fig.suptitle('Chosen proton model: B = 1 T, prescribed 10 keV kicks', fontsize=16, y=0.98)
    fig.text(0.5, 0.08, r'$K_j=j\epsilon$, $\delta\phi_j=\pi K_j/(mc^2)$, $\Phi_N=\pi\epsilon N(N+1)/(2mc^2)$', ha='center', fontsize=12)
    fig.text(0.5, 0.025, 'Phase is summed after each post-kick coast • ideal ramp omits phase feedback • calculated values, not historical data', ha='center', fontsize=10)
    fig.subplots_adjust(left=0.075, right=0.98, bottom=0.27, top=0.83, wspace=0.53)
    fig.savefig(Path.cwd() / (Path(__file__).stem + '.png'), facecolor='white')
    plt.close(fig)


if __name__ == '__main__':
    main()