Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

UMA Catalysis Tutorial

Author: Zack Ulissi (Meta, CMU), with help from AI coding agents / LLMs

Original paper: Bjarne Kreitz et al. JPCC (2021)

Overview

This tutorial demonstrates how to use the Universal Model for Atoms (UMA) machine learning potential to perform comprehensive catalyst surface analysis. We replicate key computational workflows from “Microkinetic Modeling of CO₂ Desorption from Supported Multifaceted Ni Catalysts” by Bjarne Kreitz (now faculty at Georgia Tech!), showing how ML potentials can accelerate computational catalysis research.

Installation and Setup

This tutorial uses a number of helpful open source packages:

Huggingface setups

You need to get a HuggingFace account and request access to the UMA models.

You need a Huggingface account, request access to https://huggingface.co/facebook/UMA, and to create a Huggingface token at https://huggingface.co/settings/tokens/ with these permission:

Permissions: Read access to contents of all public gated repos you can access

Then, add the token as an environment variable using huggingface-cli login:

# Enter token via huggingface-cli
! huggingface-cli login

or you can set the token via HF_TOKEN variable:

# Set token via env variable
import os

os.environ["HF_TOKEN"] = "MYTOKEN"

FAIR Chemistry (UMA) installation

It may be enough to use pip install fairchem-core. This gets you the latest version on PyPi (https://pypi.org/project/fairchem-core/)

Here we install some sub-packages. This can take 2-5 minutes to run.

! pip install fairchem-core[docs] fairchem-data-oc fairchem-applications-cattsunami x3dase
# Check that packages are installed
!pip list | grep fairchem
fairchem-applications-cattsunami   1.1.2.dev239+g171971183
fairchem-core                      2.18.0
fairchem-data-oc                   1.0.3.dev239+g171971183
fairchem-data-omat                 0.2.1.dev144+g171971183
import fairchem.core

fairchem.core.__version__
Warp DeprecationWarning: The symbol `warp.vec` will soon be removed from the public API. Use `warp.types.vector` instead.
'2.18.0'

Package imports

First, let’s import all necessary libraries and initialize the UMA-S-1P2 predictor:

from pathlib import Path

import ase.io
import matplotlib.pyplot as plt
import numpy as np
from ase import Atoms
from ase.build import bulk
from ase.constraints import FixBondLengths
from ase.io import write
from ase.mep import interpolate
from ase.mep.dyneb import DyNEB
from ase.optimize import FIRE, LBFGS
from ase.vibrations import Vibrations
from ase.visualize import view
from fairchem.core import FAIRChemCalculator, pretrained_mlip
from fairchem.data.oc.core import (
    Adsorbate,
    AdsorbateSlabConfig,
    Bulk,
    MultipleAdsorbateSlabConfig,
    Slab,
)
from pymatgen.analysis.wulff import WulffShape
from pymatgen.core import Lattice, Structure
from pymatgen.core.surface import SlabGenerator
from pymatgen.io.ase import AseAtomsAdaptor
from torch_dftd.torch_dftd3_calculator import TorchDFTD3Calculator

# Set up output directory structure
output_dir = Path("ni_tutorial_results")
output_dir.mkdir(exist_ok=True)

# Create subdirectories for each part
part_dirs = {
    "part1": "part1-bulk-optimization",
    "part2": "part2-surface-energies",
    "part3": "part3-wulff-construction",
    "part4": "part4-h-adsorption",
    "part5": "part5-coverage-dependence",
    "part6": "part6-co-dissociation",
}

for key, dirname in part_dirs.items():
    (output_dir / dirname).mkdir(exist_ok=True)

# Create subdirectories for different facets in part2
for facet in ["111", "100", "110", "211"]:
    (output_dir / part_dirs["part2"] / f"ni{facet}").mkdir(exist_ok=True)

# Initialize the UMA-S-1P2 predictor
print("\nLoading UMA-S-1P2 model...")
predictor = pretrained_mlip.get_predict_unit("uma-s-1p2")
print("✓ Model loaded successfully!")

Loading UMA-S-1P2 model...
WARNING:root:device was not explicitly set, using device='cuda'.
✓ Model loaded successfully!

It is somewhat time consuming to run this. We’re going to use a small number of bulks for the testing of this documentation, but otherwise run all of the results for the actual documentation.

import os

fast_docs = os.environ.get("FAST_DOCS", "false").lower() == "true"
if fast_docs:
    num_sites = 2
    relaxation_steps = 20
else:
    num_sites = 5
    relaxation_steps = 300

Part 1: Bulk Crystal Optimization

Introduction

Before studying surfaces, we need to determine the equilibrium lattice constant of bulk Ni. This is crucial because surface energies and adsorbate binding depend strongly on the underlying lattice parameter.

Theory

For FCC metals like Ni, the lattice constant a defines the unit cell size. The experimental value for Ni is a = 3.524 Å at room temperature. We’ll optimize both atomic positions and the cell volume to find the ML potential’s equilibrium structure.

# Create initial FCC Ni structure
a_initial = 3.52  # Å, close to experimental
ni_bulk = bulk("Ni", "fcc", a=a_initial, cubic=True)

print(f"Initial lattice constant: {a_initial:.2f} Å")
print(f"Number of atoms: {len(ni_bulk)}")

# Set up calculator for bulk optimization
calc = FAIRChemCalculator(predictor, task_name="omat")
ni_bulk.calc = calc

# Use ExpCellFilter to allow cell relaxation
from ase.filters import ExpCellFilter

ecf = ExpCellFilter(ni_bulk)

# Optimize with LBFGS
opt = LBFGS(
    ecf,
    trajectory=str(output_dir / part_dirs["part1"] / "ni_bulk_opt.traj"),
    logfile=str(output_dir / part_dirs["part1"] / "ni_bulk_opt.log"),
)
opt.run(fmax=0.05, steps=relaxation_steps)

# Extract results
cell = ni_bulk.get_cell()
a_optimized = cell[0, 0]
a_exp = 3.524  # Experimental value
error = abs(a_optimized - a_exp) / a_exp * 100

print(f"\n{'='*50}")
print(f"Experimental lattice constant: {a_exp:.2f} Å")
print(f"Optimized lattice constant:    {a_optimized:.2f} Å")
print(f"Relative error:                {error:.2f}%")
print(f"{'='*50}")

ase.io.write(str(output_dir / part_dirs["part1"] / "ni_bulk_relaxed.cif"), ni_bulk)

# Store results for later use
a_opt = a_optimized
Initial lattice constant: 3.52 Å
Number of atoms: 4
/tmp/ipykernel_9733/3959847705.py:15: DeprecationWarning: Use FrechetCellFilter for better convergence w.r.t. cell variables.
  ecf = ExpCellFilter(ni_bulk)

==================================================
Experimental lattice constant: 3.52 Å
Optimized lattice constant:    3.51 Å
Relative error:                0.50%
==================================================

Part 2: Surface Energy Calculations

Introduction

Surface energy (γ) quantifies the thermodynamic cost of creating a surface. It determines surface stability, morphology, and catalytic activity. We’ll calculate γ for four low-index Ni facets: (111), (100), (110), and (211).

Theory

The surface energy is defined as:

γ=EslabNEbulk2A\gamma = \frac{E_{\text{slab}} - N \cdot E_{\text{bulk}}}{2A}

where:

Challenge: Direct calculation suffers from quantum size effects, and if you were doing DFT calculations small numerical errors in the simulation or from the K-point grid sampling can lead to small (but significant) errors in the bulk lattice energy.

Solution: It is fairly common when calculating surface energies to use the bulk energy from a bulk relaxation in the above equation. However, because DFT often has some small numerical noise in the predictions from k-point convergence, this might lead to the wrong surface energy. Instead, two more careful schemes are either:

  1. Calculate the energy of a bulk structure oriented to each slab to maximize cancellation of small numerical errors or

  2. Calculate the energy of multiple slabs at multiple thicknesses and extrapolate to zero thickness. The intercept will be the surface energy, and the slope will be a fitted bulk energy. A benefit of this approach is that it also forces us to check that we have a sufficiently thick slab for a well defined surface energy; if the fit is non-linear we need thicker slabs.

We’ll use the linear extrapolation method here as it’s more likely to work in future DFT studies if you use this code!

Step 1: Setup and Bulk Energy Reference

First, we’ll set up the calculation parameters and get the bulk energy reference:

# Calculate surface energies for all facets
facets = [(1, 1, 1), (1, 0, 0), (1, 1, 0), (2, 1, 1)]
surface_energies = {}
surface_energies_SI = {}
all_fit_data = {}

# Get bulk energy reference (only need to do this once)
E_bulk_total = ni_bulk.get_potential_energy()
N_bulk = len(ni_bulk)
E_bulk_per_atom = E_bulk_total / N_bulk

print(f"Bulk energy reference:")
print(f"  Total energy: {E_bulk_total:.2f} eV")
print(f"  Number of atoms: {N_bulk}")
print(f"  Energy per atom: {E_bulk_per_atom:.6f} eV/atom")
Bulk energy reference:
  Total energy: -21.98 eV
  Number of atoms: 4
  Energy per atom: -5.494851 eV/atom

Step 2: Generate and Relax Slabs

Now we’ll loop through each facet, generating slabs at three different thicknesses:

# Convert bulk to pymatgen structure for slab generation
adaptor = AseAtomsAdaptor()
ni_structure = adaptor.get_structure(ni_bulk)

for facet in facets:
    facet_str = "".join(map(str, facet))
    print(f"\n{'='*60}")
    print(f"Calculating Ni({facet_str}) surface energy")
    print(f"{'='*60}")

    # Calculate for three thicknesses
    thicknesses = [4, 6, 8]  # layers
    n_atoms_list = []
    energies_list = []

    for n_layers in thicknesses:
        print(f"\n  Thickness: {n_layers} layers")

        # Generate slab
        slabgen = SlabGenerator(
            ni_structure,
            facet,
            min_slab_size=n_layers * a_opt / np.sqrt(sum([h**2 for h in facet])),
            min_vacuum_size=10.0,
            center_slab=True,
        )
        pmg_slab = slabgen.get_slabs()[0]
        slab = adaptor.get_atoms(pmg_slab)
        slab.center(vacuum=10.0, axis=2)

        print(f"    Atoms: {len(slab)}")

        # Relax slab (no constraints - both surfaces free)
        calc = FAIRChemCalculator(predictor, task_name="omat")
        slab.calc = calc
        opt = LBFGS(slab, logfile=None)
        opt.run(fmax=0.05, steps=relaxation_steps)

        E_slab = slab.get_potential_energy()
        n_atoms_list.append(len(slab))
        energies_list.append(E_slab)
        print(f"    Energy: {E_slab:.2f} eV")

    # Linear regression: E_slab = slope * N + intercept
    coeffs = np.polyfit(n_atoms_list, energies_list, 1)
    slope = coeffs[0]
    intercept = coeffs[1]

    # Extract surface energy from intercept
    cell = slab.get_cell()
    area = np.linalg.norm(np.cross(cell[0], cell[1]))
    gamma = intercept / (2 * area)  # eV/Ų
    gamma_SI = gamma * 16.0218  # J/m²

    print(f"\n  Linear fit:")
    print(f"    Slope:     {slope:.6f} eV/atom (cf. bulk {E_bulk_per_atom:.6f})")
    print(f"    Intercept: {intercept:.2f} eV")
    print(f"\n  Surface energy:")
    print(f"    γ = {gamma:.6f} eV/Ų = {gamma_SI:.2f} J/m²")

    # Store results and fit data
    surface_energies[facet] = gamma
    surface_energies_SI[facet] = gamma_SI
    all_fit_data[facet] = {
        "n_atoms": n_atoms_list,
        "energies": energies_list,
        "slope": slope,
        "intercept": intercept,
    }

============================================================
Calculating Ni(111) surface energy
============================================================

  Thickness: 4 layers
    Atoms: 4
    Energy: -20.66 eV

  Thickness: 6 layers
    Atoms: 6
    Energy: -31.64 eV

  Thickness: 8 layers
    Atoms: 8
    Energy: -42.63 eV

  Linear fit:
    Slope:     -5.491917 eV/atom (cf. bulk -5.494851)
    Intercept: 1.31 eV

  Surface energy:
    γ = 0.122694 eV/Ų = 1.97 J/m²

============================================================
Calculating Ni(100) surface energy
============================================================

  Thickness: 4 layers
    Atoms: 8
    Energy: -42.15 eV

  Thickness: 6 layers
    Atoms: 12
    Energy: -64.13 eV

  Thickness: 8 layers
    Atoms: 16
    Energy: -86.11 eV

  Linear fit:
    Slope:     -5.494140 eV/atom (cf. bulk -5.494851)
    Intercept: 1.80 eV

  Surface energy:
    γ = 0.146291 eV/Ų = 2.34 J/m²

============================================================
Calculating Ni(110) surface energy
============================================================

  Thickness: 4 layers
    Atoms: 8
    Energy: -41.39 eV

  Thickness: 6 layers
    Atoms: 12
    Energy: -63.36 eV

  Thickness: 8 layers
    Atoms: 16
    Energy: -85.34 eV

  Linear fit:
    Slope:     -5.493909 eV/atom (cf. bulk -5.494851)
    Intercept: 2.57 eV

  Surface energy:
    γ = 0.147560 eV/Ų = 2.36 J/m²

============================================================
Calculating Ni(211) surface energy
============================================================

  Thickness: 4 layers
    Atoms: 8
    Energy: -39.59 eV

  Thickness: 6 layers
    Atoms: 12
    Energy: -61.60 eV

  Thickness: 8 layers
    Atoms: 16
    Energy: -83.55 eV

  Linear fit:
    Slope:     -5.494914 eV/atom (cf. bulk -5.494851)
    Intercept: 4.36 eV

  Surface energy:
    γ = 0.144676 eV/Ų = 2.32 J/m²

Step 3: Visualize Linear Fits

Let’s visualize the linear extrapolation for all four facets:

# Visualize linear fits for all facets
fig, axes = plt.subplots(2, 2, figsize=(12, 10))
axes = axes.flatten()

for idx, facet in enumerate(facets):
    ax = axes[idx]
    data = all_fit_data[facet]

    # Plot data points
    ax.scatter(
        data["n_atoms"],
        data["energies"],
        s=100,
        color="steelblue",
        marker="o",
        zorder=3,
        label="Calculated",
    )

    # Plot fit line
    n_range = np.linspace(min(data["n_atoms"]) - 5, max(data["n_atoms"]) + 5, 100)
    E_fit = data["slope"] * n_range + data["intercept"]
    ax.plot(
        n_range,
        E_fit,
        "r--",
        linewidth=2,
        label=f'Fit: {data["slope"]:.2f}N + {data["intercept"]:.2f}',
    )

    # Formatting
    facet_str = f"Ni({facet[0]}{facet[1]}{facet[2]})"
    ax.set_xlabel("Number of Atoms", fontsize=11)
    ax.set_ylabel("Slab Energy (eV)", fontsize=11)
    ax.set_title(
        f"{facet_str}: γ = {surface_energies_SI[facet]:.2f} J/m²",
        fontsize=12,
        fontweight="bold",
    )
    ax.legend(fontsize=9)
    ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig(
    str(output_dir / part_dirs["part2"] / "surface_energy_fits.png"),
    dpi=300,
    bbox_inches="tight",
)
plt.show()
<Figure size 1200x1000 with 4 Axes>

Step 4: Compare with Literature

Finally, let’s compare our calculated surface energies with DFT literature values:

print(f"\n{'='*70}")
print("Comparison with DFT Literature (Tran et al., 2016)")
print(f"{'='*70}")
lit_values = {
    (1, 1, 1): 1.92,
    (1, 0, 0): 2.21,
    (1, 1, 0): 2.29,
    (2, 1, 1): 2.24,
}  # J/m²

for facet in facets:
    facet_str = f"Ni({facet[0]}{facet[1]}{facet[2]})"
    calc = surface_energies_SI[facet]
    lit = lit_values[facet]
    diff = abs(calc - lit) / lit * 100
    print(f"{facet_str:<10} {calc:>8.2f} J/m²  (Lit: {lit:.2f}, Δ={diff:.1f}%)")

======================================================================
Comparison with DFT Literature (Tran et al., 2016)
======================================================================
Ni(111)        1.97 J/m²  (Lit: 1.92, Δ=2.4%)
Ni(100)        2.34 J/m²  (Lit: 2.21, Δ=6.1%)
Ni(110)        2.36 J/m²  (Lit: 2.29, Δ=3.2%)
Ni(211)        2.32 J/m²  (Lit: 2.24, Δ=3.5%)

Explore on Your Own

  1. Thickness convergence: Add 10 and 12 layer calculations. Is the linear fit still valid?

  2. Constraint effects: Fix the bottom 2 layers during relaxation. How does this affect γ?

  3. Vacuum size: Vary min_vacuum_size from 8 to 15 Å. When does γ converge?

  4. High-index facets: Try (311) or (331) surfaces. Are they more or less stable?

  5. Alternative fitting: Use polynomial (degree 2) instead of linear fit. Does the intercept change?


Part 3: Wulff Construction

Introduction

The Wulff construction predicts the equilibrium shape of a crystalline particle by minimizing total surface energy. This determines the morphology of supported catalyst nanoparticles.

Theory

The Wulff theorem states that at equilibrium, the distance from the particle center to a facet is proportional to its surface energy:

hiγi=constant\frac{h_i}{\gamma_i} = \text{constant}

Facets with lower surface energy have larger areas in the equilibrium shape.

Step 1: Prepare Surface Energies

We’ll use the surface energies calculated in Part 2 to construct the Wulff shape:

print("\nConstructing Wulff Shape")
print("=" * 50)

# Use optimized bulk structure
adaptor = AseAtomsAdaptor()
ni_structure = adaptor.get_structure(ni_bulk)

miller_list = list(surface_energies_SI.keys())
energy_list = [surface_energies_SI[m] for m in miller_list]

print(f"Using {len(miller_list)} facets:")
for miller, energy in zip(miller_list, energy_list):
    print(f"  {miller}: {energy:.2f} J/m²")

Constructing Wulff Shape
==================================================
Using 4 facets:
  (1, 1, 1): 1.97 J/m²
  (1, 0, 0): 2.34 J/m²
  (1, 1, 0): 2.36 J/m²
  (2, 1, 1): 2.32 J/m²

Step 2: Generate Wulff Construction

Now we create the Wulff shape and analyze its properties:

# Create Wulff shape
wulff = WulffShape(ni_structure.lattice, miller_list, energy_list)

# Print properties
print(f"\nWulff Shape Properties:")
print(f"  Volume:          {wulff.volume:.2f} ų")
print(f"  Surface area:    {wulff.surface_area:.2f} Ų")
print(f"  Effective radius: {wulff.effective_radius:.2f} Å")
print(f"  Weighted γ:      {wulff.weighted_surface_energy:.2f} J/m²")

# Area fractions
print(f"\nFacet Area Fractions:")
area_frac = wulff.area_fraction_dict
for hkl, frac in sorted(area_frac.items(), key=lambda x: x[1], reverse=True):
    print(f"  {hkl}: {frac*100:.1f}%")

Wulff Shape Properties:
  Volume:          47.73 ų
  Surface area:    69.30 Ų
  Effective radius: 2.25 Å
  Weighted γ:      2.07 J/m²

Facet Area Fractions:
  (1, 1, 1): 73.2%
  (1, 0, 0): 17.0%
  (2, 1, 1): 6.1%
  (1, 1, 0): 3.8%

Step 3: Visualize and Compare

Let’s visualize the Wulff shape and compare with literature:

# Visualize
fig = wulff.get_plot()
plt.title("Wulff Construction: Ni Nanoparticle", fontsize=14)
plt.tight_layout()
plt.savefig(
    str(output_dir / part_dirs["part3"] / "wulff_shape.png"),
    dpi=300,
    bbox_inches="tight",
)
plt.show()

# Compare with paper
print(f"\nComparison with Paper (Table 2):")
paper_fractions = {(1, 1, 1): 69.23, (1, 0, 0): 21.10, (1, 1, 0): 5.28, (2, 1, 1): 4.39}
for hkl in miller_list:
    calc_frac = area_frac.get(hkl, 0) * 100
    paper_frac = paper_fractions.get(hkl, 0)
    print(f"  {hkl}: {calc_frac:>6.1f}% (Paper: {paper_frac:.1f}%)")
<Figure size 800x800 with 1 Axes>

Comparison with Paper (Table 2):
  (1, 1, 1):   73.2% (Paper: 69.2%)
  (1, 0, 0):   17.0% (Paper: 21.1%)
  (1, 1, 0):    3.8% (Paper: 5.3%)
  (2, 1, 1):    6.1% (Paper: 4.4%)

Explore on Your Own

  1. Particle size effects: How would including edge/corner energies modify the shape?

  2. Anisotropic strain: Apply 2% compressive strain to the lattice. How does the shape change?

  3. Temperature effects: Surface energies decrease with T. Estimate γ(T) and recompute Wulff shape.

  4. Alloy nanoparticles: Replace some Ni with Cu or Au. How would segregation affect the shape?

  5. Support effects: Some facets interact more strongly with supports. Model this by reducing their γ.


Part 4: H Adsorption Energy with ZPE Correction

Introduction

Hydrogen adsorption is a fundamental step in many catalytic reactions (hydrogenation, dehydrogenation, etc.). We’ll calculate the binding energy with vibrational zero-point energy (ZPE) corrections.

Theory

The adsorption energy is:

Eads=E(slab+H)E(slab)12E(H2)E_{\text{ads}} = E(\text{slab+H}) - E(\text{slab}) - \frac{1}{2}E(\text{H}_2)

ZPE correction accounts for quantum vibrational effects:

EadsZPE=Eads+ZPE(H)12ZPE(H2)E_{\text{ads}}^{\text{ZPE}} = E_{\text{ads}} + \text{ZPE}(\text{H}^*) - \frac{1}{2}\text{ZPE}(\text{H}_2)

The ZPE correction is calculated by analyzing the vibrational modes of the molecule/adsorbate.

Step 1: Setup and Relax Clean Slab

First, we create the Ni(111) surface and relax it:

# Create Ni(111) slab
ni_bulk_atoms = bulk("Ni", "fcc", a=a_opt, cubic=True)
ni_bulk_obj = Bulk(bulk_atoms=ni_bulk_atoms)
ni_slabs = Slab.from_bulk_get_specific_millers(
    bulk=ni_bulk_obj, specific_millers=(1, 1, 1)
)
ni_slab = ni_slabs[0].atoms

print(f"   Created {len(ni_slab)} atom slab")

# Set up calculators
calc = FAIRChemCalculator(predictor, task_name="oc20")
d3_calc = TorchDFTD3Calculator(device="cpu", damping="bj")
print("   Calculators initialized (ML + D3)")
   Created 96 atom slab
   Calculators initialized (ML + D3)

Step 2: Relax Clean Slab

Relax the bare Ni(111) surface as our reference:

print("\n1. Relaxing clean Ni(111) slab...")
clean_slab = ni_slab.copy()
clean_slab.set_pbc([True, True, True])
clean_slab.calc = calc

opt = LBFGS(
    clean_slab,
    trajectory=str(output_dir / part_dirs["part4"] / "ni111_clean.traj"),
    logfile=str(output_dir / part_dirs["part4"] / "ni111_clean.log"),
)
opt.run(fmax=0.05, steps=relaxation_steps)

E_clean_ml = clean_slab.get_potential_energy()
clean_slab.calc = d3_calc
E_clean_d3 = clean_slab.get_potential_energy()
E_clean = E_clean_ml + E_clean_d3
print(f"   E(clean): {E_clean:.2f} eV (ML: {E_clean_ml:.2f}, D3: {E_clean_d3:.2f})")

# Save clean slab
ase.io.write(str(output_dir / part_dirs["part4"] / "ni111_clean.xyz"), clean_slab)
print("   ✓ Clean slab relaxed and saved")

1. Relaxing clean Ni(111) slab...
/home/runner/work/_tool/Python/3.12.13/x64/lib/python3.12/site-packages/torch_dftd/torch_dftd3_calculator.py:98: UserWarning: Creating a tensor from a list of numpy.ndarrays is extremely slow. Please consider converting the list to a single numpy.ndarray with numpy.array() before converting to a tensor. (Triggered internally at /pytorch/torch/csrc/utils/tensor_new.cpp:253.)
  cell: Optional[Tensor] = torch.tensor(
   E(clean): -487.48 eV (ML: -450.70, D3: -36.79)
   ✓ Clean slab relaxed and saved

Step 3: Generate H Adsorption Sites

Use heuristic placement to generate multiple candidate H adsorption sites:

print("\n2. Generating 5 H adsorption sites...")
ni_slab_for_ads = ni_slabs[0]
ni_slab_for_ads.atoms = clean_slab.copy()

adsorbate_h = Adsorbate(adsorbate_smiles_from_db="*H")
ads_slab_config = AdsorbateSlabConfig(
    ni_slab_for_ads,
    adsorbate_h,
    mode="random_site_heuristic_placement",
    num_sites=num_sites,
)

print(f"   Generated {len(ads_slab_config.atoms_list)} initial configurations")
print("   These include fcc, hcp, bridge, and top sites")

2. Generating 5 H adsorption sites...
   Generated 2 initial configurations
   These include fcc, hcp, bridge, and top sites

Step 4: Relax All H Configurations

Relax each configuration and identify the most stable site:

print("\n3. Relaxing all H adsorption configurations...")
h_energies = []
h_configs = []
h_d3_energies = []

for idx, config in enumerate(ads_slab_config.atoms_list):
    config_relaxed = config.copy()
    config_relaxed.set_pbc([True, True, True])
    config_relaxed.calc = calc

    opt = LBFGS(
        config_relaxed,
        trajectory=str(output_dir / part_dirs["part4"] / f"h_site_{idx+1}.traj"),
        logfile=str(output_dir / part_dirs["part4"] / f"h_site_{idx+1}.log"),
    )
    opt.run(fmax=0.05, steps=relaxation_steps)

    E_ml = config_relaxed.get_potential_energy()
    config_relaxed.calc = d3_calc
    E_d3 = config_relaxed.get_potential_energy()
    E_total = E_ml + E_d3

    h_energies.append(E_total)
    h_configs.append(config_relaxed)
    h_d3_energies.append(E_d3)
    print(f"   Config {idx+1}: {E_total:.2f} eV (ML: {E_ml:.2f}, D3: {E_d3:.2f})")

    # Save structure
    ase.io.write(
        str(output_dir / part_dirs["part4"] / f"h_site_{idx+1}.xyz"), config_relaxed
    )

# Select best configuration
best_idx = np.argmin(h_energies)
slab_with_h = h_configs[best_idx]
E_with_h = h_energies[best_idx]
E_with_h_d3 = h_d3_energies[best_idx]

print(f"\n   ✓ Best site: Config {best_idx+1}, E = {E_with_h:.2f} eV")
print(f"   Energy spread: {max(h_energies) - min(h_energies):.2f} eV")
print(f"   This spread indicates the importance of testing multiple sites!")

3. Relaxing all H adsorption configurations...
   Config 1: -491.54 eV (ML: -454.67, D3: -36.87)
   Config 2: -491.56 eV (ML: -454.69, D3: -36.87)

   ✓ Best site: Config 2, E = -491.56 eV
   Energy spread: 0.01 eV
   This spread indicates the importance of testing multiple sites!

Step 5: Calculate H₂ Reference Energy

We need the H₂ molecule energy as a reference:

print("\n4. Calculating H₂ reference energy...")
h2 = Atoms("H2", positions=[[0, 0, 0], [0, 0, 0.74]])
h2.center(vacuum=10.0)
h2.set_pbc([True, True, True])
h2.calc = calc

opt = LBFGS(
    h2,
    trajectory=str(output_dir / part_dirs["part4"] / "h2.traj"),
    logfile=str(output_dir / part_dirs["part4"] / "h2.log"),
)
opt.run(fmax=0.05, steps=relaxation_steps)

E_h2_ml = h2.get_potential_energy()
h2.calc = d3_calc
E_h2_d3 = h2.get_potential_energy()
E_h2 = E_h2_ml + E_h2_d3
print(f"   E(H₂): {E_h2:.2f} eV (ML: {E_h2_ml:.2f}, D3: {E_h2_d3:.2f})")

# Save H2 structure
ase.io.write(str(output_dir / part_dirs["part4"] / "h2_optimized.xyz"), h2)

4. Calculating H₂ reference energy...
   E(H₂): -6.94 eV (ML: -6.94, D3: -0.00)

Step 6: Compute Adsorption Energy

Calculate the adsorption energy using the formula: E_ads = E(slab+H) - E(slab) - 0.5×E(H₂)

print(f"\n4. Computing Adsorption Energy:")
print("   E_ads = E(slab+H) - E(slab) - 0.5×E(H₂)")

E_ads = E_with_h - E_clean - 0.5 * E_h2
E_ads_no_d3 = (E_with_h - E_with_h_d3) - (E_clean - E_clean_d3) - 0.5 * (E_h2 - E_h2_d3)

print(f"\n   Without D3: {E_ads_no_d3:.2f} eV")
print(f"   With D3:    {E_ads:.2f} eV")
print(f"   D3 effect:  {E_ads - E_ads_no_d3:.2f} eV")
print(f"\n   → D3 corrections are negligible for H* (small, covalent bonding)")

4. Computing Adsorption Energy:
   E_ads = E(slab+H) - E(slab) - 0.5×E(H₂)

   Without D3: -0.52 eV
   With D3:    -0.60 eV
   D3 effect:  -0.08 eV

   → D3 corrections are negligible for H* (small, covalent bonding)

Step 7: Zero-Point Energy (ZPE) Corrections

Calculate vibrational frequencies to get ZPE corrections:

print("\n6. Computing ZPE corrections...")
print("   This accounts for quantum vibrational effects")
h_index = len(slab_with_h) - 1
slab_with_h.calc = calc
vib = Vibrations(slab_with_h, indices=[h_index], delta=0.02)
vib.run()
vib_energies = vib.get_energies()
zpe_ads = np.sum(vib_energies) / 2.0

h2.calc = calc
vib_h2 = Vibrations(h2, indices=[0, 1], delta=0.02)
vib_h2.run()
vib_energies_h2 = vib_h2.get_energies()
zpe_h2 = np.sum(vib_energies_h2) / 2.0

E_ads_zpe = E_ads + zpe_ads - 0.5 * zpe_h2

print(f"   ZPE(H*):  {zpe_ads:.2f} eV")
print(f"   ZPE(H₂):  {zpe_h2:.2f} eV")
print(f"   E_ads(ZPE): {E_ads_zpe:.2f} eV")

# Visualize vibrational modes
print("\n   Creating animations of vibrational modes...")
vib.write_mode(n=0)
ase.io.write("vib.0.gif", ase.io.read("vib.0.traj@:"), rotation=("-45x,0y,0z"))

vib.clean()
vib_h2.clean()

6. Computing ZPE corrections...
   This accounts for quantum vibrational effects
   ZPE(H*):  0.18+0.00j eV
   ZPE(H₂):  0.28+0.00j eV
   E_ads(ZPE): -0.56-0.00j eV

   Creating animations of vibrational modes...
0
<Figure size 640x480 with 1 Axes>

Step 8: Visualize and Compare Results

Visualize the best configuration and compare with literature:

print("\n7. Visualizing best H* configuration...")
view(slab_with_h, viewer='x3d')

7. Visualizing best H* configuration...
Loading...
# 6. Compare with literature
print(f"\n{'='*60}")
print("Comparison with Literature:")
print(f"{'='*60}")
print("Table 4 (DFT): -0.60 eV (Ni(111), ref H₂)")
print(f"This work:     {E_ads_zpe:.2f} eV")
print(f"Difference:    {abs(E_ads_zpe - (-0.60)):.2f} eV")

============================================================
Comparison with Literature:
============================================================
Table 4 (DFT): -0.60 eV (Ni(111), ref H₂)
This work:     -0.56-0.00j eV
Difference:    0.04 eV

Explore on Your Own

  1. Site preference: Identify which site (fcc, hcp, bridge, top) the H prefers. Visualize with view(atoms, viewer='x3d').

  2. Coverage effects: Place 2 H atoms on the slab. How does binding change with separation?

  3. Different facets: Compare H adsorption on (100) and (110) surfaces. Which is strongest?

  4. Subsurface H: Place H below the surface layer. Is it stable?

  5. ZPE uncertainty: How sensitive is E_ads to the vibrational delta parameter (try 0.01, 0.03 Å)?


Part 5: Coverage-Dependent H Adsorption

Introduction

At higher coverages, adsorbate-adsorbate interactions become significant. We’ll study how H binding energy changes from dilute (1 atom) to saturated (full monolayer) coverage.

Theory

The differential adsorption energy at coverage θ is:

Eads(θ)=E(nH)E()n12E(H2)nE_{\text{ads}}(\theta) = \frac{E(n\text{H}^*) - E(*) - n \cdot \frac{1}{2}E(\text{H}_2)}{n}

For many systems, this varies linearly:

Eads(θ)=Eads(0)+βθE_{\text{ads}}(\theta) = E_{\text{ads}}(0) + \beta \theta

where β quantifies lateral interactions (repulsive if β > 0).

Step 1: Setup Slab and Calculators

Create a larger Ni(111) slab to accommodate multiple adsorbates:

# Create large Ni(111) slab
ni_bulk_atoms = bulk("Ni", "fcc", a=a_opt, cubic=True)
ni_bulk_obj = Bulk(bulk_atoms=ni_bulk_atoms)
ni_slabs = Slab.from_bulk_get_specific_millers(
    bulk=ni_bulk_obj, specific_millers=(1, 1, 1)
)
slab = ni_slabs[0].atoms.copy()

print(f"   Created {len(slab)} atom slab")

# Set up calculators
base_calc = FAIRChemCalculator(predictor, task_name="oc20")
d3_calc = TorchDFTD3Calculator(device="cpu", damping="bj")
print("   ✓ Calculators initialized")
   Created 96 atom slab
   ✓ Calculators initialized

Step 2: Calculate Reference Energies

Get reference energies for clean surface and H₂:

print("\n1. Relaxing clean slab...")
clean_slab = slab.copy()
clean_slab.pbc = True
clean_slab.calc = base_calc

opt = LBFGS(
    clean_slab,
    trajectory=str(output_dir / part_dirs["part5"] / "ni111_clean.traj"),
    logfile=str(output_dir / part_dirs["part5"] / "ni111_clean.log"),
)
opt.run(fmax=0.05, steps=relaxation_steps)

E_clean_ml = clean_slab.get_potential_energy()
clean_slab.calc = d3_calc
E_clean_d3 = clean_slab.get_potential_energy()
E_clean = E_clean_ml + E_clean_d3
print(f"   E(clean): {E_clean:.2f} eV")

print("\n2. Calculating H₂ reference...")
h2 = Atoms("H2", positions=[[0, 0, 0], [0, 0, 0.74]])
h2.center(vacuum=10.0)
h2.set_pbc([True, True, True])
h2.calc = base_calc

opt = LBFGS(
    h2,
    trajectory=str(output_dir / part_dirs["part5"] / "h2.traj"),
    logfile=str(output_dir / part_dirs["part5"] / "h2.log"),
)
opt.run(fmax=0.05, steps=relaxation_steps)

E_h2_ml = h2.get_potential_energy()
h2.calc = d3_calc
E_h2_d3 = h2.get_potential_energy()
E_h2 = E_h2_ml + E_h2_d3
print(f"   E(H₂): {E_h2:.2f} eV")

1. Relaxing clean slab...
   E(clean): -487.48 eV

2. Calculating H₂ reference...
   E(H₂): -6.94 eV

Step 3: Set Up Coverage Study

Define the coverages we’ll test (from dilute to nearly 1 ML):

# Count surface sites
tags = slab.get_tags()
n_sites = np.sum(tags == 1)
print(f"\n3. Surface sites: {n_sites} (4×4 Ni(111))")

# Test coverages: 1 H, 0.25 ML, 0.5 ML, 0.75 ML, 1.0 ML
coverages_to_test = [1, 4, 8, 12, 16]
print(f"\n   Will test coverages: {[f'{n/n_sites:.2f} ML' for n in coverages_to_test]}")
print("   This spans from dilute to nearly full monolayer")

coverages = []
adsorption_energies = []

3. Surface sites: 16 (4×4 Ni(111))

   Will test coverages: ['0.06 ML', '0.25 ML', '0.50 ML', '0.75 ML', '1.00 ML']
   This spans from dilute to nearly full monolayer

Step 4: Generate and Relax Configurations at Each Coverage

For each coverage, generate multiple configurations and find the lowest energy:

for n_h in coverages_to_test:
    print(f"\n3. Coverage: {n_h} H ({n_h/n_sites:.2f} ML)")

    # Generate configurations
    ni_bulk_obj_h = Bulk(bulk_atoms=ni_bulk_atoms)
    ni_slabs_h = Slab.from_bulk_get_specific_millers(
        bulk=ni_bulk_obj_h, specific_millers=(1, 1, 1)
    )
    slab_for_ads = ni_slabs_h[0]
    slab_for_ads.atoms = clean_slab.copy()

    adsorbates_list = [Adsorbate(adsorbate_smiles_from_db="*H") for _ in range(n_h)]

    try:
        multi_ads_config = MultipleAdsorbateSlabConfig(
            slab_for_ads, adsorbates_list, num_configurations=num_sites
        )
    except ValueError as e:
        print(f"   ⚠ Configuration generation failed: {e}")
        continue

    if len(multi_ads_config.atoms_list) == 0:
        print(f"   ⚠ No configurations generated")
        continue

    print(f"   Generated {len(multi_ads_config.atoms_list)} configurations")

    # Relax each and find best
    config_energies = []

    for idx, config in enumerate(multi_ads_config.atoms_list):
        config_relaxed = config.copy()
        config_relaxed.set_pbc([True, True, True])
        config_relaxed.calc = base_calc

        opt = LBFGS(config_relaxed, logfile=None)
        opt.run(fmax=0.05, steps=relaxation_steps)

        E_ml = config_relaxed.get_potential_energy()
        config_relaxed.calc = d3_calc
        E_d3 = config_relaxed.get_potential_energy()
        E_total = E_ml + E_d3

        config_energies.append(E_total)
        print(f"     Config {idx+1}: {E_total:.2f} eV")

    best_idx = np.argmin(config_energies)
    best_energy = config_energies[best_idx]
    best_config = multi_ads_config.atoms_list[best_idx]
    E_ads_per_h = (best_energy - E_clean - n_h * 0.5 * E_h2) / n_h

    coverage = n_h / n_sites
    coverages.append(coverage)
    adsorption_energies.append(E_ads_per_h)

    print(f"   → E_ads/H: {E_ads_per_h:.2f} eV")

    # Visualize best configuration at this coverage
    print(f"   Visualizing configuration with {n_h} H atoms...")
    view(best_config, viewer='x3d')

print(f"\n✓ Completed coverage study: {len(coverages)} data points")

3. Coverage: 1 H (0.06 ML)
   Generated 2 configurations
     Config 1: -491.54 eV
     Config 2: -491.41 eV
   → E_ads/H: -0.59 eV
   Visualizing configuration with 1 H atoms...

3. Coverage: 4 H (0.25 ML)
   Generated 2 configurations
     Config 1: -503.39 eV
     Config 2: -503.39 eV
   → E_ads/H: -0.51 eV
   Visualizing configuration with 4 H atoms...

3. Coverage: 8 H (0.50 ML)
   Generated 2 configurations
     Config 1: -517.42 eV
     Config 2: -518.62 eV
   → E_ads/H: -0.42 eV
   Visualizing configuration with 8 H atoms...

3. Coverage: 12 H (0.75 ML)
   Generated 2 configurations
     Config 1: -533.58 eV
     Config 2: -532.92 eV
   → E_ads/H: -0.37 eV
   Visualizing configuration with 12 H atoms...

3. Coverage: 16 H (1.00 ML)
   Generated 2 configurations
     Config 1: -547.66 eV
     Config 2: -547.15 eV
   → E_ads/H: -0.29 eV
   Visualizing configuration with 16 H atoms...

✓ Completed coverage study: 5 data points

Step 5: Perform Linear Fit

Fit E_ads vs coverage to extract the slope (lateral interaction strength):

print("\n4. Performing linear fit to coverage dependence...")

# Linear fit
from numpy.polynomial import Polynomial

p = Polynomial.fit(coverages, adsorption_energies, 1)
slope = p.coef[1]
intercept = p.coef[0]

print(f"\n{'='*60}")
print(f"Linear Fit: E_ads = {intercept:.2f} + {slope:.2f}θ (eV)")
print(f"Slope: {slope * 96.485:.1f} kJ/mol per ML")
print(f"Paper: 8.7 kJ/mol per ML")
print(f"{'='*60}")

4. Performing linear fit to coverage dependence...

============================================================
Linear Fit: E_ads = -0.43 + 0.14θ (eV)
Slope: 13.8 kJ/mol per ML
Paper: 8.7 kJ/mol per ML
============================================================

Step 6: Visualize Coverage Dependence

Create a plot showing how adsorption energy changes with coverage:

print("\n5. Plotting coverage dependence...")

# Plot
fig, ax = plt.subplots(figsize=(8, 6))
ax.scatter(
    coverages,
    adsorption_energies,
    s=100,
    marker="o",
    label="Calculated",
    zorder=3,
    color="steelblue",
)

cov_fit = np.linspace(0, max(coverages), 100)
ads_fit = p(cov_fit)
ax.plot(
    cov_fit, ads_fit, "r--", label=f"Fit: {intercept:.2f} + {slope:.2f}θ", linewidth=2
)

ax.set_xlabel("H Coverage (ML)", fontsize=12)
ax.set_ylabel("Adsorption Energy (eV/H)", fontsize=12)
ax.set_title("Coverage-Dependent H Adsorption on Ni(111)", fontsize=14)
ax.legend(fontsize=11)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig(str(output_dir / part_dirs["part5"] / "coverage_dependence.png"), dpi=300)
plt.show()

print("\n✓ Coverage dependence analysis complete!")

5. Plotting coverage dependence...
<Figure size 800x600 with 1 Axes>

✓ Coverage dependence analysis complete!

Explore on Your Own

  1. Non-linear behavior: Use polynomial (degree 2) fit. Is there curvature at high coverage?

  2. Temperature effects: Estimate configurational entropy at each coverage. How does this affect free energy?

  3. Pattern formation: Visualize the lowest-energy configuration at 0.5 ML. Are H atoms ordered?

  4. Other adsorbates: Repeat for O or N. How do lateral interactions compare?

  5. Phase diagrams: At what coverage do you expect phase separation (islands vs uniform)?


Part 6: CO Formation/Dissociation Thermochemistry and Barrier

Introduction

CO dissociation (CO* → C* + O*) is the rate-limiting step in many catalytic processes (Fischer-Tropsch, CO oxidation, etc.). We’ll calculate the reaction energy for C* + O* → CO* and the activation barriers in both directions using the nudged elastic band (NEB) method.

Theory

Step 1: Setup Slab and Calculators

Initialize the Ni(111) surface and calculators:

# Create slab
ni_bulk_atoms = bulk("Ni", "fcc", a=a_opt, cubic=True)
ni_bulk_obj = Bulk(bulk_atoms=ni_bulk_atoms)
ni_slabs = Slab.from_bulk_get_specific_millers(
    bulk=ni_bulk_obj, specific_millers=(1, 1, 1)
)
slab = ni_slabs[0].atoms

print(f"   Created {len(slab)} atom slab")

base_calc = FAIRChemCalculator(predictor, task_name="oc20")
d3_calc = TorchDFTD3Calculator(device="cpu", damping="bj")
print("   \u2713 Calculators initialized")
   Created 96 atom slab
   ✓ Calculators initialized

Step 2: Generate and Relax Final State (CO*)

Find the most stable CO adsorption configuration (this is the product of C+O recombination):

print("\n1. Final State: CO* on Ni(111)")
print("   Generating CO adsorption configurations...")

ni_bulk_obj_co = Bulk(bulk_atoms=ni_bulk_atoms)
ni_slab_co = Slab.from_bulk_get_specific_millers(
    bulk=ni_bulk_obj_co, specific_millers=(1, 1, 1)
)[0]
ni_slab_co.atoms = slab.copy()

adsorbate_co = Adsorbate(adsorbate_smiles_from_db="*CO")
multi_ads_config_co = MultipleAdsorbateSlabConfig(
    ni_slab_co, [adsorbate_co], num_configurations=num_sites
)

print(f"   Generated {len(multi_ads_config_co.atoms_list)} configurations")

# Relax and find best
co_energies = []
co_energies_ml = []
co_energies_d3 = []
co_configs = []

for idx, config in enumerate(multi_ads_config_co.atoms_list):
    config_relaxed = config.copy()
    config_relaxed.set_pbc([True, True, True])
    config_relaxed.calc = base_calc
    opt = LBFGS(config_relaxed, logfile=None)
    opt.run(fmax=0.05, steps=relaxation_steps)

    E_ml = config_relaxed.get_potential_energy()
    config_relaxed.calc = d3_calc
    E_d3 = config_relaxed.get_potential_energy()
    E_total = E_ml + E_d3

    co_energies.append(E_total)
    co_energies_ml.append(E_ml)
    co_energies_d3.append(E_d3)
    co_configs.append(config_relaxed)
    print(
        f"     Config {idx+1}: E_total = {E_total:.2f} eV (RPBE: {E_ml:.2f}, D3: {E_d3:.2f})"
    )

best_co_idx = np.argmin(co_energies)
final_co = co_configs[best_co_idx]
E_final_co = co_energies[best_co_idx]
E_final_co_ml = co_energies_ml[best_co_idx]
E_final_co_d3 = co_energies_d3[best_co_idx]

print(f"\n   → Best CO* (Config {best_co_idx+1}):")
print(f"      RPBE:  {E_final_co_ml:.2f} eV")
print(f"      D3:    {E_final_co_d3:.2f} eV")
print(f"      Total: {E_final_co:.2f} eV")

# Save best CO state
ase.io.write(str(output_dir / part_dirs["part6"] / "co_final_best.traj"), final_co)
print("   ✓ Best CO* structure saved")

# Visualize best CO* structure
print("\n   Visualizing best CO* structure...")
view(final_co, viewer='x3d')

1. Final State: CO* on Ni(111)
   Generating CO adsorption configurations...
   Generated 2 configurations
     Config 1: E_total = -503.89 eV (RPBE: -466.82, D3: -37.07)
     Config 2: E_total = -503.97 eV (RPBE: -466.92, D3: -37.04)

   → Best CO* (Config 2):
      RPBE:  -466.92 eV
      D3:    -37.04 eV
      Total: -503.97 eV
   ✓ Best CO* structure saved

   Visualizing best CO* structure...
Loading...

Step 3: Generate and Relax Initial State (C* + O*)

Find the most stable configuration for dissociated C and O (reactants):

print("\n2. Initial State: C* + O* on Ni(111)")
print("   Generating C+O configurations...")

ni_bulk_obj_c_o = Bulk(bulk_atoms=ni_bulk_atoms)
ni_slab_c_o = Slab.from_bulk_get_specific_millers(
    bulk=ni_bulk_obj_c_o, specific_millers=(1, 1, 1)
)[0]

adsorbate_c = Adsorbate(adsorbate_smiles_from_db="*C")
adsorbate_o = Adsorbate(adsorbate_smiles_from_db="*O")

multi_ads_config_c_o = MultipleAdsorbateSlabConfig(
    ni_slab_c_o, [adsorbate_c, adsorbate_o], num_configurations=num_sites
)

print(f"   Generated {len(multi_ads_config_c_o.atoms_list)} configurations")

c_o_energies = []
c_o_energies_ml = []
c_o_energies_d3 = []
c_o_configs = []

for idx, config in enumerate(multi_ads_config_c_o.atoms_list):
    config_relaxed = config.copy()
    config_relaxed.set_pbc([True, True, True])
    config_relaxed.calc = base_calc
    opt = LBFGS(config_relaxed, logfile=None)
    opt.run(fmax=0.05, steps=relaxation_steps)

    # Check C-O bond distance to ensure they haven't formed CO molecule
    c_o_dist = config_relaxed[config_relaxed.get_tags() == 2].get_distance(
        0, 1, mic=True
    )

    # CO bond length is ~1.15 Å, so if distance < 1.5 Å, they've formed a molecule
    if c_o_dist < 1.5:
        print(
            f"     Config {idx+1}: ⚠ REJECTED - C and O formed CO molecule (d = {c_o_dist:.2f} Å)"
        )
        continue

    E_ml = config_relaxed.get_potential_energy()
    config_relaxed.calc = d3_calc
    E_d3 = config_relaxed.get_potential_energy()
    E_total = E_ml + E_d3

    c_o_energies.append(E_total)
    c_o_energies_ml.append(E_ml)
    c_o_energies_d3.append(E_d3)
    c_o_configs.append(config_relaxed)
    print(
        f"     Config {idx+1}: E_total = {E_total:.2f} eV (RPBE: {E_ml:.2f}, D3: {E_d3:.2f}, C-O dist: {c_o_dist:.2f} Å)"
    )

best_c_o_idx = np.argmin(c_o_energies)
initial_c_o = c_o_configs[best_c_o_idx]
E_initial_c_o = c_o_energies[best_c_o_idx]
E_initial_c_o_ml = c_o_energies_ml[best_c_o_idx]
E_initial_c_o_d3 = c_o_energies_d3[best_c_o_idx]

print(f"\n   → Best C*+O* (Config {best_c_o_idx+1}):")
print(f"      RPBE:  {E_initial_c_o_ml:.2f} eV")
print(f"      D3:    {E_initial_c_o_d3:.2f} eV")
print(f"      Total: {E_initial_c_o:.2f} eV")

# Save best C+O state
ase.io.write(str(output_dir / part_dirs["part6"] / "co_initial_best.traj"), initial_c_o)
print("   ✓ Best C*+O* structure saved")

# Visualize best C*+O* structure
print("\n   Visualizing best C*+O* structure...")
view(initial_c_o, viewer='x3d')

2. Initial State: C* + O* on Ni(111)
   Generating C+O configurations...
   Generated 2 configurations
     Config 1: E_total = -498.83 eV (RPBE: -461.70, D3: -37.14, C-O dist: 4.79 Å)
     Config 2: ⚠ REJECTED - C and O formed CO molecule (d = 1.21 Å)

   → Best C*+O* (Config 1):
      RPBE:  -461.70 eV
      D3:    -37.14 eV
      Total: -498.83 eV
   ✓ Best C*+O* structure saved

   Visualizing best C*+O* structure...
Loading...

Step 3b: Calculate C* and O* Energies Separately

Another strategy to calculate the initial energies for *C and *O at very low coverage (without interactions between the two reactants) is to do two separate relaxations.

# Clean slab
ni_bulk_obj = Bulk(bulk_atoms=ni_bulk_atoms)
clean_slab = Slab.from_bulk_get_specific_millers(
    bulk=ni_bulk_obj_c_o, specific_millers=(1, 1, 1)
)[0].atoms
clean_slab.set_pbc([True, True, True])
clean_slab.calc = base_calc
opt = LBFGS(clean_slab, logfile=None)
opt.run(fmax=0.05, steps=relaxation_steps)

E_clean_ml = clean_slab.get_potential_energy()
clean_slab.calc = d3_calc
E_clean_d3 = clean_slab.get_potential_energy()
E_clean = E_clean_ml + E_clean_d3

print(
    f"\n   Clean slab: E_total = {E_clean:.2f} eV (RPBE: {E_clean_ml:.2f}, D3: {E_clean_d3:.2f})"
)

   Clean slab: E_total = -487.48 eV (RPBE: -450.70, D3: -36.79)
print(f"\n2b. Separate C* and O* Energies:")
print("    Calculating energies in separate unit cells to avoid interactions")

ni_bulk_obj_c_o = Bulk(bulk_atoms=ni_bulk_atoms)
ni_slab_c_o = Slab.from_bulk_get_specific_millers(
    bulk=ni_bulk_obj_c_o, specific_millers=(1, 1, 1)
)[0]

print("\n   Generating C* configurations...")
multi_ads_config_c = MultipleAdsorbateSlabConfig(
    ni_slab_c_o,
    adsorbates=[Adsorbate(adsorbate_smiles_from_db="*C")],
    num_configurations=num_sites,
)

c_energies = []
c_energies_ml = []
c_energies_d3 = []
c_configs = []

for idx, config in enumerate(multi_ads_config_c.atoms_list):
    config_relaxed = config.copy()
    config_relaxed.set_pbc([True, True, True])
    config_relaxed.calc = base_calc
    opt = LBFGS(config_relaxed, logfile=None)
    opt.run(fmax=0.05, steps=relaxation_steps)

    E_ml = config_relaxed.get_potential_energy()
    config_relaxed.calc = d3_calc
    E_d3 = config_relaxed.get_potential_energy()
    E_total = E_ml + E_d3

    c_energies.append(E_total)
    c_energies_ml.append(E_ml)
    c_energies_d3.append(E_d3)
    c_configs.append(config_relaxed)
    print(
        f"     Config {idx+1}: E_total = {E_total:.2f} eV (RPBE: {E_ml:.2f}, D3: {E_d3:.2f})"
    )

best_c_idx = np.argmin(c_energies)
c_ads = c_configs[best_c_idx]
E_c = c_energies[best_c_idx]
E_c_ml = c_energies_ml[best_c_idx]
E_c_d3 = c_energies_d3[best_c_idx]

print(f"\n   → Best C* (Config {best_c_idx+1}):")
print(f"      RPBE:  {E_c_ml:.2f} eV")
print(f"      D3:    {E_c_d3:.2f} eV")
print(f"      Total: {E_c:.2f} eV")

# Save best C state
ase.io.write(str(output_dir / part_dirs["part6"] / "c_best.traj"), c_ads)

# Visualize best C* structure
print("\n   Visualizing best C* structure...")
view(c_ads, viewer='x3d')

# Generate O* configuration
print("\n   Generating O* configurations...")
multi_ads_config_o = MultipleAdsorbateSlabConfig(
    ni_slab_c_o,
    adsorbates=[Adsorbate(adsorbate_smiles_from_db="*O")],
    num_configurations=num_sites,
)
o_energies = []
o_energies_ml = []
o_energies_d3 = []
o_configs = []

for idx, config in enumerate(multi_ads_config_o.atoms_list):
    config_relaxed = config.copy()
    config_relaxed.set_pbc([True, True, True])
    config_relaxed.calc = base_calc
    opt = LBFGS(config_relaxed, logfile=None)
    opt.run(fmax=0.05, steps=relaxation_steps)

    E_ml = config_relaxed.get_potential_energy()
    config_relaxed.calc = d3_calc
    E_d3 = config_relaxed.get_potential_energy()
    E_total = E_ml + E_d3

    o_energies.append(E_total)
    o_energies_ml.append(E_ml)
    o_energies_d3.append(E_d3)
    o_configs.append(config_relaxed)
    print(
        f"     Config {idx+1}: E_total = {E_total:.2f} eV (RPBE: {E_ml:.2f}, D3: {E_d3:.2f})"
    )

best_o_idx = np.argmin(o_energies)
o_ads = o_configs[best_o_idx]
E_o = o_energies[best_o_idx]
E_o_ml = o_energies_ml[best_o_idx]
E_o_d3 = o_energies_d3[best_o_idx]

print(f"\n   → Best O* (Config {best_o_idx+1}):")
print(f"      RPBE:  {E_o_ml:.2f} eV")
print(f"      D3:    {E_o_d3:.2f} eV")
print(f"      Total: {E_o:.2f} eV")

# Save best O state
ase.io.write(str(output_dir / part_dirs["part6"] / "o_best.traj"), o_ads)

# Visualize best O* structure
print("\n   Visualizing best O* structure...")
view(o_ads, viewer='x3d')

# Calculate combined energy for separate C* and O*
E_initial_c_o_separate = E_c + E_o
E_initial_c_o_separate_ml = E_c_ml + E_o_ml
E_initial_c_o_separate_d3 = E_c_d3 + E_o_d3

2b. Separate C* and O* Energies:
    Calculating energies in separate unit cells to avoid interactions

   Generating C* configurations...
     Config 1: E_total = -495.74 eV (RPBE: -458.74, D3: -37.00)
     Config 2: E_total = -495.73 eV (RPBE: -458.73, D3: -37.00)

   → Best C* (Config 1):
      RPBE:  -458.74 eV
      D3:    -37.00 eV
      Total: -495.74 eV

   Visualizing best C* structure...

   Generating O* configurations...
     Config 1: E_total = -494.57 eV (RPBE: -457.68, D3: -36.90)
     Config 2: E_total = -494.68 eV (RPBE: -457.79, D3: -36.90)

   → Best O* (Config 2):
      RPBE:  -457.79 eV
      D3:    -36.90 eV
      Total: -494.68 eV

   Visualizing best O* structure...
print(f"\n   Combined C* + O* (separate calculations):")
print(f"      RPBE:  {E_initial_c_o_separate_ml:.2f} eV")
print(f"      D3:    {E_initial_c_o_separate_d3:.2f} eV")
print(f"      Total: {E_initial_c_o_separate:.2f} eV")

print(f"\n   Comparison:")
print(f"      C*+O* (same cell):  {E_initial_c_o - E_clean:.2f} eV")
print(f"      C* + O* (separate): {E_initial_c_o_separate - 2*E_clean:.2f} eV")
print(
    f"      Difference:         {(E_initial_c_o - E_clean) - (E_initial_c_o_separate - 2*E_clean):.2f} eV"
)
print("   ✓ Separate C* and O* energies calculated")

   Combined C* + O* (separate calculations):
      RPBE:  -916.52 eV
      D3:    -73.90 eV
      Total: -990.42 eV

   Comparison:
      C*+O* (same cell):  -11.35 eV
      C* + O* (separate): -15.45 eV
      Difference:         4.11 eV
   ✓ Separate C* and O* energies calculated

Step 4: Calculate Reaction Energy with ZPE

Compute the thermochemistry for C* + O* → CO* with ZPE corrections:

print(f"\n3. Reaction Energy (C* + O* → CO*):")
print(f"   " + "=" * 60)

# Electronic energies
print(f"\n   Electronic Energies:")
print(
    f"   Initial (C*+O*): RPBE = {E_initial_c_o_ml:.2f} eV, D3 = {E_initial_c_o_d3:.2f} eV, Total = {E_initial_c_o:.2f} eV"
)
print(
    f"   Final (CO*):     RPBE = {E_final_co_ml:.2f} eV, D3 = {E_final_co_d3:.2f} eV, Total = {E_final_co:.2f} eV"
)

# Reaction energies without ZPE
delta_E_rpbe = E_final_co_ml - E_initial_c_o_ml
delta_E_d3_contrib = E_final_co_d3 - E_initial_c_o_d3
delta_E_elec = E_final_co - E_initial_c_o

print(f"\n   Reaction Energies (without ZPE):")
print(f"   ΔE(RPBE only):     {delta_E_rpbe:.2f} eV = {delta_E_rpbe*96.485:.1f} kJ/mol")
print(
    f"   ΔE(D3 contrib):    {delta_E_d3_contrib:.2f} eV = {delta_E_d3_contrib*96.485:.1f} kJ/mol"
)
print(f"   ΔE(RPBE+D3):       {delta_E_elec:.2f} eV = {delta_E_elec*96.485:.1f} kJ/mol")

# Calculate ZPE for CO* (final state)
print(f"\n   Computing ZPE for CO*...")
final_co.calc = base_calc

co_indices = np.where(final_co.get_tags() == 2)[0]
vib_co = Vibrations(final_co, indices=co_indices, delta=0.02, name="vib_co")
vib_co.run()
vib_energies_co = vib_co.get_energies()
zpe_co = np.sum(vib_energies_co[vib_energies_co > 0]) / 2.0
vib_co.clean()
print(f"   ZPE(CO*): {zpe_co:.2f} eV ({zpe_co*1000:.1f} meV)")


# Calculate ZPE for C* and O* (initial state)
print(f"\n   Computing ZPE for C* and O*...")
initial_c_o.calc = base_calc
c_o_indices = np.where(initial_c_o.get_tags() == 2)[0]
vib_c_o = Vibrations(initial_c_o, indices=c_o_indices, delta=0.02, name="vib_c_o")
vib_c_o.run()
vib_energies_c_o = vib_c_o.get_energies()
zpe_c_o = np.sum(vib_energies_c_o[vib_energies_c_o > 0]) / 2.0
vib_c_o.clean()
print(f"   ZPE(C*+O*): {zpe_c_o:.2f} eV ({zpe_c_o*1000:.1f} meV)")


# Total reaction energy with ZPE
delta_zpe = zpe_co - zpe_c_o
delta_E_zpe = delta_E_elec + delta_zpe

print(f"\n   Reaction Energy (with ZPE):")
print(f"   ΔE(electronic):    {delta_E_elec:.2f} eV = {delta_E_elec*96.485:.1f} kJ/mol")
print(
    f"   ΔZPE:              {delta_zpe:.2f} eV = {delta_zpe*96.485:.1f} kJ/mol ({delta_zpe*1000:.1f} meV)"
)
print(f"   ΔE(total):         {delta_E_zpe:.2f} eV = {delta_E_zpe*96.485:.1f} kJ/mol")

print(f"\n   Summary:")
print(
    f"   Without D3, without ZPE: {delta_E_rpbe:.2f} eV = {delta_E_rpbe*96.485:.1f} kJ/mol"
)
print(
    f"   With D3, without ZPE:    {delta_E_elec:.2f} eV = {delta_E_elec*96.485:.1f} kJ/mol"
)
print(
    f"   With D3, with ZPE:       {delta_E_zpe:.2f} eV = {delta_E_zpe*96.485:.1f} kJ/mol"
)

print(f"\n   " + "=" * 60)
print(f"\n   Comparison with Paper (Table 5):")
print(f"   Paper (DFT-D3): -142.7 kJ/mol = -1.48 eV")
print(f"   This work:      {delta_E_zpe*96.485:.1f} kJ/mol = {delta_E_zpe:.2f} eV")
print(f"   Difference:     {abs(delta_E_zpe - (-1.48)):.2f} eV")

if delta_E_zpe < 0:
    print(f"\n   ✓ Reaction is exothermic (C+O recombination favorable)")
else:
    print(f"\n   ⚠ Reaction is endothermic (dissociation favorable)")

3. Reaction Energy (C* + O* → CO*):
   ============================================================

   Electronic Energies:
   Initial (C*+O*): RPBE = -461.70 eV, D3 = -37.14 eV, Total = -498.83 eV
   Final (CO*):     RPBE = -466.92 eV, D3 = -37.04 eV, Total = -503.97 eV

   Reaction Energies (without ZPE):
   ΔE(RPBE only):     -5.23 eV = -504.4 kJ/mol
   ΔE(D3 contrib):    0.09 eV = 9.1 kJ/mol
   ΔE(RPBE+D3):       -5.13 eV = -495.3 kJ/mol

   Computing ZPE for CO*...
   ZPE(CO*): 0.18+0.00j eV (180.7+0.0j meV)

   Computing ZPE for C* and O*...
   ZPE(C*+O*): 0.11+0.06j eV (106.5+60.6j meV)

   Reaction Energy (with ZPE):
   ΔE(electronic):    -5.13 eV = -495.3 kJ/mol
   ΔZPE:              0.07-0.06j eV = 7.2-5.8j kJ/mol (74.2-60.6j meV)
   ΔE(total):         -5.06-0.06j eV = -488.2-5.8j kJ/mol

   Summary:
   Without D3, without ZPE: -5.23 eV = -504.4 kJ/mol
   With D3, without ZPE:    -5.13 eV = -495.3 kJ/mol
   With D3, with ZPE:       -5.06-0.06j eV = -488.2-5.8j kJ/mol

   ============================================================

   Comparison with Paper (Table 5):
   Paper (DFT-D3): -142.7 kJ/mol = -1.48 eV
   This work:      -488.2-5.8j kJ/mol = -5.06-0.06j eV
   Difference:     3.58 eV

   ✓ Reaction is exothermic (C+O recombination favorable)

Step 5: Calculate CO Adsorption Energy (Bonus)

Calculate how strongly CO binds to the surface:

print(f"\n4. CO Adsorption Energy ( CO(g) + * → CO*):")
print("   This helps us understand CO binding strength")

# CO(g)
co_gas = Atoms("CO", positions=[[0, 0, 0], [0, 0, 1.15]])
co_gas.center(vacuum=10.0)
co_gas.set_pbc([True, True, True])
co_gas.calc = base_calc
opt = LBFGS(co_gas, logfile=None)
opt.run(fmax=0.05, steps=relaxation_steps)

E_co_gas_ml = co_gas.get_potential_energy()
co_gas.calc = d3_calc
E_co_gas_d3 = co_gas.get_potential_energy()
E_co_gas = E_co_gas_ml + E_co_gas_d3

print(
    f"   CO(g):       E_total = {E_co_gas:.2f} eV (RPBE: {E_co_gas_ml:.2f}, D3: {E_co_gas_d3:.2f})"
)

# Calculate ZPE for CO(g)
co_gas.calc = base_calc
vib_co_gas = Vibrations(co_gas, indices=[0, 1], delta=0.01, nfree=2)
vib_co_gas.clean()
vib_co_gas.run()
vib_energies_co_gas = vib_co_gas.get_energies()
zpe_co_gas = 0.5 * np.sum(vib_energies_co_gas[vib_energies_co_gas > 0])
vib_co_gas.clean()

print(f"   ZPE(CO(g)):  {zpe_co_gas:.2f} eV")
print(f"   ZPE(CO*):    {zpe_co:.2f} eV (from Step 4 calculation)")

# Electronic adsorption energy
E_ads_co_elec = E_final_co - E_clean - E_co_gas

# ZPE contribution to adsorption energy
delta_zpe_ads = zpe_co - zpe_co_gas

# Total adsorption energy with ZPE
E_ads_co_total = E_ads_co_elec + delta_zpe_ads

print(f"\n   Electronic Energy Breakdown:")
print(f"   ΔE(RPBE only) = {(E_final_co_ml - E_clean_ml - E_co_gas_ml):.2f} eV")
print(f"   ΔE(D3 contrib) = {((E_final_co_d3 - E_clean_d3 - E_co_gas_d3)):.2f} eV")
print(f"   ΔE(RPBE+D3) = {E_ads_co_elec:.2f} eV")
print(f"\n   ZPE Contribution:")
print(f"   ΔZPE = {delta_zpe_ads:.2f} eV")
print(f"\n   Total Adsorption Energy:")
print(f"   ΔE(total) = {E_ads_co_total:.2f} eV = {E_ads_co_total*96.485:.1f} kJ/mol")
print(f"\n   Summary:")
print(
    f"   E_ads(CO) without ZPE = {-E_ads_co_elec:.2f} eV = {-E_ads_co_elec*96.485:.1f} kJ/mol"
)
print(
    f"   E_ads(CO) with ZPE    = {-E_ads_co_total:.2f} eV = {-E_ads_co_total*96.485:.1f} kJ/mol"
)
print(
    f"   → CO binds {abs(E_ads_co_total):.2f} eV stronger than H ({abs(E_ads_co_total)/0.60:.1f}x)"
)

4. CO Adsorption Energy ( CO(g) + * → CO*):
   This helps us understand CO binding strength
   CO(g):       E_total = -14.51 eV (RPBE: -14.50, D3: -0.01)
   ZPE(CO(g)):  0.13+0.00j eV
   ZPE(CO*):    0.18+0.00j eV (from Step 4 calculation)

   Electronic Energy Breakdown:
   ΔE(RPBE only) = -1.73 eV
   ΔE(D3 contrib) = -0.24 eV
   ΔE(RPBE+D3) = -1.97 eV

   ZPE Contribution:
   ΔZPE = 0.05-0.00j eV

   Total Adsorption Energy:
   ΔE(total) = -1.93-0.00j eV = -185.7-0.0j kJ/mol

   Summary:
   E_ads(CO) without ZPE = 1.97 eV = 190.5 kJ/mol
   E_ads(CO) with ZPE    = 1.93+0.00j eV = 185.7+0.0j kJ/mol
   → CO binds 1.93 eV stronger than H (3.2x)

Step 6: Find guesses for nearby initial and final states for the reaction

Now that we have an estimate on the reaction energy from the best possible initial and final states, we want to find a transition state (barrier) for this reaction. There are MANY possible ways that we could do this. In this case, we’ll start with the *CO final state and then try and find a nearby local minimal of *C and *O, by fixing the C-O bond distance and finding a nearby local minima. Note that this approach required some insight into what the transition state might look like, and could be considerably more complicated for a reaction that did not involve breaking a single bond.

print(f"\nFinding Transition State Initial and Final States")
print("   Creating initial guess with stretched C-O bond...")
print("   Starting from CO* and stretching the C-O bond...")

# Create a guess structure with stretched CO bond (start from CO*)
initial_guess = final_co.copy()

# Set up a constraint to fix the bond length to ~2 Angstroms, which should be far enough that we'll be closer to *C+*O than *CO
co_indices = np.where(initial_guess.get_tags() == 2)[0]

# Rotate the atoms a bit just to break the symmetry and prevent the O from going straight up to satisfy the constraint
initial_slab = initial_guess[initial_guess.get_tags() != 2]
initial_co = initial_guess[initial_guess.get_tags() == 2]
initial_co.rotate(30, "x", center=initial_co.positions[0])
initial_guess = initial_slab + initial_co

initial_guess.calc = FAIRChemCalculator(predictor, task_name="oc20")

# Add constraints to keep the CO bond length extended
initial_guess.constraints += [
    FixBondLengths([co_indices], tolerance=1e-2, iterations=5000, bondlengths=[2.0])
]


try:
    opt = LBFGS(
        initial_guess,
        trajectory=output_dir / part_dirs["part6"] / "initial_guess_with_constraint.traj",
    )
    opt.run(fmax=0.01)
except RuntimeError:
    # The FixBondLength constraint is sometimes a little finicky,
    # but it's ok if it doesn't finish as it's just an initial guess
    # for the next step
    pass

# Now that we have a guess, re-relax without the constraints
initial_guess.constraints = initial_guess.constraints[:-1]
opt = LBFGS(
    initial_guess,
    trajectory=output_dir
    / part_dirs["part6"]
    / "initial_guess_without_constraint.traj",
)
opt.run(fmax=0.01)

Finding Transition State Initial and Final States
   Creating initial guess with stretched C-O bond...
   Starting from CO* and stretching the C-O bond...
       Step     Time          Energy          fmax
LBFGS:    0 00:25:08     -466.506200        1.228727
LBFGS:    1 00:25:09     -461.146553        7.775391
LBFGS:    2 00:25:09     -461.238161        7.050651
LBFGS:    3 00:25:09     -461.571647        4.106577
LBFGS:    4 00:25:09     -461.735733        2.176934
LBFGS:    5 00:25:10     -461.793940        0.670104
LBFGS:    6 00:25:10     -461.823024        0.336775
LBFGS:    7 00:25:10     -461.776899        0.320981
LBFGS:    8 00:25:10     -461.814070        0.853831
LBFGS:    9 00:25:10     -461.833254        0.444866
LBFGS:   10 00:25:11     -461.830640        0.277280
LBFGS:   11 00:25:11     -461.854002        0.330687
LBFGS:   12 00:25:11     -461.870645        0.539081
LBFGS:   13 00:25:11     -461.972333        0.545343
LBFGS:   14 00:25:12     -461.905431        0.849362
LBFGS:   15 00:25:12     -461.908094        0.538107
LBFGS:   16 00:25:12     -461.936870        0.646416
LBFGS:   17 00:25:12     -461.994964        1.125856
LBFGS:   18 00:25:13     -462.012793        1.843190
LBFGS:   19 00:25:13     -462.064091        2.444404
LBFGS:   20 00:25:13     -462.036434        1.796514
LBFGS:   21 00:25:13     -461.969338        1.050018
LBFGS:   22 00:25:14     -461.792601        2.122378
LBFGS:   23 00:25:14     -462.058654        1.421619
LBFGS:   24 00:25:14     -462.174257        1.974788
LBFGS:   25 00:25:15     -462.062351        1.423399
LBFGS:   26 00:25:15     -461.785242        2.217493
LBFGS:   27 00:25:15     -462.041766        1.382310
LBFGS:   28 00:25:15     -462.025725        1.323928
LBFGS:   29 00:25:16     -462.135585        1.864123
LBFGS:   30 00:25:16     -462.039484        1.318204
LBFGS:   31 00:25:16     -461.972206        0.977136
LBFGS:   32 00:25:16     -461.921605        0.750477
LBFGS:   33 00:25:17     -461.990362        1.036516
LBFGS:   34 00:25:17     -462.030215        1.155331
LBFGS:   35 00:25:17     -461.960333        0.865965
LBFGS:   36 00:25:17     -461.892825        0.777719
LBFGS:   37 00:25:18     -461.846135        0.963110
LBFGS:   38 00:25:18     -461.788639        1.268683
LBFGS:   39 00:25:18     -461.771673        1.400721
LBFGS:   40 00:25:18     -461.781685        1.381685
LBFGS:   41 00:25:19     -461.763886        1.425470
LBFGS:   42 00:25:19     -461.740157        1.495938
LBFGS:   43 00:25:19     -461.715930        1.566945
LBFGS:   44 00:25:19     -461.702007        1.619760
LBFGS:   45 00:25:20     -461.714610        1.585546
LBFGS:   46 00:25:20     -461.712939        1.560131
LBFGS:   47 00:25:20     -461.694856        1.455666
LBFGS:   48 00:25:20     -461.674819        1.353378
LBFGS:   49 00:25:21     -461.664778        1.255883
LBFGS:   50 00:25:21     -461.667918        1.196794
LBFGS:   51 00:25:21     -461.665299        1.201244
LBFGS:   52 00:25:21     -461.681699        1.166878
LBFGS:   53 00:25:22     -461.686978        1.197083
LBFGS:   54 00:25:22     -461.672073        1.207094
LBFGS:   55 00:25:22     -461.672489        1.178968
LBFGS:   56 00:25:22     -461.672948        1.145178
LBFGS:   57 00:25:23     -461.673142        1.119990
LBFGS:   58 00:25:23     -461.673148        1.106922
LBFGS:   59 00:25:23     -461.673158        1.101873
LBFGS:   60 00:25:24     -461.673164        1.100428
LBFGS:   61 00:25:24     -461.673158        1.101159
LBFGS:   62 00:25:24     -461.673170        1.097419
LBFGS:   63 00:25:25     -461.673183        1.094918
LBFGS:   64 00:25:25     -461.673180        1.094103
LBFGS:   65 00:25:25     -461.673187        1.094882
LBFGS:   66 00:25:25     -461.673166        1.093691
LBFGS:   67 00:25:26     -461.673179        1.093576
LBFGS:   68 00:25:26     -461.671443        1.104368
LBFGS:   69 00:25:26     -461.698323        0.988216
LBFGS:   70 00:25:27     -461.722596        0.860560
LBFGS:   71 00:25:27     -461.738309        0.764145
LBFGS:   72 00:25:28     -461.749204        0.696459
LBFGS:   73 00:25:28     -461.756951        0.650276
LBFGS:   74 00:25:28     -461.762295        0.619350
LBFGS:   75 00:25:29     -461.765602        0.597833
LBFGS:   76 00:25:29     -461.767286        0.580091
LBFGS:   77 00:25:29     -461.768093        0.561923
LBFGS:   78 00:25:30     -461.768717        0.540916
LBFGS:   79 00:25:30     -461.769530        0.514288
LBFGS:   80 00:25:31     -461.770580        0.483241
LBFGS:   81 00:25:31     -461.771777        0.451371
LBFGS:   82 00:25:31     -461.773055        0.421678
LBFGS:   83 00:25:32     -461.774372        0.395598
LBFGS:   84 00:25:32     -461.775686        0.373681
LBFGS:   85 00:25:33     -461.776959        0.356036
LBFGS:   86 00:25:33     -461.778125        0.342090
LBFGS:   87 00:25:33     -461.779161        0.330469
LBFGS:   88 00:25:34     -461.780144        0.320123
LBFGS:   89 00:25:34     -461.781119        0.311278
LBFGS:   90 00:25:35     -461.782042        0.305882
LBFGS:   91 00:25:35     -461.782693        0.305072
LBFGS:   92 00:25:35     -461.782929        0.306481
LBFGS:   93 00:25:36     -461.782916        0.306788
LBFGS:   94 00:25:36     -461.782937        0.306498
LBFGS:   95 00:25:37     -461.783033        0.306208
LBFGS:   96 00:25:37     -461.783209        0.307406
LBFGS:   97 00:25:37     -461.783456        0.309897
LBFGS:   98 00:25:38     -461.783590        0.310154
LBFGS:   99 00:25:38     -461.783037        0.302540
LBFGS:  100 00:25:39     -461.784701        0.323862
LBFGS:  101 00:25:39     -461.789130        0.408940
LBFGS:  102 00:25:40     -461.745787        0.221110
LBFGS:  103 00:25:40     -461.755906        0.405915
LBFGS:  104 00:25:41     -461.711264        0.580737
LBFGS:  105 00:25:41     -461.738743        0.632547
LBFGS:  106 00:25:42     -461.717794        0.719540
LBFGS:  107 00:25:42     -461.725963        0.634133
LBFGS:  108 00:25:42     -461.730592        0.603837
LBFGS:  109 00:25:43     -461.726689        0.617890
LBFGS:  110 00:25:43     -461.720256        0.647784
LBFGS:  111 00:25:44     -461.720688        0.644869
LBFGS:  112 00:25:44     -461.720687        0.644748
LBFGS:  113 00:25:44     -461.720680        0.645063
LBFGS:  114 00:25:45     -461.720568        0.646022
LBFGS:  115 00:25:45     -461.721368        0.639974
LBFGS:  116 00:25:46     -461.722382        0.630430
LBFGS:  117 00:25:46     -461.724788        0.604615
LBFGS:  118 00:25:47     -461.734414        0.537107
LBFGS:  119 00:25:47     -461.749449        0.425092
LBFGS:  120 00:25:48     -461.758641        0.260707
LBFGS:  121 00:25:48     -461.742553        0.165779
LBFGS:  122 00:25:48     -461.766819        0.343709
LBFGS:  123 00:25:49     -461.765584        0.125319
LBFGS:  124 00:25:49     -461.763673        0.107736
LBFGS:  125 00:25:50     -461.699636        0.236570
LBFGS:  126 00:25:50     -461.778140        0.520424
LBFGS:  127 00:25:51     -461.783494        0.909732
LBFGS:  128 00:25:51     -461.787166        1.357109
LBFGS:  129 00:25:51     -461.787323        1.803366
LBFGS:  130 00:25:52     -461.775655        2.198613
LBFGS:  131 00:25:52     -461.796682        2.517854
LBFGS:  132 00:25:53     -461.790379        2.521590
LBFGS:  133 00:25:53     -461.796171        2.432584
LBFGS:  134 00:25:54     -461.806356        1.971568
LBFGS:  135 00:25:54     -461.806580        1.675112
LBFGS:  136 00:25:54     -461.762853        2.078229
LBFGS:  137 00:25:55     -461.811952        2.434473
LBFGS:  138 00:25:55     -461.807900        2.609545
LBFGS:  139 00:25:56     -461.771244        2.675623
LBFGS:  140 00:25:56     -461.803506        2.639029
LBFGS:  141 00:25:57     -461.858652        2.616605
LBFGS:  142 00:25:57     -461.806724        2.602996
LBFGS:  143 00:25:58     -461.845883        2.590824
LBFGS:  144 00:25:58     -461.718490        2.583923
LBFGS:  145 00:25:59     -461.596560        2.505426
LBFGS:  146 00:25:59     -461.483253        2.478635
LBFGS:  147 00:26:00     -461.372264        2.479117
LBFGS:  148 00:26:00     -461.452376        2.437992
LBFGS:  149 00:26:00     -461.408563        2.507467
LBFGS:  150 00:26:01     -461.522440        2.531841
LBFGS:  151 00:26:01     -461.489272        2.610842
LBFGS:  152 00:26:02     -461.506040        2.616698
LBFGS:  153 00:26:02     -461.372508        2.543772
LBFGS:  154 00:26:03     -461.347954        2.451957
LBFGS:  155 00:26:03     -461.249226        2.345840
LBFGS:  156 00:26:04     -461.144439        2.562508
LBFGS:  157 00:26:04     -461.272125        2.581003
LBFGS:  158 00:26:05     -461.042852        2.463737
LBFGS:  159 00:26:05     -460.960353        2.328467
LBFGS:  160 00:26:06     -460.674978        2.040399
LBFGS:  161 00:26:06     -460.795498        2.078880
LBFGS:  162 00:26:07     -460.603744        1.997414
LBFGS:  163 00:26:07     -460.338922        1.924947
LBFGS:  164 00:26:07     -460.358023        1.903768
LBFGS:  165 00:26:08     -460.352768        1.898208
LBFGS:  166 00:26:08     -460.363143        1.895360
LBFGS:  167 00:26:09     -460.451356        1.887031
LBFGS:  168 00:26:09     -460.378071        1.833001
LBFGS:  169 00:26:09     -460.393220        1.929620
LBFGS:  170 00:26:10     -460.346143        1.838922
LBFGS:  171 00:26:10     -460.226578        1.865444
LBFGS:  172 00:26:10     -460.161632        1.914250
LBFGS:  173 00:26:11     -460.048124        1.993424
LBFGS:  174 00:26:11     -459.967196        2.089321
LBFGS:  175 00:26:11     -459.869065        2.189206
LBFGS:  176 00:26:12     -459.762480        2.283396
LBFGS:  177 00:26:12     -459.670716        2.380888
LBFGS:  178 00:26:13     -459.556443        2.486655
LBFGS:  179 00:26:13     -459.523209        2.555200
LBFGS:  180 00:26:13     -459.512971        2.597108
LBFGS:  181 00:26:14     -459.520139        2.607485
LBFGS:  182 00:26:14     -459.465051        2.559256
LBFGS:  183 00:26:14     -459.297197        2.510237
LBFGS:  184 00:26:15     -459.157788        2.488035
LBFGS:  185 00:26:15     -459.195393        2.501725
LBFGS:  186 00:26:15     -459.047646        2.558388
LBFGS:  187 00:26:16     -459.087135        2.550225
LBFGS:  188 00:26:16     -458.920451        2.637426
LBFGS:  189 00:26:16     -458.940322        2.399759
LBFGS:  190 00:26:17     -458.858640        2.233380
LBFGS:  191 00:26:17     -458.953182        2.333921
LBFGS:  192 00:26:18     -459.069650        2.503246
LBFGS:  193 00:26:18     -459.155434        2.626937
LBFGS:  194 00:26:19     -459.237598        2.772546
LBFGS:  195 00:26:19     -459.338605        2.994102
LBFGS:  196 00:26:19     -459.423297        3.319700
LBFGS:  197 00:26:20     -459.292455        3.286046
LBFGS:  198 00:26:20     -459.390802        3.355902
LBFGS:  199 00:26:20     -459.487351        3.429026
LBFGS:  200 00:26:21     -459.595143        3.507714
LBFGS:  201 00:26:21     -459.470379        3.328524
LBFGS:  202 00:26:22     -459.265998        3.127420
LBFGS:  203 00:26:22     -459.544392        3.293864
LBFGS:  204 00:26:22     -459.725885        3.508719
LBFGS:  205 00:26:23     -459.456026        3.319814
LBFGS:  206 00:26:23     -459.250235        3.147954
LBFGS:  207 00:26:24     -459.459892        3.319867
LBFGS:  208 00:26:24     -459.681883        3.523709
LBFGS:  209 00:26:24     -459.470322        3.309496
LBFGS:  210 00:26:25     -459.267092        3.110607
LBFGS:  211 00:26:25     -459.485700        3.301641
LBFGS:  212 00:26:26     -459.719208        3.508862
LBFGS:  213 00:26:26     -459.490896        3.294847
LBFGS:  214 00:26:26     -459.260965        3.089741
LBFGS:  215 00:26:27     -459.511964        3.285897
LBFGS:  216 00:26:27     -459.776420        3.488970
LBFGS:  217 00:26:27     -459.515750        3.278965
LBFGS:  218 00:26:28     -459.248660        3.068148
LBFGS:  219 00:26:28     -459.528561        3.269920
LBFGS:  220 00:26:29     -459.806534        3.466029
LBFGS:  221 00:26:29     -459.537879        3.262358
LBFGS:  222 00:26:30     -459.457561        2.978914
LBFGS:  223 00:26:30     -459.373894        2.735191
LBFGS:  224 00:26:31     -459.621278        2.887080
LBFGS:  225 00:26:32     -459.841012        3.109491
LBFGS:  226 00:26:32     -460.035789        3.246384
LBFGS:  227 00:26:33     -460.170521        3.173028
LBFGS:  228 00:26:33     -460.070117        2.888257
LBFGS:  229 00:26:34     -460.221562        3.091561
LBFGS:  230 00:26:34     -460.290106        3.041533
LBFGS:  231 00:26:35     -460.481573        3.054080
LBFGS:  232 00:26:36     -460.606897        2.835501
LBFGS:  233 00:26:36     -460.798856        2.637763
LBFGS:  234 00:26:37     -460.899802        2.159375
LBFGS:  235 00:26:37     -460.946049        2.281223
LBFGS:  236 00:26:38     -461.055528        1.913551
LBFGS:  237 00:26:39     -461.165363        1.708413
LBFGS:  238 00:26:39     -461.220890        1.481927
LBFGS:  239 00:26:40     -461.232868        1.344161
LBFGS:  240 00:26:40     -461.250047        1.336900
LBFGS:  241 00:26:41     -461.247737        1.370243
LBFGS:  242 00:26:41     -461.327577        1.334355
LBFGS:  243 00:26:42     -461.342053        1.215992
LBFGS:  244 00:26:43     -461.410505        1.222866
LBFGS:  245 00:26:43     -461.425051        1.243988
LBFGS:  246 00:26:44     -461.532038        1.142948
LBFGS:  247 00:26:44     -461.596098        1.025168
LBFGS:  248 00:26:45     -461.667448        1.055691
LBFGS:  249 00:26:45     -461.787620        1.459391
LBFGS:  250 00:26:46     -461.887347        1.739425
LBFGS:  251 00:26:47     -462.024631        1.934835
LBFGS:  252 00:26:47     -462.113627        2.101491
LBFGS:  253 00:26:48     -462.229157        2.218408
LBFGS:  254 00:26:48     -462.392009        2.109060
LBFGS:  255 00:26:49     -462.507269        2.063004
LBFGS:  256 00:26:50     -462.633841        2.051999
LBFGS:  257 00:26:50     -462.757848        1.944290
LBFGS:  258 00:26:51     -462.880081        1.809365
LBFGS:  259 00:26:51     -463.080734        1.404472
LBFGS:  260 00:26:52     -463.281119        1.478016
LBFGS:  261 00:26:52     -463.408730        2.640521
LBFGS:  262 00:26:53     -463.721368        1.213109
LBFGS:  263 00:26:54     -463.868009        0.895521
LBFGS:  264 00:26:54     -463.951993        0.488352
LBFGS:  265 00:26:55     -463.983384        0.490371
LBFGS:  266 00:26:55     -464.029941        0.627148
LBFGS:  267 00:26:56     -464.053533        0.604517
LBFGS:  268 00:26:57     -464.083487        0.622115
LBFGS:  269 00:26:57     -464.079561        0.617160
LBFGS:  270 00:26:58     -464.097192        0.463631
LBFGS:  271 00:26:58     -464.111568        0.401138
LBFGS:  272 00:26:59     -464.112072        0.350244
LBFGS:  273 00:26:59     -464.122631        0.334292
LBFGS:  274 00:27:00     -464.104813        0.427194
LBFGS:  275 00:27:00     -464.132886        0.227659
LBFGS:  276 00:27:01     -464.131441        0.236523
LBFGS:  277 00:27:01     -464.118777        0.246433
LBFGS:  278 00:27:02     -464.126969        0.227681
LBFGS:  279 00:27:03     -464.130002        0.185619
LBFGS:  280 00:27:03     -464.132984        0.158787
LBFGS:  281 00:27:04     -464.135078        0.149073
LBFGS:  282 00:27:04     -464.140657        0.135064
LBFGS:  283 00:27:05     -464.145337        0.117916
LBFGS:  284 00:27:05     -464.147417        0.091310
LBFGS:  285 00:27:06     -464.146182        0.075529
LBFGS:  286 00:27:07     -464.144794        0.064121
LBFGS:  287 00:27:07     -464.144358        0.058500
LBFGS:  288 00:27:08     -464.144282        0.047109
LBFGS:  289 00:27:08     -464.145535        0.041373
LBFGS:  290 00:27:09     -464.147022        0.036697
LBFGS:  291 00:27:09     -464.147916        0.036024
LBFGS:  292 00:27:10     -464.144030        0.034137
LBFGS:  293 00:27:10     -464.146671        0.022582
LBFGS:  294 00:27:11     -464.149209        0.018870
LBFGS:  295 00:27:11     -464.148845        0.011223
LBFGS:  296 00:27:11     -464.145060        0.011474
LBFGS:  297 00:27:12     -464.135633        0.036275
LBFGS:  298 00:27:12     -464.140847        0.011209
LBFGS:  299 00:27:13     -464.164947        0.083485
LBFGS:  300 00:27:13     -464.153091        0.032281
LBFGS:  301 00:27:13     -464.147756        0.008282
       Step     Time          Energy          fmax
LBFGS:    0 00:27:13     -464.147756        1.651646
LBFGS:    1 00:27:14     -464.224975        1.676572
LBFGS:    2 00:27:14     -464.685951        1.918680
LBFGS:    3 00:27:15     -464.792758        2.222109
LBFGS:    4 00:27:15     -464.937520        1.884773
LBFGS:    5 00:27:15     -465.028779        1.691115
LBFGS:    6 00:27:16     -465.107264        1.370862
LBFGS:    7 00:27:16     -465.175512        0.973794
LBFGS:    8 00:27:16     -465.234449        0.880814
LBFGS:    9 00:27:17     -465.278170        0.686832
LBFGS:   10 00:27:17     -465.309463        0.527383
LBFGS:   11 00:27:18     -465.324464        0.389899
LBFGS:   12 00:27:18     -465.331881        0.181769
LBFGS:   13 00:27:18     -465.334335        0.178647
LBFGS:   14 00:27:19     -465.336604        0.160639
LBFGS:   15 00:27:19     -465.338546        0.140813
LBFGS:   16 00:27:20     -465.340134        0.115825
LBFGS:   17 00:27:20     -465.341224        0.110161
LBFGS:   18 00:27:21     -465.342165        0.108774
LBFGS:   19 00:27:21     -465.342991        0.094430
LBFGS:   20 00:27:21     -465.343640        0.081191
LBFGS:   21 00:27:22     -465.344219        0.074173
LBFGS:   22 00:27:22     -465.344774        0.076896
LBFGS:   23 00:27:22     -465.345230        0.068272
LBFGS:   24 00:27:23     -465.345551        0.041604
LBFGS:   25 00:27:23     -465.345766        0.037666
LBFGS:   26 00:27:24     -465.345942        0.049352
LBFGS:   27 00:27:24     -465.346098        0.043488
LBFGS:   28 00:27:24     -465.346225        0.026958
LBFGS:   29 00:27:25     -465.346327        0.029919
LBFGS:   30 00:27:25     -465.346425        0.033066
LBFGS:   31 00:27:26     -465.346520        0.034649
LBFGS:   32 00:27:26     -465.346599        0.025464
LBFGS:   33 00:27:26     -465.346666        0.020964
LBFGS:   34 00:27:27     -465.346723        0.021623
LBFGS:   35 00:27:27     -465.346771        0.021483
LBFGS:   36 00:27:28     -465.346816        0.018226
LBFGS:   37 00:27:28     -465.346857        0.021011
LBFGS:   38 00:27:28     -465.346901        0.019658
LBFGS:   39 00:27:29     -465.346940        0.016292
LBFGS:   40 00:27:29     -465.346966        0.012171
LBFGS:   41 00:27:30     -465.346979        0.008281
np.True_

Step 7: Run NEB to Find Activation Barrier

Use the nudged elastic band method to find the minimum energy path:

print(f"\n7. NEB Barrier Calculation (C* + O* → CO*)")
print("   Setting up 7-image NEB chain with TS guess in middle...")
print("   Reaction: C* + O* (initial) → TS → CO* (final)")

initial = initial_guess.copy()
initial.calc = FAIRChemCalculator(predictor, task_name="oc20")
images = [initial]  # Start with C* + O*

n_images = 10
for i in range(n_images):
    image = initial.copy()
    image.calc = FAIRChemCalculator(predictor, task_name="oc20")
    images.append(image)

final = final_co.copy()
final.calc = FAIRChemCalculator(predictor, task_name="oc20")
images.append(final)  # End with CO*

# Interpolate with better initial guess
dyneb = DyNEB(images, climb=True, fmax=0.05)

# Interpolate first half (C*+O* → TS)
print("\n   Interpolating images...")
dyneb.interpolate("idpp", mic=True)

# Optimize
print("   Optimizing NEB path (this may take a while)...")
opt = FIRE(
    dyneb,
    trajectory=str(output_dir / part_dirs["part6"] / "neb.traj"),
    logfile=str(output_dir / part_dirs["part6"] / "neb.log"),
)
opt.run(fmax=0.1, steps=relaxation_steps)

# Extract barrier (from C*+O* to TS)
energies = [img.get_potential_energy() for img in images]
energies_rel = np.array(energies) - energies[0]
E_barrier = np.max(energies_rel)

print(f"\n   ✓ NEB converged!")
print(
    f"\n   Forward barrier (C*+O* → CO*): {E_barrier:.2f} eV = {E_barrier*96.485:.1f} kJ/mol"
)
print(
    f"   Reverse barrier (CO* → C*+O*): {E_barrier - energies_rel[-1]:.2f} eV = {(E_barrier- energies_rel[-1])*96.485:.1f} kJ/mol"
)
print(f"\n   Paper (Table 5): 153 kJ/mol = 1.59 eV ")
print(f"   Difference: {abs(E_barrier - 1.59):.2f} eV")

7. NEB Barrier Calculation (C* + O* → CO*)
   Setting up 7-image NEB chain with TS guess in middle...
   Reaction: C* + O* (initial) → TS → CO* (final)

   Interpolating images...
   Optimizing NEB path (this may take a while)...

   ✓ NEB converged!

   Forward barrier (C*+O* → CO*): 1.46 eV = 140.8 kJ/mol
   Reverse barrier (CO* → C*+O*): 3.04 eV = 293.0 kJ/mol

   Paper (Table 5): 153 kJ/mol = 1.59 eV 
   Difference: 0.13 eV

Step 8: Visualize NEB Path and Key Structures

Create plots showing the reaction pathway:

print("\n   Creating NEB visualization...")

# Plot NEB path
fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(
    range(len(energies_rel)),
    energies_rel,
    "o-",
    linewidth=2,
    markersize=10,
    color="steelblue",
    label="NEB Path",
)
ax.axhline(0, color="green", linestyle="--", alpha=0.5, label="Initial: C*+O*")
ax.axhline(delta_E_zpe, color="red", linestyle="--", alpha=0.5, label="Final: CO*")
ax.axhline(
    E_barrier,
    color="orange",
    linestyle=":",
    alpha=0.7,
    linewidth=2,
    label=f"Forward Barrier = {E_barrier:.2f} eV",
)

# Annotate transition state
ts_idx = np.argmax(energies_rel)
ax.annotate(
    f"TS\n{energies_rel[ts_idx]:.2f} eV",
    xy=(ts_idx, energies_rel[ts_idx]),
    xytext=(ts_idx, energies_rel[ts_idx] + 0.3),
    ha="center",
    fontsize=11,
    fontweight="bold",
    arrowprops=dict(arrowstyle="->", lw=1.5, color="red"),
)

ax.set_xlabel("Image Number", fontsize=13)
ax.set_ylabel("Relative Energy (eV)", fontsize=13)
ax.set_title(
    "CO Formation on Ni(111): C* + O* → CO* - NEB Path", fontsize=15, fontweight="bold"
)
ax.legend(fontsize=11, loc="upper left")
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig(
    str(output_dir / part_dirs["part6"] / "neb_path.png"), dpi=300, bbox_inches="tight"
)
plt.show()

# Create animation of NEB path
print("\n   Creating NEB path animation...")
from ase.io import write as ase_write

ase.io.write(
    str(output_dir / part_dirs["part6"] / "neb_path.gif"), images, format="gif"
)
print("   → Saved as neb_path.gif")

# Visualize key structures
print("\n   Visualizing initial state (C* + O*)...")
view(initial_c_o, viewer='x3d')

print("\n   Visualizing transition state...")
view(images[ts_idx], viewer='x3d')

print("\n   Visualizing final state (CO*)...")
view(final_co, viewer='x3d')

print("\n✓ NEB analysis complete!")

   Creating NEB visualization...
/home/runner/work/_tool/Python/3.12.13/x64/lib/python3.12/site-packages/matplotlib/cbook.py:1355: ComplexWarning: Casting complex values to real discards the imaginary part
  return np.asarray(x, float)
<Figure size 1000x600 with 1 Axes>

   Creating NEB path animation...
   → Saved as neb_path.gif

   Visualizing initial state (C* + O*)...

   Visualizing transition state...

   Visualizing final state (CO*)...

✓ NEB analysis complete!
<Figure size 640x480 with 1 Axes>

Explore on Your Own

  1. Image convergence: Run with 7 or 9 images. Does the barrier change?

  2. Spring constant: Modify the NEB spring constant. How does this affect convergence?

  3. Alternative paths: Try different initial CO/final C+O configurations. Are there multiple pathways?

  4. Reverse barrier: Calculate E_a(reverse) = E_a(forward) - ΔE. Check Brønsted-Evans-Polanyi relationship.

  5. Diffusion barriers: Compute NEB for C or O diffusion on the surface. How do they compare?


Summary and Best Practices

Key Takeaways

  1. ML Potentials: uma-s-1p2 provides ~1000× speedup over DFT with reasonable accuracy

  2. Bulk optimization: Always use the ML-optimized lattice constant for consistency

  3. Surface energies: Linear extrapolation eliminates finite-size effects

  4. Adsorption: Test multiple sites; lowest energy may not be intuitive

  5. Coverage: Lateral interactions become significant above ~0.3 ML

  6. Barriers: NEB requires careful setup but yields full reaction pathway

  1. Optimize Bulk - Determine equilibrium lattice constant

  2. Calculate Surface Energies - Identify stable facets

  3. Wulff Construction - Predict nanoparticle morphology

  4. Low-Coverage Adsorption - Find binding sites and energies

  5. Coverage Study (if coverage-dependent effects are important) - Determine lateral interactions

  6. Reaction Barriers - Calculate activation energies using NEB

  7. Microkinetic Modeling - Predict overall catalytic performance

Accuracy Considerations

PropertyTypical ErrorWhen Critical
Lattice constants1-2%Strain effects, alloys
Surface energies10-20%Nanoparticle shapes
Adsorption energies0.1-0.3 eVThermochemistry
Barriers0.2-0.5 eVKinetics, selectivity

Rule of thumb: Use ML for screening → DFT for validation → Experiment for verification

Further Reading


Appendix: Troubleshooting

Common Issues

Problem: Convergence failures

Problem: NEB fails to find transition state

Problem: Unexpected adsorption energies

Problem: Out of memory

Performance Tips

  1. Use batching: Relax multiple configurations in parallel

  2. Start with DEBUG_MAX_STEPS=50: Get quick results, refine later

  3. Cache bulk energies: Don’t recalculate reference systems

  4. Trajectory analysis: Monitor optimization progress with ASE GUI


Caveats and Pitfalls

1. Task Selection: OMAT vs OC20

Critical choice: Which task_name to use?

Impact: Using wrong task can lead to 0.1-0.3 eV errors in adsorption energies!

2. D3 Dispersion Corrections

Multiple decisions required:

  1. Whether to use D3 at all?

    • Small adsorbates (H, O, N): D3 effect ~0.01-0.05 eV (often negligible)

    • Large molecules (CO, CO₂, aromatics): D3 effect ~0.1-0.3 eV (important!)

    • Physisorption: D3 critical (can change binding from repulsive to attractive)

    • RPBE was originally fit for chemisorption energies without D3 corrections, so adding D3 corrections may actually cause small adsorbates to overbind. However, it probably would be important for larger molecules. It’s relatively uncommon to see RPBE+D3 as a choice in the catalysis literature (compared to PBE+D3, or RPBE, or BEEF-vdW).

  2. Which DFT functional for D3?

    • This tutorial uses method="PBE" consistently for the D3 correction. This is often implied when papers say they use a D3 correction, but the results can be different if use the RPBE parameterizations.

    • Original paper used PBE for bulk/surfaces, RPBE for adsorption. It’s not specified what D3 parameterization they used, but it’s likely PBE.

  3. When to apply D3?

    • End-point correction (used here): Fast, run ML optimization then add D3 energy

    • During optimization: Slower but more accurate geometries

    • Impact: Usually <0.05 eV difference, but can be larger for weak interactions

3. Coverage Dependence Challenges

Non-linearity at high coverage:

Low coverage limit:

4. Periodic Boundary Conditions

UMa requires PBC=True in all directions!

atoms.set_pbc([True, True, True])  # Always required

5. Gas Phase Reference Energies

Tricky cases:

Best practice: Always use stable molecules as references (H₂, not H; H₂O, not OH)

6. Spin Polarization

Key limitation: OC20/UMa does not include spin!

7. Constraint Philosophy

Clean slabs (Part 2): No constraints (both surfaces relax)

Adsorbate slabs (Part 4-6): Bottom layers fixed

Fairchem helper functions: Automatically apply sensible constraints

8. Complex Surface Structures

This tutorial uses low-index facets (111, 100, 110, 211)

Real catalysts have:

9. Slab Thickness and Vacuum

Convergence tests critical but expensive:

10. NEB Convergence

Most computationally expensive part:

Tricks:

  1. Use dimer method to find better TS guess (as shown in Part 6)

  2. Start with coarse convergence (fmax=0.2), refine later

  3. Visualize the path - does it make chemical sense?

  4. Try different spring constants (0.1-1.0 eV/Å)

11. Lattice Constant Source

Consistency is key:

12. Adsorbate Placement

Multiple local minima:

For complex adsorbates:


References
  1. Kreitz, B., Wehinger, G. D., Goldsmith, C. F., & Turek, T. (2021). Microkinetic Modeling of the CO2 Desorption from Supported Multifaceted Ni Catalysts. The Journal of Physical Chemistry C, 125(5), 2984–3000. 10.1021/acs.jpcc.0c09985
  2. Chanussot, L., Das, A., Goyal, S., Lavril, T., Shuaibi, M., Riviere, M., Tran, K., Heras-Domingo, J., Ho, C., Hu, W., Palizhati, A., Sriram, A., Wood, B., Yoon, J., Parikh, D., Zitnick, C. L., & Ulissi, Z. (2021). Open Catalyst 2020 (OC20) Dataset and Community Challenges. ACS Catalysis, 11(10), 6059–6072. 10.1021/acscatal.0c04525