adsorb-screen 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- adsorb_screen-0.1.0/PKG-INFO +41 -0
- adsorb_screen-0.1.0/README.md +18 -0
- adsorb_screen-0.1.0/pyproject.toml +33 -0
- adsorb_screen-0.1.0/setup.cfg +4 -0
- adsorb_screen-0.1.0/src/adsorb_screen/__init__.py +3 -0
- adsorb_screen-0.1.0/src/adsorb_screen/cli.py +393 -0
- adsorb_screen-0.1.0/src/adsorb_screen/modes.py +135 -0
- adsorb_screen-0.1.0/src/adsorb_screen/molecules.py +161 -0
- adsorb_screen-0.1.0/src/adsorb_screen/relax.py +99 -0
- adsorb_screen-0.1.0/src/adsorb_screen/slab.py +160 -0
- adsorb_screen-0.1.0/src/adsorb_screen/vasp.py +85 -0
- adsorb_screen-0.1.0/src/adsorb_screen.egg-info/PKG-INFO +41 -0
- adsorb_screen-0.1.0/src/adsorb_screen.egg-info/SOURCES.txt +16 -0
- adsorb_screen-0.1.0/src/adsorb_screen.egg-info/dependency_links.txt +1 -0
- adsorb_screen-0.1.0/src/adsorb_screen.egg-info/entry_points.txt +2 -0
- adsorb_screen-0.1.0/src/adsorb_screen.egg-info/requires.txt +16 -0
- adsorb_screen-0.1.0/src/adsorb_screen.egg-info/top_level.txt +1 -0
- adsorb_screen-0.1.0/tests/test_core.py +42 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: adsorb-screen
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: General-purpose AdsorbML screening of molecules and radicals on catalytic surfaces with FAIRChem / UMA.
|
|
5
|
+
Author-email: Raghavendra Meena <raghavendra.ip2020@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: catalysis,adsorption,machine-learning-potentials,fairchem,DFT,VASP,ASE
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: numpy>=1.23
|
|
11
|
+
Requires-Dist: pandas>=1.5
|
|
12
|
+
Requires-Dist: ase>=3.22
|
|
13
|
+
Requires-Dist: fairchem-core>=2.0.0
|
|
14
|
+
Requires-Dist: fairchem-data-oc>=0.1.0
|
|
15
|
+
Provides-Extra: smiles
|
|
16
|
+
Requires-Dist: rdkit>=2023.3.1; extra == "smiles"
|
|
17
|
+
Provides-Extra: mp
|
|
18
|
+
Requires-Dist: mp-api>=0.39.0; extra == "mp"
|
|
19
|
+
Requires-Dist: pymatgen>=2023.10.0; extra == "mp"
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
22
|
+
Requires-Dist: ruff>=0.3.0; extra == "dev"
|
|
23
|
+
|
|
24
|
+
# adsorb-screen
|
|
25
|
+
|
|
26
|
+
Automated, general-purpose **AdsorbML** configuration screening of molecules, radicals, and atoms on **any catalytic surface** using FAIRChem v2 / UMA and VASP input generation.
|
|
27
|
+
|
|
28
|
+
## Key Features
|
|
29
|
+
- **Any Adsorbate**: Screen built-in radicals (`CH2I2`, `CH2I`, `CH2`, `CH2OO`), any single atom (`H`, `O`, `N`, `I`, `Pt`), any molecule in the ASE G2 database (`CO`, `CO2`, `NH3`, `CH3OH`, `H2O`, `C2H4`), SMILES strings (`smiles:CC(=O)O`), or custom structure files (`.xyz`, `POSCAR`, `.sdf`).
|
|
30
|
+
- **Any Catalytic Surface**: Provide your own slab (`--slab-file`), cleave any bulk crystal file (`--bulk my_oxide.cif --millers 1 1 0`), use built-in oxide presets (`alpha-quartz`, `rutile-TiO2`, `MgO`), use any ASE metal/crystal (`--bulk Pt --millers 1 1 1`), or fetch directly from the Materials Project (`--bulk mp-126 --millers 1 1 1`).
|
|
31
|
+
- **Symmetry-Aware Binding Modes**: Automatically identifies symmetry-unique monodentate and bidentate binding sites and orients lone pairs/radical lobes toward the surface (`down`), pi-systems parallel to the surface (`flat`), or bidentate bridges (`pair`).
|
|
32
|
+
- **Anomaly Filtering & VASP Export**: Rejects dissociated, desorbed, intercalated, or reconstructed trajectories and exports ready-to-run VASP directories (`POSCAR`, `INCAR`, `KPOINTS`, `POTCAR_ORDER`) with open-shell `MAGMOM`/`NUPDOWN` and metallic/insulator smearing.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install -e .
|
|
38
|
+
# Optional extras for SMILES and Materials Project support:
|
|
39
|
+
pip install -e ".[smiles,mp,dev]"
|
|
40
|
+
huggingface-cli login
|
|
41
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# adsorb-screen
|
|
2
|
+
|
|
3
|
+
Automated, general-purpose **AdsorbML** configuration screening of molecules, radicals, and atoms on **any catalytic surface** using FAIRChem v2 / UMA and VASP input generation.
|
|
4
|
+
|
|
5
|
+
## Key Features
|
|
6
|
+
- **Any Adsorbate**: Screen built-in radicals (`CH2I2`, `CH2I`, `CH2`, `CH2OO`), any single atom (`H`, `O`, `N`, `I`, `Pt`), any molecule in the ASE G2 database (`CO`, `CO2`, `NH3`, `CH3OH`, `H2O`, `C2H4`), SMILES strings (`smiles:CC(=O)O`), or custom structure files (`.xyz`, `POSCAR`, `.sdf`).
|
|
7
|
+
- **Any Catalytic Surface**: Provide your own slab (`--slab-file`), cleave any bulk crystal file (`--bulk my_oxide.cif --millers 1 1 0`), use built-in oxide presets (`alpha-quartz`, `rutile-TiO2`, `MgO`), use any ASE metal/crystal (`--bulk Pt --millers 1 1 1`), or fetch directly from the Materials Project (`--bulk mp-126 --millers 1 1 1`).
|
|
8
|
+
- **Symmetry-Aware Binding Modes**: Automatically identifies symmetry-unique monodentate and bidentate binding sites and orients lone pairs/radical lobes toward the surface (`down`), pi-systems parallel to the surface (`flat`), or bidentate bridges (`pair`).
|
|
9
|
+
- **Anomaly Filtering & VASP Export**: Rejects dissociated, desorbed, intercalated, or reconstructed trajectories and exports ready-to-run VASP directories (`POSCAR`, `INCAR`, `KPOINTS`, `POTCAR_ORDER`) with open-shell `MAGMOM`/`NUPDOWN` and metallic/insulator smearing.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install -e .
|
|
15
|
+
# Optional extras for SMILES and Materials Project support:
|
|
16
|
+
pip install -e ".[smiles,mp,dev]"
|
|
17
|
+
huggingface-cli login
|
|
18
|
+
```
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=65.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "adsorb-screen"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "General-purpose AdsorbML screening of molecules and radicals on catalytic surfaces with FAIRChem / UMA."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Raghavendra Meena", email = "raghavendra.ip2020@gmail.com" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["catalysis", "adsorption", "machine-learning-potentials", "fairchem", "DFT", "VASP", "ASE"]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"numpy>=1.23",
|
|
18
|
+
"pandas>=1.5",
|
|
19
|
+
"ase>=3.22",
|
|
20
|
+
"fairchem-core>=2.0.0",
|
|
21
|
+
"fairchem-data-oc>=0.1.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
smiles = ["rdkit>=2023.3.1"]
|
|
26
|
+
mp = ["mp-api>=0.39.0", "pymatgen>=2023.10.0"]
|
|
27
|
+
dev = ["pytest>=7.0", "ruff>=0.3.0"]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
adsorb-screen = "adsorb_screen.cli:main"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import random
|
|
7
|
+
import warnings
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
from ase.io import write
|
|
12
|
+
from fairchem.data.oc.core import Adsorbate, Slab
|
|
13
|
+
from fairchem.data.oc.core.multi_adsorbate_slab_config import MultipleAdsorbateSlabConfig
|
|
14
|
+
|
|
15
|
+
from .modes import binding_modes, describe_modes, orient_for_binding
|
|
16
|
+
from .molecules import BUILTIN_MOLECULES, build_dimer, load_molecule
|
|
17
|
+
from .relax import (
|
|
18
|
+
OPTIMIZERS,
|
|
19
|
+
find_anomalies,
|
|
20
|
+
get_calculator,
|
|
21
|
+
make_relax_job,
|
|
22
|
+
min_ads_slab_distance,
|
|
23
|
+
relax_gas_molecule,
|
|
24
|
+
)
|
|
25
|
+
from .slab import BUILTIN_BULKS, load_bulk_atoms, slab_from_bulk, slab_from_file
|
|
26
|
+
from .vasp import INCAR_GAS, INCAR_SLAB, write_vasp_dir
|
|
27
|
+
|
|
28
|
+
warnings.filterwarnings("ignore", category=UserWarning)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_args() -> argparse.Namespace:
|
|
32
|
+
p = argparse.ArgumentParser(
|
|
33
|
+
prog="adsorb-screen",
|
|
34
|
+
description="General-purpose AdsorbML screening of molecules, radicals, and atoms on any catalytic surface.",
|
|
35
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
g_mol = p.add_argument_group("Adsorbates (Molecules / Radicals / Atoms)")
|
|
39
|
+
g_mol.add_argument(
|
|
40
|
+
"--molecules",
|
|
41
|
+
nargs="+",
|
|
42
|
+
default=["CH2I2"],
|
|
43
|
+
help="Built-ins, element symbols (O, H, I), ASE G2 names (CO, NH3, CH3OH), 'smiles:...', or structure files.",
|
|
44
|
+
)
|
|
45
|
+
g_mol.add_argument(
|
|
46
|
+
"--unpaired",
|
|
47
|
+
type=int,
|
|
48
|
+
default=None,
|
|
49
|
+
help="Override number of unpaired electrons for open-shell molecules.",
|
|
50
|
+
)
|
|
51
|
+
g_mol.add_argument(
|
|
52
|
+
"--no-bidentate",
|
|
53
|
+
dest="bidentate",
|
|
54
|
+
action="store_false",
|
|
55
|
+
help="Disable automatic bidentate pair binding modes.",
|
|
56
|
+
)
|
|
57
|
+
g_mol.add_argument(
|
|
58
|
+
"--include-h-modes",
|
|
59
|
+
action="store_true",
|
|
60
|
+
help="Include H atoms when generating monodentate binding modes.",
|
|
61
|
+
)
|
|
62
|
+
g_mol.add_argument(
|
|
63
|
+
"--modes",
|
|
64
|
+
nargs="+",
|
|
65
|
+
default=None,
|
|
66
|
+
help="Filter specific binding mode names (e.g., I1 C0 I1-I2).",
|
|
67
|
+
)
|
|
68
|
+
g_mol.add_argument(
|
|
69
|
+
"--atom-reference",
|
|
70
|
+
default="half_dimer",
|
|
71
|
+
choices=["half_dimer", "atom"],
|
|
72
|
+
help="Gas-phase reference for single-atom adsorbates: 1/2 E(X2) or free atom.",
|
|
73
|
+
)
|
|
74
|
+
g_mol.add_argument(
|
|
75
|
+
"--list-modes",
|
|
76
|
+
action="store_true",
|
|
77
|
+
help="Inspect generated binding modes and exit without running ML relaxations.",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
g_slab = p.add_argument_group("Catalytic Surface / Slab")
|
|
81
|
+
g_slab.add_argument(
|
|
82
|
+
"--slab-file",
|
|
83
|
+
default=None,
|
|
84
|
+
help="Path to a pre-built surface slab (POSCAR, CIF, xyz).",
|
|
85
|
+
)
|
|
86
|
+
g_slab.add_argument(
|
|
87
|
+
"--bulk",
|
|
88
|
+
default="alpha-quartz",
|
|
89
|
+
help=f"Bulk crystal when --slab-file is omitted: preset ({', '.join(BUILTIN_BULKS)}), "
|
|
90
|
+
f"element/formula (Pt, Cu, Au, TiO2), MP-ID (mp-126), or bulk crystal file.",
|
|
91
|
+
)
|
|
92
|
+
g_slab.add_argument("--crystalstructure", default=None, help="ASE crystalstructure (fcc, bcc, hcp, rocksalt, etc.).")
|
|
93
|
+
g_slab.add_argument("--lattice-a", type=float, default=None, help="Custom lattice constant 'a' (A) for ASE bulk.")
|
|
94
|
+
g_slab.add_argument("--lattice-c", type=float, default=None, help="Custom lattice constant 'c' (A) for ASE bulk.")
|
|
95
|
+
g_slab.add_argument("--millers", type=int, nargs=3, default=[0, 0, 1], help="Miller indices (h k l) for bulk cleaving.")
|
|
96
|
+
g_slab.add_argument("--termination", type=int, default=None, help="Slab termination index (default: lowest broken bonds).")
|
|
97
|
+
g_slab.add_argument("--min-ab", type=float, default=8.0, help="Minimum lateral slab dimension (A) when cleaving bulk.")
|
|
98
|
+
g_slab.add_argument("--surface-depth", type=float, default=2.5, help="Top depth (A) tagged as surface for --slab-file.")
|
|
99
|
+
g_slab.add_argument("--fixed-depth", type=float, default=6.0, help="Bottom depth (A) frozen as bulk (0 = no fixed atoms).")
|
|
100
|
+
g_slab.add_argument("--metallic", action="store_true", help="Write metallic smearing (ISMEAR=1, SIGMA=0.2) in VASP INCARs.")
|
|
101
|
+
|
|
102
|
+
g_ads = p.add_argument_group("AdsorbML Sampling & Relaxation")
|
|
103
|
+
g_ads.add_argument("--num-placements", type=int, default=50, help="Number of placements per binding mode.")
|
|
104
|
+
g_ads.add_argument("--steps", type=int, default=300, help="Maximum optimizer steps per placement.")
|
|
105
|
+
g_ads.add_argument("--fmax", type=float, default=0.02, help="Force convergence criterion (eV/A).")
|
|
106
|
+
g_ads.add_argument("--optimizer", default="LBFGS", choices=list(OPTIMIZERS), help="ASE geometry optimizer.")
|
|
107
|
+
g_ads.add_argument("--desorb-dist", type=float, default=4.5, help="Max min(adsorbate-slab) distance (A) before flagging desorption.")
|
|
108
|
+
g_ads.add_argument(
|
|
109
|
+
"--placement-mode",
|
|
110
|
+
default="random_site_heuristic_placement",
|
|
111
|
+
choices=["random_site_heuristic_placement", "heuristic", "random"],
|
|
112
|
+
)
|
|
113
|
+
g_ads.add_argument(
|
|
114
|
+
"--place-on-unrelaxed-slab",
|
|
115
|
+
action="store_true",
|
|
116
|
+
help="Place adsorbates on the unrelaxed input slab instead of the ML-relaxed clean slab.",
|
|
117
|
+
)
|
|
118
|
+
g_ads.add_argument("--save-trajectories", action="store_true", help="Save .traj files for all relaxations.")
|
|
119
|
+
g_ads.add_argument("--seed", type=int, default=0, help="Random seed for reproducible placements.")
|
|
120
|
+
|
|
121
|
+
g_ml = p.add_argument_group("MLIP Model")
|
|
122
|
+
g_ml.add_argument("--model", default="uma-s-1p2p1", help="FAIRChem / UMA checkpoint name.")
|
|
123
|
+
g_ml.add_argument("--task", default="oc25", choices=["oc25", "oc20", "omat", "omol", "odac", "omc"])
|
|
124
|
+
g_ml.add_argument("--device", default=None, choices=["cpu", "cuda"])
|
|
125
|
+
|
|
126
|
+
g_out = p.add_argument_group("Output")
|
|
127
|
+
g_out.add_argument("--outdir", default="adsorbml_results", help="Root output directory.")
|
|
128
|
+
g_out.add_argument("--top-k", type=int, default=5, help="Number of top candidates to export to VASP/traj.")
|
|
129
|
+
g_out.add_argument("--no-vasp", action="store_true", help="Skip generating VASP input directories.")
|
|
130
|
+
|
|
131
|
+
return p.parse_args()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def run_molecule(
|
|
135
|
+
name: str,
|
|
136
|
+
mol,
|
|
137
|
+
unpaired: int,
|
|
138
|
+
args: argparse.Namespace,
|
|
139
|
+
outdir: Path,
|
|
140
|
+
calc,
|
|
141
|
+
relax,
|
|
142
|
+
place_slab: Slab,
|
|
143
|
+
relaxed_slab,
|
|
144
|
+
e_slab: float,
|
|
145
|
+
) -> dict | None:
|
|
146
|
+
mol_dir = outdir / name
|
|
147
|
+
mol_dir.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
print(f"\n################ {name} ({mol.get_chemical_formula()}) ################")
|
|
149
|
+
|
|
150
|
+
# 1. Relax in gas phase FIRST so binding modes use relaxed bond lengths/angles
|
|
151
|
+
gas_boxed, e_gas = relax_gas_molecule(mol, calc)
|
|
152
|
+
write(str(mol_dir / f"{name}_gas_relaxed.xyz"), gas_boxed)
|
|
153
|
+
print(f"E({name}, gas) = {e_gas:.4f} eV")
|
|
154
|
+
|
|
155
|
+
mol_relaxed = gas_boxed.copy()
|
|
156
|
+
mol_relaxed.set_cell(None)
|
|
157
|
+
mol_relaxed.pbc = False
|
|
158
|
+
|
|
159
|
+
# 2. Compute symmetry-unique binding modes on the relaxed gas geometry
|
|
160
|
+
modes = binding_modes(
|
|
161
|
+
mol_relaxed, bidentate=args.bidentate, include_h=args.include_h_modes
|
|
162
|
+
)
|
|
163
|
+
if args.modes:
|
|
164
|
+
modes = {k: v for k, v in modes.items() if k in args.modes}
|
|
165
|
+
describe_modes(name, mol_relaxed, modes)
|
|
166
|
+
if not modes:
|
|
167
|
+
print(f" No matching binding modes for {name}; skipping.")
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
# 3. Determine gas-phase reference energy
|
|
171
|
+
single_atom = len(mol_relaxed) == 1
|
|
172
|
+
e_ref, ref_label, dimer = e_gas, f"{name}(g)", None
|
|
173
|
+
if single_atom:
|
|
174
|
+
sym = mol_relaxed[0].symbol
|
|
175
|
+
dimer_init = build_dimer(sym)
|
|
176
|
+
dimer, e_dimer = relax_gas_molecule(dimer_init, calc)
|
|
177
|
+
write(str(mol_dir / f"{sym}2_gas_relaxed.xyz"), dimer)
|
|
178
|
+
print(
|
|
179
|
+
f"E({sym}2, gas) = {e_dimer:.4f} eV (bond {dimer.get_distance(0, 1):.3f} A); "
|
|
180
|
+
f"1/2 E({sym}2) - E({sym}) = {e_dimer / 2 - e_gas:.3f} eV"
|
|
181
|
+
)
|
|
182
|
+
if args.atom_reference == "half_dimer":
|
|
183
|
+
e_ref, ref_label = e_dimer / 2.0, f"1/2 {sym}2(g)"
|
|
184
|
+
elif unpaired > 0:
|
|
185
|
+
print(
|
|
186
|
+
f" NOTE: {name} is open-shell ({unpaired} unpaired e-); non-spin-polarized MLIPs "
|
|
187
|
+
f"carry extra reference uncertainty."
|
|
188
|
+
)
|
|
189
|
+
print(f"Reference for E_ads: {ref_label}")
|
|
190
|
+
|
|
191
|
+
def extra_col(r: dict) -> dict:
|
|
192
|
+
return {"E_ads_vs_free_atom_eV": r["E_ads_vs_gas"]} if single_atom and e_ref != e_gas else {}
|
|
193
|
+
|
|
194
|
+
candidates, rows = [], []
|
|
195
|
+
for mname, idx in modes.items():
|
|
196
|
+
oriented, how = orient_for_binding(mol_relaxed, idx)
|
|
197
|
+
print(f"\n=== {name} mode {mname} [{how}]: {args.num_placements} placements ===")
|
|
198
|
+
ads = Adsorbate(adsorbate_atoms=oriented, adsorbate_binding_indices=idx)
|
|
199
|
+
configs = MultipleAdsorbateSlabConfig(
|
|
200
|
+
place_slab,
|
|
201
|
+
[ads],
|
|
202
|
+
num_configurations=args.num_placements,
|
|
203
|
+
mode=args.placement_mode,
|
|
204
|
+
).atoms_list
|
|
205
|
+
|
|
206
|
+
n_valid, kinds = 0, {}
|
|
207
|
+
for k, init in enumerate(configs):
|
|
208
|
+
res = relax(init, f"{name} {mname} #{k + 1}")
|
|
209
|
+
anomalies = find_anomalies(init, res["atoms"], relaxed_slab, args.desorb_dist)
|
|
210
|
+
res.update(
|
|
211
|
+
mode=mname,
|
|
212
|
+
E_ads=res["energy"] - e_slab - e_ref,
|
|
213
|
+
E_ads_vs_gas=res["energy"] - e_slab - e_gas,
|
|
214
|
+
anomalies=anomalies,
|
|
215
|
+
min_dist=min_ads_slab_distance(res["atoms"]),
|
|
216
|
+
)
|
|
217
|
+
rows.append(
|
|
218
|
+
{
|
|
219
|
+
"mode": mname,
|
|
220
|
+
"E_ads_eV": res["E_ads"],
|
|
221
|
+
**extra_col(res),
|
|
222
|
+
"E_adslab_eV": res["energy"],
|
|
223
|
+
"converged": res["converged"],
|
|
224
|
+
"min_ads_slab_dist_A": res["min_dist"],
|
|
225
|
+
"anomalies": ",".join(anomalies) or "none",
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
for a in anomalies:
|
|
229
|
+
kinds[a] = kinds.get(a, 0) + 1
|
|
230
|
+
if not anomalies:
|
|
231
|
+
candidates.append(res)
|
|
232
|
+
n_valid += 1
|
|
233
|
+
print(f" {n_valid} valid, {len(configs) - n_valid} rejected {kinds if kinds else ''}")
|
|
234
|
+
|
|
235
|
+
pd.DataFrame(rows).sort_values("E_ads_eV").to_csv(mol_dir / "all_placements.csv", index=False)
|
|
236
|
+
if not candidates:
|
|
237
|
+
print(f" No valid configurations for {name}; see {mol_dir / 'all_placements.csv'}")
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
candidates.sort(key=lambda c: c["E_ads"])
|
|
241
|
+
df = pd.DataFrame(
|
|
242
|
+
[
|
|
243
|
+
{
|
|
244
|
+
"mode": c["mode"],
|
|
245
|
+
"E_ads_eV": c["E_ads"],
|
|
246
|
+
**extra_col(c),
|
|
247
|
+
"E_adslab_eV": c["energy"],
|
|
248
|
+
"converged": c["converged"],
|
|
249
|
+
"min_ads_slab_dist_A": c["min_dist"],
|
|
250
|
+
}
|
|
251
|
+
for c in candidates
|
|
252
|
+
]
|
|
253
|
+
)
|
|
254
|
+
df.index.name = "rank"
|
|
255
|
+
df.to_csv(mol_dir / "adsorption_energies.csv")
|
|
256
|
+
|
|
257
|
+
top = candidates[: args.top_k]
|
|
258
|
+
write(str(mol_dir / "top_candidates.traj"), [c["atoms"] for c in top])
|
|
259
|
+
write(str(mol_dir / "all_valid_candidates.traj"), [c["atoms"] for c in candidates])
|
|
260
|
+
for k, c in enumerate(top):
|
|
261
|
+
write(str(mol_dir / f"rank{k}_{c['mode']}.vasp"), c["atoms"], format="vasp", direct=True)
|
|
262
|
+
|
|
263
|
+
print(f"\n{name}: top configurations (E_ads = E_adslab - E_slab - E[{ref_label}]; negative = exothermic)")
|
|
264
|
+
print(df.head(args.top_k).to_string(float_format=lambda x: f"{x:.3f}"))
|
|
265
|
+
|
|
266
|
+
if not args.no_vasp:
|
|
267
|
+
write_vasp_dir(
|
|
268
|
+
gas_boxed,
|
|
269
|
+
mol_dir / "vasp" / "gas",
|
|
270
|
+
INCAR_GAS,
|
|
271
|
+
kpts=(1, 1, 1),
|
|
272
|
+
unpaired=unpaired,
|
|
273
|
+
fix_moment=True,
|
|
274
|
+
)
|
|
275
|
+
if dimer is not None:
|
|
276
|
+
write_vasp_dir(dimer, mol_dir / "vasp" / "gas_dimer", INCAR_GAS, kpts=(1, 1, 1))
|
|
277
|
+
for k, c in enumerate(top):
|
|
278
|
+
write_vasp_dir(
|
|
279
|
+
c["atoms"],
|
|
280
|
+
mol_dir / "vasp" / f"rank{k}_{c['mode']}",
|
|
281
|
+
INCAR_SLAB,
|
|
282
|
+
unpaired=unpaired,
|
|
283
|
+
metallic_slab=args.metallic,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
best_per_mode = df.groupby("mode")["E_ads_eV"].min().to_dict()
|
|
287
|
+
summary = {
|
|
288
|
+
"molecule": name,
|
|
289
|
+
"formula": mol_relaxed.get_chemical_formula(),
|
|
290
|
+
"unpaired_electrons": unpaired,
|
|
291
|
+
"E_gas_eV": e_gas,
|
|
292
|
+
"E_reference_eV": e_ref,
|
|
293
|
+
"reference": ref_label,
|
|
294
|
+
"E_slab_eV": e_slab,
|
|
295
|
+
"placements": len(rows),
|
|
296
|
+
"valid": len(candidates),
|
|
297
|
+
"best_mode": top[0]["mode"],
|
|
298
|
+
"best_E_ads_eV": top[0]["E_ads"],
|
|
299
|
+
"best_per_mode_eV": best_per_mode,
|
|
300
|
+
}
|
|
301
|
+
with open(mol_dir / "summary.json", "w") as f:
|
|
302
|
+
json.dump(summary, f, indent=2)
|
|
303
|
+
return summary
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def main() -> None:
|
|
307
|
+
args = parse_args()
|
|
308
|
+
mols = [load_molecule(s, args.unpaired) for s in args.molecules]
|
|
309
|
+
|
|
310
|
+
if args.list_modes:
|
|
311
|
+
for name, mol, _ in mols:
|
|
312
|
+
modes = binding_modes(mol, bidentate=args.bidentate, include_h=args.include_h_modes)
|
|
313
|
+
if args.modes:
|
|
314
|
+
modes = {k: v for k, v in modes.items() if k in args.modes}
|
|
315
|
+
describe_modes(name, mol, modes)
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
random.seed(args.seed)
|
|
319
|
+
np.random.seed(args.seed)
|
|
320
|
+
|
|
321
|
+
outdir = Path(args.outdir).resolve()
|
|
322
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
323
|
+
traj_dir = (outdir / "trajectories") if args.save_trajectories else None
|
|
324
|
+
if traj_dir is not None:
|
|
325
|
+
traj_dir.mkdir(parents=True, exist_ok=True)
|
|
326
|
+
|
|
327
|
+
print(f"Model: {args.model} | Task: {args.task} | Device: {args.device or 'auto'}")
|
|
328
|
+
|
|
329
|
+
if args.slab_file:
|
|
330
|
+
slab = slab_from_file(str(Path(args.slab_file).resolve()), args.surface_depth, args.fixed_depth)
|
|
331
|
+
else:
|
|
332
|
+
bulk_atoms = load_bulk_atoms(
|
|
333
|
+
args.bulk,
|
|
334
|
+
crystalstructure=args.crystalstructure,
|
|
335
|
+
a=args.lattice_a,
|
|
336
|
+
c=args.lattice_c,
|
|
337
|
+
)
|
|
338
|
+
slab = slab_from_bulk(
|
|
339
|
+
bulk_atoms,
|
|
340
|
+
tuple(args.millers),
|
|
341
|
+
termination=args.termination,
|
|
342
|
+
fixed_depth=args.fixed_depth,
|
|
343
|
+
min_ab=args.min_ab,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
write(str(outdir / "slab_input.vasp"), slab.atoms, format="vasp", direct=True)
|
|
347
|
+
|
|
348
|
+
calc = get_calculator(args.model, args.task, args.device)
|
|
349
|
+
relax = make_relax_job(calc, args.fmax, args.steps, args.optimizer, traj_dir)
|
|
350
|
+
|
|
351
|
+
print("\nRelaxing clean slab:")
|
|
352
|
+
slab_res = relax(slab.atoms, "clean slab")
|
|
353
|
+
e_slab, relaxed_slab = slab_res["energy"], slab_res["atoms"]
|
|
354
|
+
write(str(outdir / "slab_relaxed.vasp"), relaxed_slab, format="vasp", direct=True)
|
|
355
|
+
|
|
356
|
+
if not args.no_vasp:
|
|
357
|
+
write_vasp_dir(relaxed_slab, outdir / "vasp_slab", INCAR_SLAB, metallic_slab=args.metallic)
|
|
358
|
+
|
|
359
|
+
# Default: place on the relaxed slab to avoid artificial surface_changed anomalies
|
|
360
|
+
place_slab = slab if args.place_on_unrelaxed_slab else Slab(slab_atoms=relaxed_slab.copy())
|
|
361
|
+
|
|
362
|
+
summaries = []
|
|
363
|
+
for name, mol, unpaired in mols:
|
|
364
|
+
s = run_molecule(
|
|
365
|
+
name, mol, unpaired, args, outdir, calc, relax, place_slab, relaxed_slab, e_slab
|
|
366
|
+
)
|
|
367
|
+
if s is not None:
|
|
368
|
+
summaries.append(s)
|
|
369
|
+
|
|
370
|
+
if summaries:
|
|
371
|
+
tab = pd.DataFrame(
|
|
372
|
+
[
|
|
373
|
+
{
|
|
374
|
+
"molecule": s["molecule"],
|
|
375
|
+
"best_mode": s["best_mode"],
|
|
376
|
+
"E_ads_eV": s["best_E_ads_eV"],
|
|
377
|
+
"reference": s["reference"],
|
|
378
|
+
"valid": s["valid"],
|
|
379
|
+
"placements": s["placements"],
|
|
380
|
+
"unpaired_e": s["unpaired_electrons"],
|
|
381
|
+
}
|
|
382
|
+
for s in summaries
|
|
383
|
+
]
|
|
384
|
+
)
|
|
385
|
+
tab.to_csv(outdir / "summary_all_molecules.csv", index=False)
|
|
386
|
+
print("\n================ Summary ================")
|
|
387
|
+
print(tab.to_string(index=False, float_format=lambda x: f"{x:.3f}"))
|
|
388
|
+
|
|
389
|
+
print(f"\nAll output written to: {outdir}")
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
if __name__ == "__main__":
|
|
393
|
+
main()
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import itertools
|
|
4
|
+
import numpy as np
|
|
5
|
+
from ase import Atoms
|
|
6
|
+
from ase.neighborlist import NeighborList, natural_cutoffs
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def safe_rotate(atoms: Atoms, v1: np.ndarray, v2: np.ndarray, center: np.ndarray) -> None:
|
|
10
|
+
"""Rotate vector v1 onto v2 around center, safely handling collinear/antiparallel vectors."""
|
|
11
|
+
n1, n2 = np.linalg.norm(v1), np.linalg.norm(v2)
|
|
12
|
+
if n1 < 1e-8 or n2 < 1e-8:
|
|
13
|
+
return
|
|
14
|
+
u1, u2 = v1 / n1, v2 / n2
|
|
15
|
+
if np.allclose(u1, u2, atol=1e-6):
|
|
16
|
+
return
|
|
17
|
+
if np.allclose(u1, -u2, atol=1e-6):
|
|
18
|
+
ortho = np.array([1.0, 0.0, 0.0]) if abs(u1[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
|
|
19
|
+
axis = np.cross(u1, ortho)
|
|
20
|
+
atoms.rotate(180.0, axis, center=center)
|
|
21
|
+
else:
|
|
22
|
+
atoms.rotate(u1, u2, center=center)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def bonded_neighbors(mol: Atoms, mult: float = 1.15) -> list[list[int]]:
|
|
26
|
+
if len(mol) <= 1:
|
|
27
|
+
return [[]]
|
|
28
|
+
nl = NeighborList(natural_cutoffs(mol, mult=mult), self_interaction=False, bothways=True)
|
|
29
|
+
nl.update(mol)
|
|
30
|
+
return [list(nl.get_neighbors(i)[0]) for i in range(len(mol))]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def atom_fingerprint(mol: Atoms, i: int, tol: float = 0.08) -> tuple:
|
|
34
|
+
"""Distance-spectrum + coordination fingerprint for symmetry equivalence."""
|
|
35
|
+
d = mol.get_distances(i, range(len(mol)))
|
|
36
|
+
spectrum = tuple(
|
|
37
|
+
sorted((mol[j].symbol, round(float(d[j]) / tol)) for j in range(len(mol)) if j != i)
|
|
38
|
+
)
|
|
39
|
+
return (mol[i].symbol,) + spectrum
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def orient_for_binding(
|
|
43
|
+
mol: Atoms, atoms: list[int], flat_threshold: float = 0.5
|
|
44
|
+
) -> tuple[Atoms, str]:
|
|
45
|
+
"""Orient molecule so binding atom(s) face -z (toward the catalytic surface)."""
|
|
46
|
+
m = mol.copy()
|
|
47
|
+
if len(m) == 1:
|
|
48
|
+
return m, "atom"
|
|
49
|
+
|
|
50
|
+
center = m.positions[atoms].mean(axis=0)
|
|
51
|
+
if len(atoms) == 2:
|
|
52
|
+
ax = m.positions[atoms[1]] - m.positions[atoms[0]]
|
|
53
|
+
safe_rotate(m, ax, np.array([1.0, 0.0, 0.0]), center=center)
|
|
54
|
+
others = [k for k in range(len(m)) if k not in atoms]
|
|
55
|
+
if not others:
|
|
56
|
+
return m, "pair"
|
|
57
|
+
best, best_score = None, None
|
|
58
|
+
for ang in np.arange(0.0, 360.0, 2.0):
|
|
59
|
+
t = m.copy()
|
|
60
|
+
t.rotate(ang, "x", center=center)
|
|
61
|
+
dz = t.positions[others, 2] - t.positions[atoms, 2].mean()
|
|
62
|
+
score = (round(float(dz.min()), 2), float(dz.mean()))
|
|
63
|
+
if best_score is None or score > best_score:
|
|
64
|
+
best, best_score = t, score
|
|
65
|
+
return best, "pair"
|
|
66
|
+
|
|
67
|
+
nbrs = bonded_neighbors(m)
|
|
68
|
+
vec = np.zeros(3)
|
|
69
|
+
for i in atoms:
|
|
70
|
+
for j in nbrs[i]:
|
|
71
|
+
u = m.positions[j] - m.positions[i]
|
|
72
|
+
norm = np.linalg.norm(u)
|
|
73
|
+
if norm > 1e-8:
|
|
74
|
+
vec += u / norm
|
|
75
|
+
|
|
76
|
+
if np.linalg.norm(vec) > flat_threshold:
|
|
77
|
+
safe_rotate(m, -vec, np.array([0.0, 0.0, -1.0]), center=center)
|
|
78
|
+
return m, "down"
|
|
79
|
+
|
|
80
|
+
x = m.positions - m.positions.mean(axis=0)
|
|
81
|
+
normal = np.linalg.eigh(x.T @ x)[1][:, 0]
|
|
82
|
+
safe_rotate(m, normal, np.array([0.0, 0.0, 1.0]), center=center)
|
|
83
|
+
return m, "flat"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def binding_modes(
|
|
87
|
+
mol: Atoms,
|
|
88
|
+
bidentate: bool = True,
|
|
89
|
+
include_h: bool = False,
|
|
90
|
+
pair_cutoff: float = 4.0,
|
|
91
|
+
) -> dict[str, list[int]]:
|
|
92
|
+
"""Find symmetry-unique monodentate and bidentate binding modes."""
|
|
93
|
+
if len(mol) == 1:
|
|
94
|
+
return {f"{mol[0].symbol}0": [0]}
|
|
95
|
+
|
|
96
|
+
candidates = [
|
|
97
|
+
i for i, a in enumerate(mol) if include_h or a.symbol != "H"
|
|
98
|
+
]
|
|
99
|
+
if not candidates:
|
|
100
|
+
candidates = list(range(len(mol)))
|
|
101
|
+
|
|
102
|
+
modes: dict[str, list[int]] = {}
|
|
103
|
+
seen: set[tuple] = set()
|
|
104
|
+
for i in candidates:
|
|
105
|
+
fp = atom_fingerprint(mol, i)
|
|
106
|
+
if fp not in seen:
|
|
107
|
+
seen.add(fp)
|
|
108
|
+
modes[f"{mol[i].symbol}{i}"] = [i]
|
|
109
|
+
|
|
110
|
+
if bidentate and len(candidates) >= 2:
|
|
111
|
+
seen_pairs: set[tuple] = set()
|
|
112
|
+
for i, j in itertools.combinations(candidates, 2):
|
|
113
|
+
if mol.get_distance(i, j) > pair_cutoff:
|
|
114
|
+
continue
|
|
115
|
+
fp_pair = tuple(sorted([atom_fingerprint(mol, i), atom_fingerprint(mol, j)]))
|
|
116
|
+
o, _ = orient_for_binding(mol, [i, j])
|
|
117
|
+
others = [k for k in range(len(mol)) if k not in (i, j)]
|
|
118
|
+
if others and (o.positions[others, 2] - o.positions[[i, j], 2].mean()).min() < 0.3:
|
|
119
|
+
continue
|
|
120
|
+
if fp_pair not in seen_pairs:
|
|
121
|
+
seen_pairs.add(fp_pair)
|
|
122
|
+
modes[f"{mol[i].symbol}{i}-{mol[j].symbol}{j}"] = [i, j]
|
|
123
|
+
|
|
124
|
+
return modes
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def describe_modes(name: str, mol: Atoms, modes: dict[str, list[int]]) -> None:
|
|
128
|
+
print(f"\n{name} ({mol.get_chemical_formula()}): {len(modes)} binding mode(s)")
|
|
129
|
+
for mname, idx in modes.items():
|
|
130
|
+
o, how = orient_for_binding(mol, idx)
|
|
131
|
+
z = o.positions[:, 2] - o.positions[idx, 2].mean()
|
|
132
|
+
prof = ", ".join(
|
|
133
|
+
f"{s}{k}:{dz:+.2f}" for k, (s, dz) in enumerate(zip(o.get_chemical_symbols(), z))
|
|
134
|
+
)
|
|
135
|
+
print(f" {mname:<10s} [{how:4s}] heights rel. to binding atom(s): {prof}")
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import numpy as np
|
|
5
|
+
from ase import Atoms
|
|
6
|
+
from ase.build import molecule as ase_molecule
|
|
7
|
+
from ase.collections import g2
|
|
8
|
+
from ase.data import chemical_symbols, covalent_radii
|
|
9
|
+
from ase.io import read
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _ch2i2() -> Atoms:
|
|
13
|
+
r_ci, r_ch = 2.14, 1.09
|
|
14
|
+
a, b = np.radians(113.0) / 2, np.radians(112.0) / 2
|
|
15
|
+
return Atoms(
|
|
16
|
+
"CI2H2",
|
|
17
|
+
positions=[
|
|
18
|
+
(0, 0, 0),
|
|
19
|
+
(r_ci * np.sin(a), 0, r_ci * np.cos(a)),
|
|
20
|
+
(-r_ci * np.sin(a), 0, r_ci * np.cos(a)),
|
|
21
|
+
(0, r_ch * np.sin(b), -r_ch * np.cos(b)),
|
|
22
|
+
(0, -r_ch * np.sin(b), -r_ch * np.cos(b)),
|
|
23
|
+
],
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _ch2i() -> Atoms:
|
|
28
|
+
r_ci, r_ch, t = 2.05, 1.08, np.radians(120.0)
|
|
29
|
+
return Atoms(
|
|
30
|
+
"CIH2",
|
|
31
|
+
positions=[
|
|
32
|
+
(0, 0, 0),
|
|
33
|
+
(r_ci, 0, 0),
|
|
34
|
+
(r_ch * np.cos(t), r_ch * np.sin(t), 0),
|
|
35
|
+
(r_ch * np.cos(t), -r_ch * np.sin(t), 0),
|
|
36
|
+
],
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _ch2() -> Atoms:
|
|
41
|
+
r, h = 1.08, np.radians(134.0) / 2
|
|
42
|
+
return Atoms(
|
|
43
|
+
"CH2",
|
|
44
|
+
positions=[
|
|
45
|
+
(0, 0, 0),
|
|
46
|
+
(r * np.sin(h), 0, r * np.cos(h)),
|
|
47
|
+
(-r * np.sin(h), 0, r * np.cos(h)),
|
|
48
|
+
],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _ch2oo() -> Atoms:
|
|
53
|
+
r_co, r_oo, r_ch = 1.27, 1.35, 1.08
|
|
54
|
+
o1 = np.array([r_co, 0, 0])
|
|
55
|
+
o2 = o1 + r_oo * np.array([np.cos(np.radians(62.0)), np.sin(np.radians(62.0)), 0])
|
|
56
|
+
t = np.radians(120.0)
|
|
57
|
+
return Atoms(
|
|
58
|
+
"COOH2",
|
|
59
|
+
positions=[
|
|
60
|
+
(0, 0, 0),
|
|
61
|
+
o1,
|
|
62
|
+
o2,
|
|
63
|
+
(r_ch * np.cos(t), r_ch * np.sin(t), 0),
|
|
64
|
+
(r_ch * np.cos(t), -r_ch * np.sin(t), 0),
|
|
65
|
+
],
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
BUILTIN_MOLECULES: dict[str, tuple[callable, int]] = {
|
|
70
|
+
"CH2I2": (_ch2i2, 0),
|
|
71
|
+
"CH2I": (_ch2i, 1),
|
|
72
|
+
"CH2": (_ch2, 2),
|
|
73
|
+
"CH2OO": (_ch2oo, 0),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Standard ground-state unpaired electrons for isolated atoms
|
|
77
|
+
ATOM_UNPAIRED: dict[str, int] = {
|
|
78
|
+
"H": 1, "Li": 1, "B": 1, "C": 2, "N": 3, "O": 2, "F": 1,
|
|
79
|
+
"Na": 1, "Al": 1, "Si": 2, "P": 3, "S": 2, "Cl": 1,
|
|
80
|
+
"K": 1, "Br": 1, "I": 1,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _from_smiles(smiles: str) -> Atoms:
|
|
85
|
+
try:
|
|
86
|
+
from rdkit import Chem
|
|
87
|
+
from rdkit.Chem import AllChem
|
|
88
|
+
except ImportError as exc:
|
|
89
|
+
raise SystemExit(
|
|
90
|
+
"Install rdkit (`pip install adsorb-screen[smiles]`) to build molecules from SMILES."
|
|
91
|
+
) from exc
|
|
92
|
+
|
|
93
|
+
mol = Chem.AddHs(Chem.MolFromSmiles(smiles))
|
|
94
|
+
if mol is None:
|
|
95
|
+
raise SystemExit(f"Invalid SMILES string: '{smiles}'")
|
|
96
|
+
AllChem.EmbedMolecule(mol, AllChem.ETKDGv3())
|
|
97
|
+
AllChem.UFFOptimizeMolecule(mol)
|
|
98
|
+
conf = mol.GetConformer()
|
|
99
|
+
symbols = [a.GetSymbol() for a in mol.GetAtoms()]
|
|
100
|
+
positions = conf.GetPositions()
|
|
101
|
+
return Atoms(symbols=symbols, positions=positions)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def load_molecule(spec: str, unpaired_override: int | None = None) -> tuple[str, Atoms, int]:
|
|
105
|
+
"""Load a molecule/atom from built-ins, file path, SMILES, single element, or ASE G2."""
|
|
106
|
+
path = Path(spec)
|
|
107
|
+
if path.is_file():
|
|
108
|
+
mol = read(str(path))
|
|
109
|
+
mol.set_cell(None)
|
|
110
|
+
mol.pbc = False
|
|
111
|
+
n_unp = unpaired_override if unpaired_override is not None else 0
|
|
112
|
+
return path.stem, mol, n_unp
|
|
113
|
+
|
|
114
|
+
if spec in BUILTIN_MOLECULES:
|
|
115
|
+
builder, default_unp = BUILTIN_MOLECULES[spec]
|
|
116
|
+
n_unp = unpaired_override if unpaired_override is not None else default_unp
|
|
117
|
+
return spec, builder(), n_unp
|
|
118
|
+
|
|
119
|
+
if spec.startswith("smiles:"):
|
|
120
|
+
smiles_str = spec.split("smiles:", 1)[1]
|
|
121
|
+
mol = _from_smiles(smiles_str)
|
|
122
|
+
safe_name = "".join(c if c.isalnum() else "_" for c in smiles_str).strip("_")
|
|
123
|
+
n_unp = unpaired_override if unpaired_override is not None else 0
|
|
124
|
+
return f"smiles_{safe_name}", mol, n_unp
|
|
125
|
+
|
|
126
|
+
if spec in chemical_symbols and spec != "X":
|
|
127
|
+
mol = Atoms(spec, positions=[(0.0, 0.0, 0.0)])
|
|
128
|
+
default_unp = ATOM_UNPAIRED.get(spec, 0)
|
|
129
|
+
n_unp = unpaired_override if unpaired_override is not None else default_unp
|
|
130
|
+
return spec, mol, n_unp
|
|
131
|
+
|
|
132
|
+
if spec in g2.names:
|
|
133
|
+
mol = ase_molecule(spec)
|
|
134
|
+
mol.set_cell(None)
|
|
135
|
+
mol.pbc = False
|
|
136
|
+
inferred_unp = int(round(abs(mol.get_initial_magnetic_moments().sum())))
|
|
137
|
+
n_unp = unpaired_override if unpaired_override is not None else inferred_unp
|
|
138
|
+
return spec, mol, n_unp
|
|
139
|
+
|
|
140
|
+
raise SystemExit(
|
|
141
|
+
f"Unknown molecule '{spec}'. Supported inputs:\n"
|
|
142
|
+
f" 1. Structure file (.xyz, POSCAR, .sdf, .cif)\n"
|
|
143
|
+
f" 2. Built-in presets: {', '.join(BUILTIN_MOLECULES)}\n"
|
|
144
|
+
f" 3. Any chemical element symbol (e.g., H, O, N, I, Pt)\n"
|
|
145
|
+
f" 4. Any ASE G2 molecule name (e.g., CO, CO2, NH3, H2O, CH4, CH3OH, C2H4)\n"
|
|
146
|
+
f" 5. SMILES string prefixed with 'smiles:' (e.g., 'smiles:CCO')"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def build_dimer(symbol: str, box: float = 20.0) -> Atoms:
|
|
151
|
+
"""Build a homonuclear diatomic molecule X2 for half-dimer gas reference."""
|
|
152
|
+
z = Atoms(symbol).numbers[0]
|
|
153
|
+
r0 = 2.67 if symbol == "I" else float(2.0 * covalent_radii[z])
|
|
154
|
+
dimer = Atoms(
|
|
155
|
+
f"{symbol}2",
|
|
156
|
+
positions=[(0.0, 0.0, 0.0), (0.0, 0.0, r0)],
|
|
157
|
+
cell=[box, box, box],
|
|
158
|
+
pbc=True,
|
|
159
|
+
)
|
|
160
|
+
dimer.center()
|
|
161
|
+
return dimer
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import time
|
|
5
|
+
import numpy as np
|
|
6
|
+
from ase import Atoms
|
|
7
|
+
from ase.optimize import BFGS, FIRE, LBFGS
|
|
8
|
+
from fairchem.data.oc.utils import DetectTrajAnomaly
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
OPTIMIZERS = {"LBFGS": LBFGS, "BFGS": BFGS, "FIRE": FIRE}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_calculator(model: str, task: str, device: str | None = None):
|
|
15
|
+
from fairchem.core import FAIRChemCalculator, pretrained_mlip
|
|
16
|
+
predict_unit = pretrained_mlip.get_predict_unit(model, device=device)
|
|
17
|
+
return FAIRChemCalculator(predict_unit, task_name=task)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def make_relax_job(
|
|
21
|
+
calc,
|
|
22
|
+
fmax: float = 0.02,
|
|
23
|
+
steps: int = 300,
|
|
24
|
+
optimizer_name: str = "LBFGS",
|
|
25
|
+
traj_dir: Path | None = None,
|
|
26
|
+
):
|
|
27
|
+
opt_cls = OPTIMIZERS.get(optimizer_name.upper(), LBFGS)
|
|
28
|
+
|
|
29
|
+
def relax(initial_atoms: Atoms, label: str) -> dict:
|
|
30
|
+
atoms = initial_atoms.copy()
|
|
31
|
+
atoms.calc = calc
|
|
32
|
+
traj_path = None
|
|
33
|
+
if traj_dir is not None:
|
|
34
|
+
safe_label = label.replace(" #", "_").replace(" ", "_")
|
|
35
|
+
traj_path = str(traj_dir / f"{safe_label}.traj")
|
|
36
|
+
t0 = time.time()
|
|
37
|
+
opt = opt_cls(atoms, logfile=None, trajectory=traj_path)
|
|
38
|
+
converged = bool(opt.run(fmax=fmax, steps=steps))
|
|
39
|
+
energy = float(atoms.get_potential_energy())
|
|
40
|
+
atoms.calc = None
|
|
41
|
+
print(
|
|
42
|
+
f" {label:>26s}: E = {energy:12.4f} eV, {opt.nsteps:4d} steps, "
|
|
43
|
+
f"{'converged' if converged else 'NOT converged'}, {time.time() - t0:6.1f} s",
|
|
44
|
+
flush=True,
|
|
45
|
+
)
|
|
46
|
+
return {
|
|
47
|
+
"initial": initial_atoms,
|
|
48
|
+
"atoms": atoms,
|
|
49
|
+
"energy": energy,
|
|
50
|
+
"converged": converged,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return relax
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def relax_gas_molecule(
|
|
57
|
+
mol: Atoms, calc, box: float = 20.0, fmax: float = 0.01, steps: int = 500
|
|
58
|
+
) -> tuple[Atoms, float]:
|
|
59
|
+
gas = mol.copy()
|
|
60
|
+
gas.set_cell([box, box, box])
|
|
61
|
+
gas.center()
|
|
62
|
+
gas.pbc = True
|
|
63
|
+
gas.calc = calc
|
|
64
|
+
if len(gas) > 1:
|
|
65
|
+
LBFGS(gas, logfile=None).run(fmax=fmax, steps=steps)
|
|
66
|
+
e = float(gas.get_potential_energy())
|
|
67
|
+
gas.calc = None
|
|
68
|
+
# Strip cell for clean orientation processing while keeping relaxed coordinates
|
|
69
|
+
gas_no_cell = gas.copy()
|
|
70
|
+
gas_no_cell.set_cell(None)
|
|
71
|
+
gas_no_cell.pbc = False
|
|
72
|
+
return gas, e
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def min_ads_slab_distance(atoms: Atoms) -> float:
|
|
76
|
+
tags = atoms.get_tags()
|
|
77
|
+
ads = np.where(tags == 2)[0]
|
|
78
|
+
sub = np.where(tags != 2)[0]
|
|
79
|
+
if len(ads) == 0 or len(sub) == 0:
|
|
80
|
+
return 0.0
|
|
81
|
+
return float(min(atoms.get_distances(i, sub, mic=True).min() for i in ads))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def find_anomalies(
|
|
85
|
+
initial: Atoms, final: Atoms, relaxed_slab: Atoms, desorb_dist: float
|
|
86
|
+
) -> list[str]:
|
|
87
|
+
det = DetectTrajAnomaly(
|
|
88
|
+
initial, final, atoms_tag=initial.get_tags(), final_slab_atoms=relaxed_slab
|
|
89
|
+
)
|
|
90
|
+
found = []
|
|
91
|
+
if (initial.get_tags() == 2).sum() > 1 and det.is_adsorbate_dissociated():
|
|
92
|
+
found.append("dissociated")
|
|
93
|
+
if min_ads_slab_distance(final) > desorb_dist:
|
|
94
|
+
found.append("desorbed")
|
|
95
|
+
if det.has_surface_changed():
|
|
96
|
+
found.append("surface_changed")
|
|
97
|
+
if det.is_adsorbate_intercalated():
|
|
98
|
+
found.append("intercalated")
|
|
99
|
+
return found
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from ase import Atoms
|
|
5
|
+
from ase.build import bulk as ase_bulk
|
|
6
|
+
from ase.constraints import FixAtoms
|
|
7
|
+
from ase.io import read
|
|
8
|
+
from ase.neighborlist import NeighborList, natural_cutoffs
|
|
9
|
+
from ase.spacegroup import crystal
|
|
10
|
+
from fairchem.data.oc.core import Bulk, Slab
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
BUILTIN_BULKS: dict[str, callable] = {
|
|
14
|
+
"alpha-quartz": lambda: crystal(
|
|
15
|
+
["Si", "O"],
|
|
16
|
+
basis=[(0.4697, 0.0, 1 / 3), (0.4135, 0.2669, 0.2142)],
|
|
17
|
+
spacegroup=152,
|
|
18
|
+
cellpar=[4.9134, 4.9134, 5.4052, 90, 90, 120],
|
|
19
|
+
),
|
|
20
|
+
"rutile-TiO2": lambda: crystal(
|
|
21
|
+
["Ti", "O"],
|
|
22
|
+
basis=[(0.0, 0.0, 0.0), (0.3048, 0.3048, 0.0)],
|
|
23
|
+
spacegroup=136,
|
|
24
|
+
cellpar=[4.5937, 4.5937, 2.9587, 90, 90, 90],
|
|
25
|
+
),
|
|
26
|
+
"MgO": lambda: ase_bulk("MgO", crystalstructure="rocksalt", a=4.212),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_bulk_atoms(
|
|
31
|
+
bulk_spec: str,
|
|
32
|
+
crystalstructure: str | None = None,
|
|
33
|
+
a: float | None = None,
|
|
34
|
+
c: float | None = None,
|
|
35
|
+
) -> Atoms:
|
|
36
|
+
"""Load bulk crystal from preset, file path, Materials Project ID, or ASE bulk."""
|
|
37
|
+
if bulk_spec in BUILTIN_BULKS:
|
|
38
|
+
return BUILTIN_BULKS[bulk_spec]()
|
|
39
|
+
|
|
40
|
+
if bulk_spec.startswith("mp-"):
|
|
41
|
+
try:
|
|
42
|
+
from mp_api.client import MPRester
|
|
43
|
+
from pymatgen.io.ase import AseAtomsAdaptor
|
|
44
|
+
except ImportError as exc:
|
|
45
|
+
raise SystemExit(
|
|
46
|
+
"Install mp-api (`pip install adsorb-screen[mp]`) to fetch Materials Project IDs."
|
|
47
|
+
) from exc
|
|
48
|
+
with MPRester() as mpr:
|
|
49
|
+
struct = mpr.get_structure_by_material_id(bulk_spec, conventional_unit_cell=True)
|
|
50
|
+
return AseAtomsAdaptor.get_atoms(struct)
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
return read(bulk_spec)
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
kwargs = {}
|
|
59
|
+
if crystalstructure:
|
|
60
|
+
kwargs["crystalstructure"] = crystalstructure
|
|
61
|
+
if a is not None:
|
|
62
|
+
kwargs["a"] = a
|
|
63
|
+
if c is not None:
|
|
64
|
+
kwargs["c"] = c
|
|
65
|
+
return ase_bulk(bulk_spec, **kwargs)
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
raise SystemExit(
|
|
68
|
+
f"Could not build bulk '{bulk_spec}'. Provide a valid structure file, "
|
|
69
|
+
f"preset ({', '.join(BUILTIN_BULKS)}), ASE formula (e.g. Pt, Cu, Fe, Au, NaCl), "
|
|
70
|
+
f"or Materials Project ID (mp-XXXX)."
|
|
71
|
+
) from exc
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_bulk_cn(bulk_atoms: Atoms, mult: float = 1.15) -> dict[str, int]:
|
|
75
|
+
"""Dynamically determine ideal bulk coordination numbers per element."""
|
|
76
|
+
rep = bulk_atoms.repeat((2, 2, 2))
|
|
77
|
+
nl = NeighborList(natural_cutoffs(rep, mult=mult), self_interaction=False, bothways=True)
|
|
78
|
+
nl.update(rep)
|
|
79
|
+
cn_map: dict[str, int] = {}
|
|
80
|
+
for idx, atom in enumerate(rep):
|
|
81
|
+
cn = len(nl.get_neighbors(idx)[0])
|
|
82
|
+
cn_map[atom.symbol] = max(cn_map.get(atom.symbol, 0), cn)
|
|
83
|
+
return cn_map
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def broken_bonds(atoms: Atoms, bulk_cn: dict[str, int], mult: float = 1.15) -> int:
|
|
87
|
+
"""Count under-coordinated bonds on surface atoms (tag == 1) for any crystal."""
|
|
88
|
+
nl = NeighborList(natural_cutoffs(atoms, mult=mult), self_interaction=False, bothways=True)
|
|
89
|
+
nl.update(atoms)
|
|
90
|
+
surf = np.where(atoms.get_tags() == 1)[0]
|
|
91
|
+
return int(
|
|
92
|
+
sum(max(bulk_cn.get(atoms[k].symbol, 0) - len(nl.get_neighbors(k)[0]), 0) for k in surf)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def apply_fixed_depth(atoms: Atoms, fixed_depth: float) -> Atoms:
|
|
97
|
+
"""Freeze atoms within fixed_depth (A) of the bottom of the slab."""
|
|
98
|
+
z = atoms.positions[:, 2]
|
|
99
|
+
thickness = float(z.max() - z.min())
|
|
100
|
+
fixed = z < (z.min() + fixed_depth) if fixed_depth > 0 else np.zeros(len(atoms), dtype=bool)
|
|
101
|
+
if fixed.all():
|
|
102
|
+
raise SystemExit(
|
|
103
|
+
f"--fixed-depth {fixed_depth} A fixes the entire slab (thickness {thickness:.2f} A)."
|
|
104
|
+
)
|
|
105
|
+
overlap = fixed & (atoms.get_tags() == 1)
|
|
106
|
+
if overlap.any():
|
|
107
|
+
print(f" WARNING: {overlap.sum()} surface-tagged atoms are inside --fixed-depth.")
|
|
108
|
+
atoms.set_constraint(FixAtoms(mask=fixed) if fixed.any() else [])
|
|
109
|
+
free = ~fixed
|
|
110
|
+
print(
|
|
111
|
+
f" Fixed {fixed.sum()} / {len(atoms)} atoms: bottom {fixed_depth:.2f} A of a "
|
|
112
|
+
f"{thickness:.2f} A slab (relaxing {free.sum()} atoms with z >= {z[free].min():.2f} A)"
|
|
113
|
+
)
|
|
114
|
+
return atoms
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def slab_from_bulk(
|
|
118
|
+
bulk_atoms: Atoms,
|
|
119
|
+
millers: tuple[int, int, int],
|
|
120
|
+
termination: int | None = None,
|
|
121
|
+
fixed_depth: float = 6.0,
|
|
122
|
+
min_ab: float = 8.0,
|
|
123
|
+
) -> Slab:
|
|
124
|
+
"""Cleave bulk crystal along Miller indices, pick lowest broken-bond termination, and tile."""
|
|
125
|
+
bulk_cn = get_bulk_cn(bulk_atoms)
|
|
126
|
+
slabs = Slab.from_bulk_get_specific_millers(
|
|
127
|
+
tuple(millers), bulk=Bulk(bulk_atoms=bulk_atoms), min_ab=min_ab
|
|
128
|
+
)
|
|
129
|
+
print(f"{len(slabs)} termination(s) for {bulk_atoms.get_chemical_formula()} {tuple(millers)}:")
|
|
130
|
+
scores = []
|
|
131
|
+
for k, s in enumerate(slabs):
|
|
132
|
+
bb = broken_bonds(s.atoms, bulk_cn)
|
|
133
|
+
scores.append(bb)
|
|
134
|
+
print(f" [{k}] {s} under-coordinated surface bonds: {bb}")
|
|
135
|
+
pick = termination if termination is not None else int(np.argmin(scores))
|
|
136
|
+
print(f"Selected termination [{pick}]")
|
|
137
|
+
slab = slabs[pick]
|
|
138
|
+
if fixed_depth is not None:
|
|
139
|
+
apply_fixed_depth(slab.atoms, fixed_depth)
|
|
140
|
+
return slab
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def slab_from_file(path: str, surface_depth: float, fixed_depth: float) -> Slab:
|
|
144
|
+
"""Load custom slab file and tag top surface_depth A as surface (tag=1)."""
|
|
145
|
+
atoms = read(path)
|
|
146
|
+
atoms.pbc = True
|
|
147
|
+
z = atoms.positions[:, 2]
|
|
148
|
+
surf = z > (z.max() - surface_depth)
|
|
149
|
+
if surf.sum() == 0:
|
|
150
|
+
raise SystemExit("Check --surface-depth: no atoms tagged as surface.")
|
|
151
|
+
atoms.set_tags(surf.astype(int))
|
|
152
|
+
vac = float(atoms.cell.lengths()[2] - (z.max() - z.min()))
|
|
153
|
+
print(
|
|
154
|
+
f"Slab {atoms.get_chemical_formula()} from {path}: {len(atoms)} atoms, "
|
|
155
|
+
f"{surf.sum()} surface (top {surface_depth:.2f} A), ~{vac:.1f} A vacuum"
|
|
156
|
+
)
|
|
157
|
+
apply_fixed_depth(atoms, fixed_depth)
|
|
158
|
+
if vac < 12.0:
|
|
159
|
+
print(" WARNING: Vacuum is less than 12 A; periodic image interactions may occur.")
|
|
160
|
+
return Slab(slab_atoms=atoms)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import numpy as np
|
|
5
|
+
from ase import Atoms
|
|
6
|
+
from ase.io import write
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
INCAR_COMMON = {
|
|
10
|
+
"PREC": "Normal",
|
|
11
|
+
"ENCUT": 400,
|
|
12
|
+
"GGA": "RP",
|
|
13
|
+
"IVDW": 11,
|
|
14
|
+
"ISPIN": 1,
|
|
15
|
+
"EDIFF": 1e-5,
|
|
16
|
+
"ISMEAR": 0,
|
|
17
|
+
"SIGMA": 0.05,
|
|
18
|
+
"LREAL": "Auto",
|
|
19
|
+
"ISYM": 0,
|
|
20
|
+
"NELM": 200,
|
|
21
|
+
"LWAVE": ".FALSE.",
|
|
22
|
+
"LCHARG": ".FALSE.",
|
|
23
|
+
"IBRION": 2,
|
|
24
|
+
"NSW": 500,
|
|
25
|
+
"ISIF": 0,
|
|
26
|
+
"EDIFFG": -0.03,
|
|
27
|
+
}
|
|
28
|
+
INCAR_SLAB = {"LDIPOL": ".TRUE.", "IDIPOL": 3}
|
|
29
|
+
INCAR_GAS = {"LDIPOL": ".TRUE.", "IDIPOL": 4}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def write_vasp_dir(
|
|
33
|
+
atoms: Atoms,
|
|
34
|
+
outdir: Path,
|
|
35
|
+
incar_extra: dict,
|
|
36
|
+
kpts: tuple[int, int, int] | None = None,
|
|
37
|
+
unpaired: int = 0,
|
|
38
|
+
fix_moment: bool = False,
|
|
39
|
+
metallic_slab: bool = False,
|
|
40
|
+
) -> None:
|
|
41
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
|
|
43
|
+
# Sort explicitly by symbol so tags stay synchronized with POSCAR atom order
|
|
44
|
+
symbols_orig = atoms.get_chemical_symbols()
|
|
45
|
+
sort_idx = np.argsort(symbols_orig, kind="mergesort")
|
|
46
|
+
sorted_atoms = atoms[sort_idx]
|
|
47
|
+
|
|
48
|
+
poscar_path = outdir / "POSCAR"
|
|
49
|
+
write(str(poscar_path), sorted_atoms, format="vasp", direct=True, sort=False)
|
|
50
|
+
|
|
51
|
+
symbols = sorted_atoms.get_chemical_symbols()
|
|
52
|
+
species = list(dict.fromkeys(symbols))
|
|
53
|
+
|
|
54
|
+
incar = {**INCAR_COMMON, **incar_extra}
|
|
55
|
+
if metallic_slab and incar_extra != INCAR_GAS:
|
|
56
|
+
incar["ISMEAR"] = 1
|
|
57
|
+
incar["SIGMA"] = 0.20
|
|
58
|
+
|
|
59
|
+
if unpaired > 0:
|
|
60
|
+
incar["ISPIN"] = 2
|
|
61
|
+
if fix_moment:
|
|
62
|
+
incar["NUPDOWN"] = unpaired
|
|
63
|
+
tags = sorted_atoms.get_tags()
|
|
64
|
+
# Assign initial spin to non-H adsorbate atoms (tag == 2) or isolated gas molecule
|
|
65
|
+
is_gas = (tags == 2).sum() == 0
|
|
66
|
+
is_mag_target = [
|
|
67
|
+
((t == 2 or is_gas) and (s != "H" or len(sorted_atoms) == 1))
|
|
68
|
+
for t, s in zip(tags, symbols)
|
|
69
|
+
]
|
|
70
|
+
n_mag = max(sum(is_mag_target), 1)
|
|
71
|
+
mag_val = round(max(unpaired / n_mag, 0.5), 2)
|
|
72
|
+
incar["MAGMOM"] = " ".join(f"{mag_val}" if m else "0.0" for m in is_mag_target)
|
|
73
|
+
|
|
74
|
+
with open(outdir / "INCAR", "w") as f:
|
|
75
|
+
f.write("".join(f"{k} = {v}\n" for k, v in incar.items()))
|
|
76
|
+
|
|
77
|
+
if kpts is None:
|
|
78
|
+
a, b = sorted_atoms.cell.lengths()[:2]
|
|
79
|
+
kpts = (max(1, round(40.0 / a)), max(1, round(40.0 / b)), 1)
|
|
80
|
+
|
|
81
|
+
with open(outdir / "KPOINTS", "w") as f:
|
|
82
|
+
f.write(f"Automatic\n0\nGamma\n{kpts[0]} {kpts[1]} {kpts[2]}\n0 0 0\n")
|
|
83
|
+
|
|
84
|
+
with open(outdir / "POTCAR_ORDER", "w") as f:
|
|
85
|
+
f.write(" ".join(species) + "\n")
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: adsorb-screen
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: General-purpose AdsorbML screening of molecules and radicals on catalytic surfaces with FAIRChem / UMA.
|
|
5
|
+
Author-email: Raghavendra Meena <raghavendra.ip2020@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: catalysis,adsorption,machine-learning-potentials,fairchem,DFT,VASP,ASE
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: numpy>=1.23
|
|
11
|
+
Requires-Dist: pandas>=1.5
|
|
12
|
+
Requires-Dist: ase>=3.22
|
|
13
|
+
Requires-Dist: fairchem-core>=2.0.0
|
|
14
|
+
Requires-Dist: fairchem-data-oc>=0.1.0
|
|
15
|
+
Provides-Extra: smiles
|
|
16
|
+
Requires-Dist: rdkit>=2023.3.1; extra == "smiles"
|
|
17
|
+
Provides-Extra: mp
|
|
18
|
+
Requires-Dist: mp-api>=0.39.0; extra == "mp"
|
|
19
|
+
Requires-Dist: pymatgen>=2023.10.0; extra == "mp"
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
22
|
+
Requires-Dist: ruff>=0.3.0; extra == "dev"
|
|
23
|
+
|
|
24
|
+
# adsorb-screen
|
|
25
|
+
|
|
26
|
+
Automated, general-purpose **AdsorbML** configuration screening of molecules, radicals, and atoms on **any catalytic surface** using FAIRChem v2 / UMA and VASP input generation.
|
|
27
|
+
|
|
28
|
+
## Key Features
|
|
29
|
+
- **Any Adsorbate**: Screen built-in radicals (`CH2I2`, `CH2I`, `CH2`, `CH2OO`), any single atom (`H`, `O`, `N`, `I`, `Pt`), any molecule in the ASE G2 database (`CO`, `CO2`, `NH3`, `CH3OH`, `H2O`, `C2H4`), SMILES strings (`smiles:CC(=O)O`), or custom structure files (`.xyz`, `POSCAR`, `.sdf`).
|
|
30
|
+
- **Any Catalytic Surface**: Provide your own slab (`--slab-file`), cleave any bulk crystal file (`--bulk my_oxide.cif --millers 1 1 0`), use built-in oxide presets (`alpha-quartz`, `rutile-TiO2`, `MgO`), use any ASE metal/crystal (`--bulk Pt --millers 1 1 1`), or fetch directly from the Materials Project (`--bulk mp-126 --millers 1 1 1`).
|
|
31
|
+
- **Symmetry-Aware Binding Modes**: Automatically identifies symmetry-unique monodentate and bidentate binding sites and orients lone pairs/radical lobes toward the surface (`down`), pi-systems parallel to the surface (`flat`), or bidentate bridges (`pair`).
|
|
32
|
+
- **Anomaly Filtering & VASP Export**: Rejects dissociated, desorbed, intercalated, or reconstructed trajectories and exports ready-to-run VASP directories (`POSCAR`, `INCAR`, `KPOINTS`, `POTCAR_ORDER`) with open-shell `MAGMOM`/`NUPDOWN` and metallic/insulator smearing.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install -e .
|
|
38
|
+
# Optional extras for SMILES and Materials Project support:
|
|
39
|
+
pip install -e ".[smiles,mp,dev]"
|
|
40
|
+
huggingface-cli login
|
|
41
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/adsorb_screen/__init__.py
|
|
4
|
+
src/adsorb_screen/cli.py
|
|
5
|
+
src/adsorb_screen/modes.py
|
|
6
|
+
src/adsorb_screen/molecules.py
|
|
7
|
+
src/adsorb_screen/relax.py
|
|
8
|
+
src/adsorb_screen/slab.py
|
|
9
|
+
src/adsorb_screen/vasp.py
|
|
10
|
+
src/adsorb_screen.egg-info/PKG-INFO
|
|
11
|
+
src/adsorb_screen.egg-info/SOURCES.txt
|
|
12
|
+
src/adsorb_screen.egg-info/dependency_links.txt
|
|
13
|
+
src/adsorb_screen.egg-info/entry_points.txt
|
|
14
|
+
src/adsorb_screen.egg-info/requires.txt
|
|
15
|
+
src/adsorb_screen.egg-info/top_level.txt
|
|
16
|
+
tests/test_core.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
adsorb_screen
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from adsorb_screen.modes import binding_modes, orient_for_binding, safe_rotate
|
|
3
|
+
from adsorb_screen.molecules import load_molecule
|
|
4
|
+
from adsorb_screen.slab import get_bulk_cn, load_bulk_atoms
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_safe_rotate_antiparallel():
|
|
8
|
+
_, mol, _ = load_molecule("CH2I2")
|
|
9
|
+
v1 = np.array([0.0, 0.0, 1.0])
|
|
10
|
+
v2 = np.array([0.0, 0.0, -1.0])
|
|
11
|
+
safe_rotate(mol, v1, v2, center=np.zeros(3))
|
|
12
|
+
assert not np.isnan(mol.positions).any()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_binding_modes_ch2i2():
|
|
16
|
+
_, mol, _ = load_molecule("CH2I2")
|
|
17
|
+
modes = binding_modes(mol, bidentate=True)
|
|
18
|
+
# Expect C0 (flat), one unique I (down), and I1-I2 (bidentate pair)
|
|
19
|
+
assert len(modes) == 3
|
|
20
|
+
o_pair, how = orient_for_binding(mol, [1, 2])
|
|
21
|
+
assert how == "pair"
|
|
22
|
+
# C and H atoms must sit higher in z than the two binding I atoms
|
|
23
|
+
z_i = o_pair.positions[[1, 2], 2].mean()
|
|
24
|
+
assert (o_pair.positions[[0, 3, 4], 2] > z_i + 0.3).all()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_load_any_g2_and_atom():
|
|
28
|
+
name_co, co, unp_co = load_molecule("CO")
|
|
29
|
+
assert len(co) == 2 and unp_co == 0
|
|
30
|
+
|
|
31
|
+
name_o, o_atom, unp_o = load_molecule("O")
|
|
32
|
+
assert len(o_atom) == 1 and unp_o == 2
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_bulk_cn_quartz_and_pt():
|
|
36
|
+
quartz = load_bulk_atoms("alpha-quartz")
|
|
37
|
+
cn_q = get_bulk_cn(quartz)
|
|
38
|
+
assert cn_q["Si"] == 4 and cn_q["O"] == 2
|
|
39
|
+
|
|
40
|
+
pt = load_bulk_atoms("Pt")
|
|
41
|
+
cn_pt = get_bulk_cn(pt)
|
|
42
|
+
assert cn_pt["Pt"] == 12
|