Calculating the heat capacity from the partition sum

(0 comments)

It is a standard result of statistical thermodynamics that the molar heat capacity at constant volume, $C_{V,\mathrm{m}}$, is related to the molecular partition sum, $q$, through the relation $$ C_{V,\mathrm{m}} = R\beta^2\left[\frac{\ddot{q}}{q} - \left( \frac{\dot{q}}{q}\right)^2\right], $$ where $\dot{q}$ and $\ddot{q}$ represent the first and second derivative of $q$ with respect to the "thermodynamic beta", $\beta = \frac{1}{k_\mathrm{B}T}$:

\begin{align*} q &= \sum_i g_i e^{-\beta \epsilon_i}\\ \dot{q} \equiv \left( \frac{\partial q}{\partial \beta}\right)_V &= - \sum_i \epsilon_i g_i e^{-\beta \epsilon_i}\\ \ddot{q} \equiv \left( \frac{\partial^2 q}{\partial \beta^2}\right)_V &= \sum_i \epsilon_i^2 g_i e^{-\beta \epsilon_i} \end{align*}

There are several numerical issues to be dealt with in the evaluation of these summations. For example, it is not always easy to determine when convergence has been reached, and the fact that the terms can become very small as $i$ increases means that it is easy to introduce artefacts related to the finite-precision floating point representation of real numbers.

The code below defines a function calc_sum to sum a general series provided in the iterable object get_term. It uses Python's math.fsum to avoid the loss of floating point precision by tracking intermediate partial sums (the Shewchuk algorithm).

The individual functions calc_q, calc_qdot and calc_qdotdot then call calc_sum, passing it beta and user-provided functions to calculate the energy, $\epsilon_i$ (Efunc) and degeneracy, $g_i$ (gfunc) of the $i$th energy level. These, in turn, are called by calc_CV which calculates the heat capacity. Any additional arguments (such as molecular constants) that Efunc and gfunc need should be passed in the tuple args to this function.

import numpy as np
from scipy.constants import h, c, k as kB, R

# Speed of light in cm.s-1
c *= 100
hc = h * c

def calc_sum(get_term, max_terms=500, TINY=1.e-100):
    """Sum a finite or absolutely convergent series to convergence.

    The terms in the series are returned by the get_term iterator. No more than
    max_terms are summed, and convergence is assumed when a term is not zero
    but smaller than TINY.

    """

    terms = []
    for i in range(max_terms):
        try:
            term = next(get_term)
        except StopIteration:
            break
        if term and term < TINY:
            break
        terms.append(term)
    else:
        if terms[-1] >= TINY:
            print('Warning: maximum number of iterations reached in calc_sum.')
    return np.math.fsum(terms)

def get_term(beta, gfunc, Efunc, calc_term, args=()):
    """A generator returning each term in the partition sum or its derivative.

    The energy and degeneracy for the ith term are returned by Efunc and gfunc
    respectively. Any arguments they require beyond i should be passed in the
    tuple args. If Efunc should return None to signal the end of the series.
    The function calc_term should calculate the term given beta, g and E.

    """

    i = 0
    while True:
        E = Efunc(i, *args)
        if E is None:
            return None
        yield calc_term(beta, gfunc(i, *args), E)
        i += 1

def calc_q_term(beta, g, E):
    """Return a term from the partition sum.

    beta = (kB.T)^-1 in J, g is the degeneracy and E, the energy, is in cm-1.

    """

    return g * np.exp(-E * h * c * beta)

def calc_qdot_term(beta, g, E):
    """Return a term from the first derivative of partition sum wrt beta.

    beta = (kB.T)^-1 in J, g is the degeneracy and E, the energy, is in cm-1.
    The qdot=dq/dbeta term is returned in units of cm-1.

    """

    return E * calc_q_term(beta, g, E)

def calc_qdotdot_term(beta, g, E):
    """Return a term from the second derivative of partition sum wrt beta.

    beta = (kB.T)^-1 in J, g is the degeneracy and E, the energy, is in cm-1.
    The qdotdot=d2q/dbeta2 term is returned in units of cm-2.

    """

    return E * calc_qdot_term(beta, g, E)

def calc_q(beta, gfunc, Efunc, args=()):
    """Return the partition sum, q, at beta = (kB.T)^-1.

    gfunc and Efunc are functions returning the degeneracy and energy of the
    ith term in the partition sum. Any arguments they require beyond i should
    be passed in the tuple args.

    """
    return calc_sum(get_term(beta, gfunc, Efunc, calc_q_term, args))

def calc_qdot(beta, gfunc, Efunc, args=()):
    """Return the first derivative partition sum at beta = (kB.T)^-1.

    gfunc and Efunc are functions returning the degeneracy and energy of the
    ith term in the partition sum. Any arguments they require beyond i should
    be passed in the tuple args. qdot = dq/dbeta is returned in units of J

    """
    return - hc * calc_sum(get_term(beta, gfunc, Efunc, calc_qdot_term, args))

