Note
Go to the end to download the full example code.
Gibbs’s canonical ensemble: all thermodynamics from one partition function#
Gibbs (1902) showed that for a system at fixed temperature every thermodynamic function follows from the canonical partition function \(Q=\sum_i e^{-E_i/k_BT}\):
Here the partition function of a vibrational mode is built by brute-force
summation over its energy levels, and \(A\), \(U\), \(S\),
\(C_V\) are obtained from it by nothing but differentiation. They agree
with the closed forms of
VibrationalPartitionFunctionHarmonic,
and with helmholtz_free_energy()
(\(A=U-TS\)). IdealGasMolecule then
combines independent modes, whose partition functions multiply.
import matplotlib.pyplot as plt
import numpy as np
from chemistrykit.constants import K_B
from chemistrykit.statmech import IdealGasMolecule, VibrationalPartitionFunctionHarmonic
mode = VibrationalPartitionFunctionHarmonic.from_wavenumber(1000.0)
theta = mode.vibrational_temperature
levels = K_B * theta * np.arange(400) # E_v = v h nu, measured from v = 0
def ln_Q(T):
"""Gibbs's sum over states, evaluated directly."""
return np.log(np.sum(np.exp(-levels / (K_B * T))))
T = np.linspace(100.0, 3000.0, 120)
dT = 1e-2 * T
lnQ = np.array([ln_Q(t) for t in T])
dlnQ = np.array([(ln_Q(t + d) - ln_Q(t - d)) / (2 * d) for t, d in zip(T, dT)])
A = -K_B * T * lnQ
U = K_B * T**2 * dlnQ
S = (U - A) / T
Cv = np.gradient(U, T)
fig, axes = plt.subplots(1, 2, figsize=(11, 4.5))
for values, closed, label, color in [
(A / (K_B * theta), mode.helmholtz_free_energy(T, N=1.0) / (K_B * theta), r"$A$", "crimson"),
(U / (K_B * theta), mode.internal_energy(T, N=1.0) / (K_B * theta), r"$U$", "steelblue"),
]:
axes[0].plot(T, closed, color=color, label=f"{label} closed form")
axes[0].plot(T[::8], values[::8], "o", color=color, markersize=4, label=f"{label} from $\\ln Q$")
axes[0].set_xlabel("T (K)")
axes[0].set_ylabel(r"energy / $k_B\Theta_{\mathrm{vib}}$")
axes[0].set_title("Free energy and internal energy from Q")
axes[0].legend()
axes[1].plot(T, mode.entropy(T, N=1.0) / K_B, color="seagreen", label=r"$S/k_B$ closed form")
axes[1].plot(T[::8], S[::8] / K_B, "o", color="seagreen", markersize=4, label=r"$S = (U-A)/T$")
axes[1].plot(T, mode.heat_capacity_v(T, N=1.0) / K_B, color="darkorange", label=r"$C_V/k_B$ closed form")
axes[1].plot(T[::8], Cv[::8] / K_B, "o", color="darkorange", markersize=4, label=r"$C_V = \partial U/\partial T$")
axes[1].set_xlabel("T (K)")
axes[1].set_title("Entropy and heat capacity from Q")
axes[1].legend()
fig.tight_layout()

The same machinery applies to a whole molecule: for independent modes
the partition function factorizes, so ln q and every derived function
are sums over modes. thermodynamic_functions bundles them:
co2_like = IdealGasMolecule(mass=7.3e-26, volume=0.0248, moment_of_inertia=7.2e-46, symmetry_number=2, vibrational_frequencies=[4.0e13, 2.0e13, 2.0e13, 7.0e13])
tf = co2_like.thermodynamic_functions(298.15)
print(f"q = {tf.q:.3e}")
print(f"U = {tf.U:.1f} J/mol, S = {tf.S:.2f} J/(mol K), Cv = {tf.Cv:.2f} J/(mol K), A = U - TS = {tf.A:.1f} J/mol")
print(f"max |A_numeric - A_closed| / (k_B Theta) = {np.max(np.abs(A - mode.helmholtz_free_energy(T, N=1.0))) / (K_B * theta):.2e}")
plt.show()
q = 2.026e+33
U = 6888.0 J/mol, S = 213.81 J/(mol K), Cv = 28.83 J/(mol K), A = U - TS = -56858.0 J/mol
max |A_numeric - A_closed| / (k_B Theta) = 9.09e-16
Total running time of the script: (0 minutes 0.084 seconds)