r"""Temperature-programmed desorption (TPD) and Redhead's peak analysis.
A monolayer heated at a constant rate :math:`\beta = dT/dt` desorbs
according to the Polanyi-Wigner rate equation
.. math::
-\frac{d\theta}{dT} = \frac{\nu}{\beta}\,\theta^n\,e^{-E_d/RT},
with :math:`E_d` the desorption activation energy, :math:`\nu` the
pre-exponential factor and :math:`n` the desorption order (Masel,
*Principles of Adsorption and Reaction on Solid Surfaces*, Wiley, 1996,
Ch. 7). For :math:`n=1,2` it integrates in closed form given
:math:`I(T)=\int_{T_0}^{T}e^{-E_d/RT'}\,dT'`:
:math:`\theta=\theta_0e^{-\nu I/\beta}` (first order) and
:math:`\theta=\theta_0/(1+\theta_0\nu I/\beta)` (second order), which
:func:`simulate_tpd` evaluates by cumulative quadrature.
Setting the derivative of the first-order desorption rate to zero gives
the exact peak condition :math:`E_d/(RT_p^2) = (\nu/\beta)e^{-E_d/RT_p}`
(:func:`first_order_peak_temperature`). P. A. Redhead, "Thermal
Desorption of Gases," *Vacuum* 12 (1962), 203, approximated its solution
as
.. math::
E_d = RT_p\left[\ln\frac{\nu T_p}{\beta} - 3.64\right],
accurate to within a few percent for :math:`10^8 < \nu/\beta < 10^{13}\ \mathrm{K^{-1}}`
and typical binding energies (:func:`redhead_desorption_energy`) -- turning a single measured peak
temperature into a binding energy.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from scipy.integrate import cumulative_trapezoid
from scipy.optimize import brentq
from chemistrykit.constants import R
__all__ = ["TPDResult", "simulate_tpd", "first_order_peak_temperature", "redhead_desorption_energy"]
[docs]
@dataclass
class TPDResult:
"""Result of a simulated temperature-programmed desorption run."""
T: np.ndarray
"""ndarray: Temperature grid, in K."""
coverage: np.ndarray
"""ndarray: Fractional coverage :math:`\\theta(T)`."""
desorption_rate: np.ndarray
"""ndarray: Desorption rate per kelvin, :math:`-d\\theta/dT`."""
peak_temperature: float
"""float: Temperature of maximum desorption rate, in K."""
[docs]
def simulate_tpd(
Ed: float,
nu: float,
beta: float,
theta0: float = 1.0,
order: int = 1,
T_start: float = 100.0,
T_end: float = 800.0,
n_points: int = 20001,
R_gas: float = R,
) -> TPDResult:
r"""Simulate a TPD spectrum from the Polanyi-Wigner equation (first or second order).
Parameters
----------
Ed : float
Desorption activation energy, in J/mol.
nu : float
Pre-exponential factor, in 1/s (first order) or 1/(s * coverage) (second order).
beta : float
Linear heating rate, in K/s.
theta0 : float, default 1.0
Initial coverage.
order : {1, 2}, default 1
Desorption order.
T_start, T_end : float
Temperature range of the ramp, in K.
n_points : int, default 20001
Number of grid points.
R_gas : float, default :data:`chemistrykit.constants.R`
Returns
-------
TPDResult
Examples
--------
The simulated first-order peak sits where the exact peak condition
puts it:
>>> res = simulate_tpd(Ed=100e3, nu=1e13, beta=10.0)
>>> bool(abs(res.peak_temperature - first_order_peak_temperature(100e3, 1e13, 10.0)) < 0.05)
True
"""
if order not in (1, 2):
raise ValueError("order must be 1 or 2")
T = np.linspace(T_start, T_end, n_points)
boltzmann = np.exp(-Ed / (R_gas * T))
integral = cumulative_trapezoid(boltzmann, T, initial=0.0)
x = nu * integral / beta
theta = theta0 * np.exp(-x) if order == 1 else theta0 / (1.0 + theta0 * x)
rate = nu / beta * theta**order * boltzmann
i = int(np.argmax(rate))
T_peak = float(T[i])
if 0 < i < n_points - 1:
y0, y1, y2 = rate[i - 1], rate[i], rate[i + 1]
denom = y0 - 2.0 * y1 + y2
if denom != 0.0:
T_peak += 0.5 * (y0 - y2) / denom * (T[1] - T[0])
return TPDResult(T=T, coverage=theta, desorption_rate=rate, peak_temperature=T_peak)
[docs]
def first_order_peak_temperature(Ed: float, nu: float, beta: float, R_gas: float = R) -> float:
r"""Exact first-order TPD peak temperature from :math:`E_d/(RT_p^2)=(\nu/\beta)e^{-E_d/RT_p}`.
Parameters
----------
Ed : float
Desorption activation energy, in J/mol.
nu : float
Pre-exponential factor, in 1/s.
beta : float
Heating rate, in K/s.
R_gas : float, default :data:`chemistrykit.constants.R`
Returns
-------
float
Peak temperature, in K.
Examples
--------
>>> Tp = first_order_peak_temperature(Ed=100e3, nu=1e13, beta=10.0)
>>> lhs = 100e3 / (8.31446261815324 * Tp**2)
>>> rhs = 1e13 / 10.0 * np.exp(-100e3 / (8.31446261815324 * Tp))
>>> round(float(lhs / rhs), 8)
1.0
"""
def g(T):
return np.log(Ed / (R_gas * T**2)) - np.log(nu / beta) + Ed / (R_gas * T)
return float(brentq(g, 1.0, 1.0e6, xtol=1e-12))
[docs]
def redhead_desorption_energy(T_peak, nu: float, beta: float, R_gas: float = R):
r"""Redhead's estimate :math:`E_d = RT_p[\ln(\nu T_p/\beta) - 3.64]`, in J/mol.
Parameters
----------
T_peak : float or array-like of float
Measured first-order TPD peak temperature, in K.
nu : float
Assumed pre-exponential factor, in 1/s (commonly :math:`10^{13}`).
beta : float
Heating rate, in K/s.
R_gas : float, default :data:`chemistrykit.constants.R`
Returns
-------
float or ndarray
Examples
--------
Redhead's formula recovers the true desorption energy to within about
one percent at the common choice :math:`\nu=10^{13}` s^-1:
>>> Tp = first_order_peak_temperature(Ed=100e3, nu=1e13, beta=10.0)
>>> bool(abs(redhead_desorption_energy(Tp, nu=1e13, beta=10.0) / 100e3 - 1.0) < 0.015)
True
"""
T_peak = np.asarray(T_peak, dtype=np.float64)
result = R_gas * T_peak * (np.log(nu * T_peak / beta) - 3.64)
return float(result) if result.ndim == 0 else result