codex/hydrogen-equilibrium-and-frozen-rotational-heat-capacity.py

hydrogen-equilibrium-and-frozen-rotational-heat-capacity.py
"""Original exact-sum rotor comparison; Python 3.14, numpy 2.3.5, matplotlib 3.10.7.
Run from _media; writes this script's basename.png to the current directory.
Temperature is dimensionless: t = T/theta_r. No measured constants or data.
"""
from pathlib import Path
import numpy as np
import matplotlib.pyplot as plt


def rotor_models(t, jmax=100):
    """Return equilibrium ortho fraction, C_eq/kB, C_frozen/kB, and pure-para C/kB.
    Subtract each sector's lowest energy before exponentiating, retaining its
    physical offset in mean energies. Frozen composition is x_ortho=3/4.
    """
    j = np.arange(jmax+1)
    e = j*(j+1)
    means, variances, logq = [], [], []
    for parity in [0, 1]:
        en = e[j%2 == parity]
        g = 2*j[j%2 == parity]+1
        weights = g*np.exp(-(en-en[0])/t)
        z = weights.sum()
        p = weights/z
        mean = np.dot(p, en)
        means.append(mean)
        variances.append(np.dot(p, (en-mean)**2))
        logq.append(np.log(z)-en[0]/t)
    f = 1/(1+np.exp(logq[0]-logq[1])/3)
    within = (1-f)*variances[0]+f*variances[1]
    between = f*(1-f)*(means[1]-means[0])**2
    return f, (within+between)/t**2, (.25*variances[0]+.75*variances[1])/t**2, variances[0]/t**2


def main():
    plt.rcParams.update({'font.size':11, 'figure.facecolor':'white', 'axes.facecolor':'white'})
    t = np.geomspace(.12, 20, 750)
    values = np.array([rotor_models(x) for x in t])
    fig, axes = plt.subplots(2, 1, figsize=(9, 7), sharex=True, layout='constrained', gridspec_kw={'height_ratios':[1,1.6]})
    a, b = axes
    a.plot(t, values[:,0], color='#176b98', lw=2.5, label='Conversion maintains equilibrium')
    a.axhline(.75, color='#b45220', ls='--', lw=2, label='Frozen normal hydrogen: 3/4 ortho')
    a.set_ylabel('Ortho fraction'); a.set_ylim(-.035,.88); a.set_yticks([0,.25,.5,.75]); a.legend(loc='lower right', fontsize=10)
    b.plot(t, values[:,1], color='#176b98', lw=2.5, label='Equilibrium composition')
    b.plot(t, values[:,2], color='#b45220', ls='--', lw=2.5, label='Frozen 3:1 ortho/para composition')
    b.plot(t, values[:,3], color='#567a3e', ls=':', lw=2, label='Pure para (even J only)')
    b.axhline(1, color='#888888', lw=.8)
    b.set_ylabel(r'Rotational heat capacity per molecule $c_{\rm rot}/k_{\rm B}$')
    b.set_xlabel(r'Dimensionless temperature $t=T/\theta_r$')
    b.set_ylim(-.05,2.3); b.legend(loc='upper right', fontsize=10)
    b.annotate('Conversion contributes\nto equilibrium heat capacity', xy=(.52,1.9), xytext=(.13,1.1), arrowprops={'arrowstyle':'->','color':'#333333'}, fontsize=10)
    for ax in axes:
        ax.set_xscale('log'); ax.set_xlim(.12,20); ax.grid(alpha=.18); ax.spines[['top','right']].set_visible(False)
    fig.suptitle('The same rotor levels, two different thermal constraints', fontsize=16, weight='bold')
    fig.supxlabel('Ideal independent rotors; electronic and vibrational excitation omitted. Exact sums through J = 100.', fontsize=10)
    fig.savefig(Path(__file__).stem+'.png', dpi=150, facecolor='white', transparent=False)


if __name__ == '__main__':
    main()