densitygen-ald / src /densitygen /structures.py
yushg's picture
Deploy DensityGen web app + DC viz
f350bd5 verified
Raw
History Blame Contribute Delete
10 kB
"""ASE structure bridge: turn a precursor identity into 3D atoms that UMA can
evaluate, and build slab + adsorbate systems for adsorption energies.
Key design point: UMA relaxes geometries (LBFGS) before reporting an energy, so
we do NOT need accurate starting coordinates -- only a sensible, non-overlapping
guess. That lets us build metal-halide precursors (WF6, TiCl4, MoF6, ...) from
pure coordination geometry without RDKit. Polyatomic-ligand organometallics
(TMA, TEMAH) that we can't yet place return None, and the screener transparently
falls back to descriptors for those.
All ASE imports are local so the rest of the package keeps working without ASE.
"""
from __future__ import annotations
import math
from typing import Optional
from densitygen.chem import HALOGENS, Composition
# Approximate metal-ligand bond lengths (Angstrom). Coarse on purpose --
# relaxation moves them to the true minimum; these only need to avoid overlap.
_BOND = {"F": 1.83, "Cl": 2.27, "Br": 2.45, "I": 2.70, "O": 1.95, "N": 1.95,
"C": 2.10, "H": 1.70}
# Unit-vector vertex sets for each coordination number -> molecular geometry.
_GEOMETRY = {
1: [(0, 0, 1)],
2: [(0, 0, 1), (0, 0, -1)],
3: [(1, 0, 0), (-0.5, 0.8660, 0), (-0.5, -0.8660, 0)], # trigonal planar
4: [(0.5774, 0.5774, 0.5774), (0.5774, -0.5774, -0.5774), # tetrahedral
(-0.5774, 0.5774, -0.5774), (-0.5774, -0.5774, 0.5774)],
5: [(0, 0, 1), (0, 0, -1), (1, 0, 0), # trigonal bipyramidal
(-0.5, 0.8660, 0), (-0.5, -0.8660, 0)],
6: [(1, 0, 0), (-1, 0, 0), (0, 1, 0), # octahedral
(0, -1, 0), (0, 0, 1), (0, 0, -1)],
}
def is_simple_metal_halide(comp: Composition) -> Optional[tuple[str, str, int]]:
"""If the composition is a single metal + single halogen species (MX_n),
return (metal, halogen, n). Else None."""
metals = [el for el in comp.counts if el not in HALOGENS and el != "H"]
halos = list(comp.halogens.keys())
if len(metals) == 1 and len(halos) == 1 and comp.count("H") == 0 \
and comp.count("C") == 0 and comp.count("O") == 0 and comp.count("N") == 0:
n = int(comp.count(halos[0]))
if n in _GEOMETRY:
return metals[0], halos[0], n
return None
# Light (ligand) elements; anything else in a precursor is the metal center.
_LIGHT = {"H", "C", "N", "O", "F", "Cl", "Br", "I"}
# Homoleptic ligand fragments we can place: per-ligand element counts + the atom
# that bonds the metal (carbonyls bond through C, amides through N, alkoxides
# through O, alkyls through C). Matched against M(L)_k stoichiometry.
_LIGAND_SIG: dict[str, tuple[dict, str]] = {
"CO": ({"C": 1, "O": 1}, "C"), # carbonyl -> W(CO)6, Mo(CO)6, Ni(CO)4
"CH3": ({"C": 1, "H": 3}, "C"), # methyl -> TMA Al(CH3)3
"C2H5": ({"C": 2, "H": 5}, "C"), # ethyl
"C5H5": ({"C": 5, "H": 5}, "C"), # cyclopentadienyl
"NMe2": ({"N": 1, "C": 2, "H": 6}, "N"), # dimethylamide -> TDMAH/TDMAT
"NEtMe": ({"N": 1, "C": 3, "H": 8}, "N"), # ethylmethylamide -> TEMAH
"OEt": ({"O": 1, "C": 2, "H": 5}, "O"), # ethoxide
"OtBu": ({"O": 1, "C": 4, "H": 9}, "O"), # tert-butoxide -> Hf(OtBu)4
"H": ({"H": 1}, "H"), # hydride
}
def _decompose_homoleptic(comp: Composition):
"""If `comp` is one metal + k identical ligands (M(L)_k) we can place,
return (metal, k, ligand_key, coord_element). Else None."""
counts = {e: int(round(n)) for e, n in comp.counts.items() if n}
metals = [e for e in counts if e not in _LIGHT]
if len(metals) != 1 or counts[metals[0]] != 1:
return None
metal = metals[0]
rem = {e: counts[e] for e in counts if e != metal}
if not rem:
return None
for key, (sig, coord) in _LIGAND_SIG.items():
if set(rem) != set(sig):
continue
k = None
for e, per in sig.items():
if rem[e] % per:
k = None
break
kk = rem[e] // per
k = kk if k is None else (k if k == kk else -1)
if k and k > 0 and k in _GEOMETRY:
return metal, k, key, coord
return None
def _frame(u):
"""An orthonormal pair perpendicular to unit vector u."""
ax = (0.0, 0.0, 1.0) if abs(u[2]) < 0.9 else (1.0, 0.0, 0.0)
e1 = (u[1] * ax[2] - u[2] * ax[1], u[2] * ax[0] - u[0] * ax[2], u[0] * ax[1] - u[1] * ax[0])
n = math.sqrt(sum(c * c for c in e1)) or 1.0
e1 = tuple(c / n for c in e1)
e2 = (u[1] * e1[2] - u[2] * e1[1], u[2] * e1[0] - u[0] * e1[2], u[0] * e1[1] - u[1] * e1[0])
return e1, e2
def _place_ligand(coord_sym, others, P, u):
"""Place one ligand: coordinating atom at P, the rest fanned outward along u.
Coordinates are deliberately coarse but non-overlapping -- UMA/CHGNet relax
(LBFGS) before reporting an energy, so a sane connected guess is enough.
"""
e1, e2 = _frame(u)
atoms = [(coord_sym, P)]
heavies = [s for s in others if s != "H"]
hs = [s for s in others if s == "H"]
# Linear special-case: a lone heavy partner (CO) sits straight out (M-C-O).
if heavies == ["O"] and coord_sym == "C" and not hs:
atoms.append(("O", tuple(P[i] + u[i] * 1.15 for i in range(3))))
return atoms
centers = []
base = tuple(P[i] + u[i] * 1.5 for i in range(3))
for i, s in enumerate(heavies):
ang = 2 * math.pi * i / max(1, len(heavies))
off = [(e1[j] * math.cos(ang) + e2[j] * math.sin(ang)) * 0.75 + u[j] * 0.35 * i for j in range(3)]
pos = tuple(base[j] + off[j] for j in range(3))
atoms.append((s, pos)); centers.append(pos)
if not centers:
centers = [tuple(P[i] + u[i] * 1.0 for i in range(3))]
for j, s in enumerate(hs):
c = centers[j % len(centers)]
ang = 2 * math.pi * j / max(1, len(hs)) + 0.6
off = [(e1[k] * math.cos(ang) + e2[k] * math.sin(ang)) * 0.95 + u[k] * 0.45 for k in range(3)]
atoms.append((s, tuple(c[k] + off[k] for k in range(3))))
return atoms
def build_molecule(name: str, comp: Composition):
"""Return an ase.Atoms for a precursor, or None if we can't place it.
Strategy: (1) ASE's built-in molecule database by name (H2O, NH3, CO, CH4,
...); (2) metal halides MX_n from a coordination polyhedron; (3) homoleptic
organometallics M(L)_k (carbonyls, alkyls, amides, alkoxides); (4) give up.
"""
try:
from ase import Atoms
from ase.build import molecule as ase_molecule
except Exception:
return None
# (1) Known small molecules straight from ASE's G2 set.
try:
return ase_molecule(name)
except Exception:
pass
# (2) Metal halide MX_n from a coordination polyhedron.
mh = is_simple_metal_halide(comp)
if mh is not None:
metal, halo, n = mh
d = _BOND.get(halo, 2.0)
symbols = [metal]
positions = [(0.0, 0.0, 0.0)]
for vx, vy, vz in _GEOMETRY[n]:
norm = math.sqrt(vx * vx + vy * vy + vz * vz) or 1.0
positions.append((vx / norm * d, vy / norm * d, vz / norm * d))
symbols.append(halo)
return Atoms(symbols=symbols, positions=positions)
# (3) Homoleptic organometallic M(L)_k -- carbonyls, alkyls, amides, alkoxides.
homo = _decompose_homoleptic(comp)
if homo is not None:
metal, k, key, coord = homo
d = _BOND.get(coord, 2.05)
symbols = [metal]
positions = [(0.0, 0.0, 0.0)]
sig = _LIGAND_SIG[key][0]
# the ligand's atoms minus its coordinating atom, as a flat element list
others = []
for el, cnt in sig.items():
others += [el] * (cnt - (1 if el == coord else 0))
for vx, vy, vz in _GEOMETRY[k]:
norm = math.sqrt(vx * vx + vy * vy + vz * vz) or 1.0
u = (vx / norm, vy / norm, vz / norm)
P = (u[0] * d, u[1] * d, u[2] * d)
for sym, pos in _place_ligand(coord, others, P, u):
symbols.append(sym); positions.append(pos)
return Atoms(symbols=symbols, positions=positions)
return None
# Crystal structure of the elemental metals we build slabs for, so we pick the
# right ASE surface constructor.
_LATTICE = {"W": "bcc", "Mo": "bcc", "Ta": "bcc", "Al": "fcc", "Cu": "fcc",
"Ni": "fcc", "Ru": "hcp", "Ti": "hcp", "Pt": "fcc", "Ir": "fcc"}
_A0 = {"W": 3.16, "Mo": 3.15, "Ta": 3.30, "Al": 4.05, "Cu": 3.61, "Ni": 3.52,
"Pt": 3.92, "Ir": 3.84}
def build_metal_slab(element: str, size=(3, 3, 3), vacuum: float = 8.0):
"""Build a relaxable metal slab for the film element, or None if unknown."""
try:
from ase.build import bcc110, fcc111, hcp0001
except Exception:
return None
lat = _LATTICE.get(element)
if lat == "bcc":
return bcc110(element, size=size, vacuum=vacuum, a=_A0.get(element))
if lat == "fcc":
return fcc111(element, size=size, vacuum=vacuum, a=_A0.get(element))
if lat == "hcp":
return hcp0001(element, size=size, vacuum=vacuum)
return None
def build_adsorption_system(slab, molecule, height: float = 2.2):
"""Place `molecule` above the slab. Returns a new combined Atoms."""
from ase.build import add_adsorbate
sys = slab.copy()
# REASON: add_adsorbate attaches by an atom index; index 0 is the metal
# center for our halides, so the metal points at the surface (the reactive
# atom), which is the chemically meaningful adsorption geometry.
add_adsorbate(sys, molecule.copy(), height=height, position=(
slab.cell[0][0] / 2, slab.cell[1][1] / 2), mol_index=0)
return sys
def to_extxyz(atoms) -> str:
"""Serialize ase.Atoms to an extxyz string (the wire format for the
Replicate UMA backend)."""
from io import StringIO
from ase.io import write
buf = StringIO()
write(buf, atoms, format="extxyz")
return buf.getvalue()