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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """General-purpose AdsorbML screening on catalytic surfaces."""
2
+
3
+ __version__ = "0.1.0"
@@ -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,2 @@
1
+ [console_scripts]
2
+ adsorb-screen = adsorb_screen.cli:main
@@ -0,0 +1,16 @@
1
+ numpy>=1.23
2
+ pandas>=1.5
3
+ ase>=3.22
4
+ fairchem-core>=2.0.0
5
+ fairchem-data-oc>=0.1.0
6
+
7
+ [dev]
8
+ pytest>=7.0
9
+ ruff>=0.3.0
10
+
11
+ [mp]
12
+ mp-api>=0.39.0
13
+ pymatgen>=2023.10.0
14
+
15
+ [smiles]
16
+ rdkit>=2023.3.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