def calc_qdotdot(beta, gfunc, Efunc, args=()):
    """Return the second derivative of the partition sum at beta = (kB.T)^-1.

    gfunc and Efunc are functions returning the degeneracy and energy of the
    ith term in the partition sum. Any arguments they require beyond i should
    be passed in the tuple args. qdotdot = d2q/dbeta2 is returned in units
    of J^2.

    """
    return hc**2 * calc_sum(get_term(beta, gfunc, Efunc, calc_qdotdot_term,
                                     args))

def calc_qfuncs(Tgrid, gfunc, Efunc, args=()):
    """Calculate the partition sum and its first two derivatives on Tgrid.

    gfunc and Efunc are functions returning the degeneracy and energy of the
    ith term in the partition sum. Any arguments they require beyond i should
    be passed in the tuple args.

    """

    q = np.zeros_like(Tgrid)
    qdot = np.zeros_like(Tgrid)
    qdotdot = np.zeros_like(Tgrid)
    for i,T in enumerate(Tgrid):
        beta = 1/kB/T
        q[i] = calc_q(beta, gfunc, Efunc, args)
        qdot[i] = calc_qdot(beta, gfunc, Efunc, args)
        qdotdot[i] = calc_qdotdot(beta, gfunc, Efunc, args)
    return q, qdot, qdotdot

def calc_CV(Tgrid, gfunc, Efunc, args=()):
    """Calculate the heat capacity, CV, on the grid of temperatures, Tgrid.

    The Tgrid should be a sequence of temperatures  in K.
    gfunc and Efunc are functions returning the degeneracy and energy of the
    ith term in the partition sum. Any arguments they require beyond i should
    be passed in the tuple args.
    The heat capacity at constant volume, CV, is returned in J.K-1.mol-1.

    """
    beta = 1 / kB / Tgrid
    q, qdot, qdotdot = calc_qfuncs(Tgrid, gfunc, Efunc, args)
    return R * beta**2 * (qdotdot/q - (qdot/q)**2)

As an example of its usage, here is a comparison of the vibrational contribution to the heat capacity of the HBr molecule using (a) the harmonic approximation and (b) the Morse approximation to its energy levels. The harmonic approximation values are also compared with their values obtained from the analytical expression $$ C_{V,\mathrm{m}}(\mathrm{sho}) = \frac{Rx^2e^{-x}}{(1+e^-x)^2}, $$ where $x=\beta h c \tilde{\nu}$ and $\tilde{\nu}$ is the harmonic vibrational wavenumber.

enter image description here

import numpy as np
from scipy.constants import h, c, k as kB, R
# Speed of light in cm.s-1
c *= 100
hc = h * c
import matplotlib.pyplot as plt
from matplotlib import rc

from partition_function import calc_CV

rc('font', **{'family': 'serif', 'serif': ['Computer Modern'], 'size': 14})
rc('text', usetex=True)

# Morse parameters for HBr, cm-1
we, wexe = 2648.975, 45.217
# Maximum quantum number v within Morse approximation
vmax = int(we/2/wexe)
print(vmax)
# Approximate harmonic vibrational wavenumber, cm-1
nu = we - wexe

# Temperature grid, K
T = np.linspace(1,5000,501)

# To get a feel for how many vibrational levels are thermally accessible,
# calculate the Harmonic approximation partition sum
Tmax = T[-1]
qsho_max = 1./(1-np.exp(-hc*nu/kB/Tmax))
print('q_sho({} K) = {}'.format(Tmax, qsho_max))

# Functions for calculating terms in the partition sum and its derivatives
def gvib(v, *args):
    # Vibrational energy levels of closed-shell diatomics are non-degenerate.
    return 1

def Esho(v, nu):
    """Return the Harmonic approximation energy for v, with E=0 for v=0."""
    return nu * v

def Emorse(v, we, wexe, vmax):
    """Return the Morse approximation energy for v, with E=0 for v=0."""
    if v > vmax:
        return None
    return we * v - wexe * v**2

CVsho = calc_CV(T, gvib, Esho, args=(nu,))
CVmorse = calc_CV(T, gvib, Emorse, args=(we, wexe,vmax))


x = hc*nu/kB/T
CVsho_analytical = R * x**2 * np.exp(-x)/(1-np.exp(-x))**2

plt.plot(T, CVsho/R, label="Harmonic", lw=2, alpha=0.6)
plt.plot(T, CVsho_analytical/R, marker='o', markevery=20, alpha=0.6)
plt.plot(T, CVmorse/R, label="Morse", lw=2, alpha=0.6)
plt.legend(loc=5)
plt.xlabel(r'$T/\mathrm{K}$')
plt.ylabel(r'$C_V/R$')
plt.savefig('HBr-CV.png')
plt.show()
Current rating: 5

Comments

Comments are pre-moderated. Please be patient and your comment will appear soon.

There are currently no comments

New Comment

required

required (not published)

optional

required