"""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()