The file diatomic-data.tab
contains parameters, in \(\mathrm{cm^{-1}}\), describing the energy levels of a selection of diatomic molecules. These energies can be written in the form:
$$ S(v, J) = G(v) + F(v, J), $$
where the anharmonic vibrational energies are parameterised as:
$$ G(v) = \textstyle\omega_\mathrm{e}\left(v + \frac{1}{2}\right) - \omega_\mathrm{e}x_\mathrm{e}\left(v + \frac{1}{2}\right)^2 + \omega_\mathrm{e}y_\mathrm{e}\left(v + \frac{1}{2}\right)^3 $$
and the rotational energies include centrifugal distortion and vibration-rotation interaction terms:
\begin{align*} F(v, J) & = B_vJ(J+1) - D_v[J(J+1)]^2\\ B_v &= \textstyle B_\mathrm{e} - \alpha_\mathrm{e}\left(v +\frac{1}{2}\right) + \gamma_\mathrm{e}\left(v +\frac{1}{2}\right)^2\\ D_v &= \textstyle D_\mathrm{e} - \beta_\mathrm{e}\left(v +\frac{1}{2}\right) \end{align*}
In this example, we will use these data to plot the rovibrational (IR) spectrum of the fundamental band (\(v=1 \leftarrow 0\)) of \(\mathrm{{}^1H^{35}Cl}\).
Some definitions, library imports and some useful physical constants from SciPy's physical_constants
module:
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
from scipy.constants import physical_constants
# The second radiation constants, c2 = hc/kB, in cm.K
c2 = physical_constants['second radiation constant'][0] * 100
# Speed of light (m.s-1), atomic mass constant (kg) and Boltzmann constant (J.K-1)
c = physical_constants['speed of light in vacuum'][0]
u = physical_constants['atomic mass constant'][0]
kB = physical_constants['Boltzmann constant'][0]
The data are in fixed-width fields, separated by whitespace, with the asterisk character used as a placeholder for missing values.
df = pd.read_csv('diatomic-data.tab', sep='\s+', index_col=0, na_values='*')
df
(12C)(16O) (1H)(35Cl) ... (1H)(81Br) (16O)2
parameter ...
we 2169.813600 2990.946300 ... 2648.975000 1580.193000
wexe 13.288310 52.818600 ... 45.217500 11.981000
weye NaN 0.224370 ... -0.002900 0.047470
Be 1.931281 10.593416 ... 8.464884 1.437677
alpha_e 0.017504 0.307181 ... 0.233280 0.015930
[5 rows x 6 columns]
First we are going to need a function to calculate the energy (in \(\mathrm{cm^{-1}}\)) of a rovibrational state using the above formula for \(S(v, J)\).
def get_E(v, J, molecule):
"""Return the energy, in cm-1, of the state (v, J) for a molecule."""
# Get the parameters, replacing NaN values with 0
prms = df[molecule].fillna(0)
vfac, Jfac = v + 0.5, J * (J+1)
Bv = prms['Be'] - prms['alpha_e'] * vfac + prms['gamma_e'] * vfac**2
Dv = prms['De'] - prms['beta_e'] * vfac
F = Bv * Jfac - Dv * Jfac**2
G = prms['we'] * vfac - prms['wexe'] * vfac**2 + prms['weye'] * vfac**3
return G + F
Next, identify the molecule by the column heading in the prms
DataFrame
, and decide how many energy levels to calculate; here up to \(J=50\) for each of the vibrational states, \(v=0,1,2\). These energy levels, and their degeneracies (\(2J+1\)) are stored in a new DataFrame
called states
, with a MultiIndex
: each row is identified by the tuple (v, J)
.
T = 296
molecule = '(1H)(35Cl)'
states_vmax, states_Jmax = 2, 50
index = pd.MultiIndex.from_tuples(
((v, J) for v in range(states_vmax+1) for J in range(states_Jmax+1)),
names=['v', 'J'])
states = pd.DataFrame(index=index, columns=('E', 'g'), dtype=float)
for v, J in index:
# Set the energy and the degeneracy columns.
states.loc[(v, J), 'E'] = get_E(v, J, molecule)
states.loc[(v, J), 'g'] = 2 * J + 1
# Convert the dtype of the degeneracy column to int.
states = states.convert_dtypes()
states
E g
v J
0 0 1482.296546 1
1 1503.174069 3
2 1544.916349 5
3 1607.497853 7
4 1690.88028 9
... ... ...
2 46 25906.990015 93
47 26609.673157 95
48 27317.604419 97
49 28030.171006 99
50 28746.747356 101
[153 rows x 2 columns]
Calculate the partition sum, \(q\), from the calculated state energies and degeneracies.
E, g = states['E'], states['g']
E0 = get_E(0, 0, molecule)
# In the partition sum, measure energies from the zero-point level.
q = np.sum(g * np.exp(-c2 * (E - E0) / T))
In calculating the transitions, it is not necessary to use all of the states above (since the higher-\(J\) energy levels will be barely populated at this temperature and the line strengths correspondingly small): set a new rotational quantum number maximum for calculating the spectrum, and construct a new DataFrame
to hold the transition data. For a closed-shell diatomic molecule there are two branches to the rovibrational spectrum, \(P(J'')\) and \(R(J'')\), corresponding to \(\Delta J = J' - J'' = -1\) and \(+1\), respectively. The index for this DataFrame
will be a string corresponding to this designation.
trans_Jmax = 20
Jpp_P = np.arange(trans_Jmax, 0, -1)
Jpp_R = np.arange(0, trans_Jmax)
Pbranch = [f'P({Jpp})' for Jpp in Jpp_P]
Rbranch = [f'R({Jpp})' for Jpp in Jpp_R]
trans = pd.DataFrame(index=np.concatenate((Pbranch, Rbranch)))
trans['Jpp'] = np.concatenate((Jpp_P, Jpp_R))
trans['Jp'] = np.concatenate((Jpp_P-1, Jpp_R+1))
vp, vpp = 1, 0
Now iterate over the values of \(J''\) and \(J'\), calculating the transition wavenumber and relative line strengths from the energies in the states
table. The line strengths are given by
$$ S \propto \tilde{\nu}_0 \frac{H(J'')}{q}\exp\left(-\frac{c_2 E''}{T}\right)\left[ 1 - \exp\left(-\frac{c_2 \tilde{\nu}_0}{T}\right)\right], $$
where \(H(J'')\) is a Hönl-London factor equal to \(J''+1\) for the R-branch lines and \(J''\) for the P-branch.
for br, (Jpp, Jp) in trans[['Jpp', 'Jp']].iterrows():
# Store the transition's upper and lower state energies in the trans table,
# measured from the zero-point level.
Epp = states.loc[(vpp, Jpp), 'E'] - E0
Ep = states.loc[(vp, Jp), 'E'] - E0
nu = Ep - Epp
# Also calculate the relative intensity, including a Honl-London factor,
# P-branch: H(J") = J", R-branch: H(J") = J"+1
HLfac = Jpp
if Jp - Jpp == 1:
HLfac = Jpp + 1
rS = nu * HLfac / q * np.exp(-c2 * Epp / T) * (1 - np.exp(-c2 * nu / T))
trans.loc[br, ['Epp', 'Ep', 'nu0', 'rS']] = Epp, Ep, nu, rS
trans.head()
Jpp Jp Epp Ep nu0 rS
P(20) 20 19 4290.892494 6659.631076 2368.738583 0.000002
P(19) 19 18 3890.321554 6289.184891 2398.863337 0.000014
P(18) 18 17 3508.202491 5936.818786 2428.616295 0.000086
P(17) 17 16 3144.777869 5602.762558 2457.984688 0.000478
P(16) 16 15 2800.277487 5287.233239 2486.955751 0.002430
To calculate a spectrum, first establish a grid of wavenumber points covering the range of transition frequencies (with a bit of padding).
# Maximum and minimum transition wavenumbers.
numin, numax = trans['nu0'].min(), trans['nu0'].max()
# Pad the wavenumber grid by 5 cm-1 at each end
numin, numax = round(numin) - 5, round(numax) + 5
nu = np.arange(numin, numax, 0.001)
Also define some normalized lineshape functions: a Gaussian profile, \(f_\mathrm{G}\) (for Doppler broadening) and a Lorentzian profile, \(f_\mathrm{L}\) (for where pressure-broadening dominates), in terms of their respective half-widths at half-maximum (HWHM), \(\alpha_\mathrm{D}\) and \(\gamma_\mathrm{L}\):
\begin{align*} f_\mathrm{G}(\tilde{\nu}; \tilde{\nu}_0, \alpha_\mathrm{D}) & = \sqrt{\frac{\ln 2}{\alpha_\mathrm{D}^2\pi}} \exp\left[ -\ln 2\frac{(\tilde{\nu} - \tilde{\nu}_0)^2}{\alpha_\mathrm{D}^2}\right],\\ f_\mathrm{L}(\tilde{\nu}; \tilde{\nu}_0, \gamma_\mathrm{L}) & = \frac{\gamma_\mathrm{L}/\pi}{(\tilde{\nu} - \tilde{\nu}_0)^2 + \gamma_\mathrm{L}^2}. \end{align*}
def fG(nu, nu0, alphaD):
"""Normalized Gaussian function with HWHM alphaD, centered on nu0."""
N = np.sqrt(np.log(2) / np.pi) / alphaD
return N * np.exp(-np.log(2) * ((nu - nu0) / alphaD)**2)
def fL(nu, nu0, gammaL):
"""Normalized Lorentzian function with HWHM gammaL, centered on nu0."""
N = 1 / np.pi
return N * gammaL / ((nu - nu0)**2 + gammaL**2)
Set the Gaussian and Lorentzian HWHM values, \(\alpha_\mathrm{D}\) and \(\gamma_\mathrm{L}\) below. At 296 K, the Doppler width for HCl is small (about \(0.003\;\mathrm{cm^{-1}}\)), so here we will set a large-ish value for the pressure broadened width and use that.
# Molecular mass of (1H)(35Cl)
m = (1 + 35) * u
alphaD = nu.mean() / c * np.sqrt(2 * np.log(2) * kB * T / m)
gammaL = 1
Finally, calculate the contribution to the spectrum from each line and normalize to the maximum intensity.
spec = np.zeros_like(nu)
for br, row in trans.iterrows():
spec += row['rS'] * fL(nu, row['nu0'], gammaL)
spec /= spec.max()
plt.plot(nu, spec)
plt.xlabel(r'$\tilde{\nu}\;/\mathrm{cm^{-1}}$')
plt.ylabel(r'$\sigma$ (arb. units)')