Tutorial: Adsorbate Placement¶
This tutorial covers 6 different methods for placing adsorbate molecules on surfaces.
Learning Objectives¶
- Understand the 6 adsorbate placement methods
- Generate multiple adsorption configurations with proper rotation sampling
- Filter and select configurations for calculations
The 6 Placement Methods¶
| Method | Use Case |
|---|---|
add_simple() |
Manual placement at known position |
add_random() |
Exploratory sampling |
add_grid() |
Systematic coverage |
add_on_site() |
Specific atomic sites (most physically meaningful) |
add_with_collision() |
Safe random placement |
add_catkit() |
Automated site detection via CatKit |
Setup¶
from ase.io import read
from nh3sofc.structure import AdsorbatePlacer
# Load your surface (POSCAR or CIF format recommended)
surface = read("work/surfaces/LaVO3_001/surface.vasp", format="vasp")
# Create placer
placer = AdsorbatePlacer(surface)
Rotation Sampling¶
All placement methods (except add_simple()) apply uniform random rotations on SO(3)
for unbiased molecular orientations. This uses proper spherical sampling:
# Internally, rotations use: β = arccos(1 - 2u) for uniform coverage
# This avoids clustering near the "poles" that occurs with naive sampling
For reproducibility, always set random_seed:
configs_a = placer.add_random("NH3", n_configs=10, random_seed=42)
configs_b = placer.add_random("NH3", n_configs=10, random_seed=42)
# configs_a and configs_b are identical
Method 1: Simple (Manual)¶
Place at specific (x, y) coordinates with optional rotation.
# Place NH3 at position (2.5, 2.5) Å, 2.0 Å above surface
result = placer.add_simple(
adsorbate="NH3",
position=(2.5, 2.5),
height=2.0,
rotation=(0, 0, 0) # Euler angles (alpha, beta, gamma) in radians
)
Method 2: Random¶
Generate random configurations with uniform rotation sampling.
# Generate 10 random configurations
configs = placer.add_random(
adsorbate="NH3",
n_configs=10,
height=2.0,
random_seed=42 # Reproducibility
)
print(f"Generated {len(configs)} configurations")
Method 3: Grid¶
Systematic grid-based placement with multiple orientations per point.
# 3x3 grid with 4 orientations per point
configs = placer.add_grid(
adsorbate="NH3",
grid_size=(3, 3),
orientations=4, # 4 random orientations per grid point
height=2.0,
random_seed=42
)
# Returns 3 × 3 × 4 = 36 configurations
Method 4: On-Site (Atomic Sites)¶
Place on specific atomic sites. This is the most physically meaningful method.
Important: Only atoms in the top bilayer (within layer_tolerance of the
topmost atom) are considered as surface sites. Bulk atoms are automatically excluded.
# On top of La and V atoms in the surface bilayer
configs = placer.add_on_site(
adsorbate="NH3",
site_type="ontop",
atom_types=["La", "V"],
n_orientations=5, # 5 orientations per site
height=2.0,
layer_tolerance=2.0, # Top 2 Å = surface bilayer (default)
random_seed=42
)
# For only the topmost layer (e.g., VO2 termination only)
configs = placer.add_on_site(
adsorbate="NH3",
site_type="ontop",
atom_types=["V"],
n_orientations=3,
layer_tolerance=1.0, # Only topmost ~1 Å
random_seed=42
)
# Bridge sites (requires CatKit or uses geometric approximation)
configs = placer.add_on_site(
adsorbate="H",
site_type="bridge",
atom_types=["O"],
height=1.0
)
Surface Detection¶
The layer_tolerance parameter controls which atoms are considered "on the surface":
layer_tolerance |
Effect |
|---|---|
| 1.0 Å | Topmost atomic layer only |
| 2.0 Å (default) | Top bilayer (e.g., VO2 + LaO in perovskites) |
| 3.0 Å | Top 2-3 layers |
Method 5: Collision-Aware¶
Random placement with minimum distance checking.
# Ensure no atoms closer than 2.0 Å
configs = placer.add_with_collision(
adsorbate="NH3",
n_configs=10,
min_distance=2.0,
height=2.0,
random_seed=42
)
Method 6: CatKit Integration¶
Use CatKit for automated site detection with rotation support.
# Requires catkit installation: pip install catkit
configs = placer.add_catkit(
adsorbate="NH3",
site_type="ontop", # or "bridge", "hollow", "4fold"
n_orientations=3, # 3 orientations per site
random_seed=42
)
Supported Adsorbates¶
| Name | Formula | Default Height |
|---|---|---|
"NH3" |
NH₃ | 2.0 Å |
"NH2" |
NH₂ | 1.8 Å |
"NH" |
NH | 1.5 Å |
"N" |
N | 1.2 Å |
"H" |
H | 1.0 Å |
"H2" |
H₂ | 2.5 Å |
"H2O" |
H₂O | 2.0 Å |
"O" |
O | 1.2 Å |
"OH" |
OH | 1.5 Å |
Filtering Configurations¶
Remove duplicate configurations based on adsorbate-only RMSD:
from nh3sofc.structure.adsorbates import filter_unique_configs, save_configs
# Remove similar configurations (RMSD < 0.5 Å on adsorbate atoms only)
unique_configs = filter_unique_configs(
configs,
threshold=0.5, # RMSD threshold in Angstroms
n_slab_atoms=len(surface) # Number of atoms in the original slab
)
print(f"Filtered {len(configs)} → {len(unique_configs)} unique")
# Save to files
paths = save_configs(unique_configs, output_dir="./configs", format="vasp")
Why n_slab_atoms?¶
When adsorbates are added, they are appended to the end of the atom list:
Structure: [slab_0, slab_1, ..., slab_N-1, adsorbate_0, adsorbate_1, ...]
|<------ slab (N atoms) ----->|<---- adsorbate (M atoms) --->|
The n_slab_atoms parameter tells the filter where the adsorbate atoms start,
so it only compares those atoms (not the identical slab atoms).
Getting Site Information¶
Inspect available adsorption sites before placement:
info = placer.get_site_info(layer_tolerance=2.0)
print(f"Surface atoms: {info['n_surface_atoms']}")
print(f"By element: {info['element_counts']}")
# Output: {'La': 1, 'V': 1, 'O': 3}
Complete Example¶
from ase.io import read
from nh3sofc.structure import AdsorbatePlacer
from nh3sofc.structure.adsorbates import filter_unique_configs, save_configs
# Load surface
surface = read("work/surfaces/LaVO3_001/surface.vasp", format="vasp")
n_slab_atoms = len(surface) # Remember slab size for filtering
placer = AdsorbatePlacer(surface)
# Check available sites
info = placer.get_site_info()
print(f"Surface sites: {info['element_counts']}")
# Generate configurations on La and V sites
configs = placer.add_on_site(
"NH3",
atom_types=["La", "V"],
n_orientations=5,
random_seed=42
)
print(f"Generated: {len(configs)} configurations")
# Filter duplicates (adsorbate-only RMSD)
unique = filter_unique_configs(configs, threshold=0.5, n_slab_atoms=n_slab_atoms)
print(f"Unique: {len(unique)} configurations")
# Save for VASP calculations
paths = save_configs(unique, "work/adsorbates/NH3_on_LaVO3", format="vasp")
Next Steps¶
- VASP Calculations - Optimize the configurations
- Decomposition Pathway - Study NH3 decomposition