codex/rotational-line-assignment-and-thermal-response.py

rotational-line-assignment-and-thermal-response.py
"""Stipulated spectra, independently computed fits, and rigid-rotor thermal response.

Python 3.14; numpy 2.3.5; matplotlib 3.10.7. Run from _media.
No measured data. Integrated absorption is not a pulsed FTMW peak amplitude.
"""
from pathlib import Path
import numpy as np
import matplotlib.pyplot as plt

H = 6.62607015e-34
KB = 1.380649e-23
B = 5e9  # Hz
D = 2e3  # Hz; used for positions only


def frequency(m):
    return 2 * B * m - 4 * D * m**3


def thermal_response(temperature, cutoff=160):
    j = np.arange(cutoff + 1)
    weight = np.exp(-H * B * j * (j + 1) / (KB * temperature))
    q = np.sum((2 * j + 1) * weight)
    population = (2 * j + 1) * weight / q
    # Sum over M of |<J+1,M|cos(theta)|J,M>|^2 = (J+1)/3.
    # Net absorption subtracts stimulated emission per substate, then
    # multiplies by photon energy. J-independent constants cancel.
    nu = 2 * B * (j[:-1] + 1)
    area = nu * (j[:-1] + 1) * (weight[:-1] - weight[1:]) / q
    return j, population, area / area.max()


def main():
    plt.rcParams.update({'font.size': 11, 'axes.spines.top': False,
                         'axes.spines.right': False, 'figure.facecolor': 'white'})
    fig, axes = plt.subplots(2, 2, figsize=(11, 8.5), layout='constrained')
    fig.suptitle('Synthetic line assignment and thermal response\n'
                 'Stipulated B = 5.000000 GHz, D = 2.000 kHz; no measured data', fontsize=15)
    m = np.arange(4, 8)
    nu = frequency(m)
    ax = axes[0, 0]
    ax.vlines(nu / 1e9, 0, 1, color='#2166ac', lw=2.5)
    ax.vlines(53.2, 0, .62, color='#b35806', lw=2.5)
    for n, f in zip(m, nu):
        ax.text(f / 1e9, 1.04, f'{n-1} → {n}', ha='center', fontsize=10)
    ax.text(53.2, .68, 'unrelated\n53.2 GHz', ha='center', color='#b35806', fontsize=10)
    ax.axvspan(38, 72, color='#e8eef3', alpha=.35)
    ax.set(xlim=(38, 72), ylim=(0, 1.38), yticks=[], xlabel='Frequency ν (GHz)',
           title='A  Limited band: first visible line is 3 → 4')
    ax.text(.03, .91, 'Stick heights carry no intensity information',
            transform=ax.transAxes, fontsize=9)
    ax = axes[0, 1]
    x = np.linspace(12, 67, 150)
    ax.plot(x, (B - 2 * D * x) / 1e6, color='#2166ac')
    ax.scatter(m**2, nu / (2 * m) / 1e6, color='#2166ac', zorder=3)
    ax.scatter(64, frequency(8) / 16 / 1e6, marker='*', s=140, color='#b35806', zorder=3)
    ax.text(29, 4999.89, 'Slope = −0.004 MHz\nIntercept = 5000 MHz', fontsize=10)
    ax.text(63, 4999.724, 'held-out m = 8', ha='right', color='#b35806', fontsize=10)
    ax.ticklabel_format(axis='y', useOffset=False)
    ax.set(xlabel='Assigned upper label m² = (J + 1)²', ylabel='ν / (2m) (MHz)',
           title='B  Four assignments fit one straight line')
    colors = ['#2166ac', '#b35806']
    for t, color in zip((3, 30), colors):
        j, pop, area = thermal_response(t)
        axes[1, 0].plot(j[:30], pop[:30], '.-', color=color, label=f'T = {t} K')
        axes[1, 1].plot(j[:29], area[:29], '.-', color=color, label=f'T = {t} K')
    axes[1, 0].set(xlabel='Lower-state rotational label J', ylabel='Level population P_J',
                   xlim=(0, 28), title='C  Rigid-rotor populations: sum over all M')
    axes[1, 1].set(xlabel='Lower label J of the J → J + 1 line',
                   ylabel='Integrated absorption area / its maximum', xlim=(0, 28),
                   title='D  Ideal absorption areas differ from populations')
    for ax in axes[1]:
        ax.legend(frameon=False)
        ax.grid(alpha=.2)
    fig.supxlabel('C–D: rigid energies (D = 0), thermal equilibrium, weak unsaturated absorption.\n'
                  'No nuclear-spin restrictions or instrument response; these are not FTMW peak amplitudes.', fontsize=10)
    fig.savefig(Path(__file__).stem + '.png', dpi=110, facecolor='white')
    plt.close(fig)


if __name__ == '__main__':
    main()