Source code for molfoundry.conformer
import os
import tempfile
from io import StringIO
from typing import Optional, Dict, Tuple
from .exceptions import *
from .graph import Graph
[docs]
class Conformer:
def __init__(self, graph: Graph) -> None:
if not graph.isMolecule:
raise LogicError("Trying to create conformer from non-molecule graph.")
self._graph = graph
self._embedded = False
self._vertex_coords: Dict[int, Tuple[float, float, float]] = {
v.id: (0, 0, 0)
for v in graph.vertices
}
# noinspection PyShadowingBuiltins
[docs]
def getVertexCoordinate(self, id: int) -> Tuple[float, float, float]:
return self._vertex_coords.get(id, (0, 0, 0))
[docs]
def embedRDKit(self, seed: int = 42, num_confs: int = 50) -> None:
try:
# noinspection PyUnresolvedReferences
from rdkit import Chem
# noinspection PyUnresolvedReferences
from rdkit.Chem import AllChem
except ImportError:
raise LogicError("Failed to import RDKit. Please make sure to install RDKit.")
mol = Chem.MolFromSmiles(self._graph.smilesWithIds, sanitize=False)
if mol is None:
raise LogicError("RDKit could not parse graph.")
Chem.SanitizeMol(mol)
# Ensure no conformers are present
mol.RemoveAllConformers()
params = AllChem.ETKDGv3()
params.randomSeed = seed
params.pruneRmsThresh = 0.2
params.useRandomCoords = True
params.useExpTorsionAnglePrefs = True
params.useBasicKnowledge = True
cids = AllChem.EmbedMultipleConfs(mol, numConfs=num_confs, params=params)
if not cids:
cid = AllChem.EmbedMolecule(mol, useRandomCoords=True, randomSeed=seed)
if cid < 0:
raise LogicError("Embedding failed.")
cids = [cid]
energies = []
if AllChem.MMFFHasAllMoleculeParams(mol):
props = AllChem.MMFFGetMoleculeProperties(mol, mmffVariant="MMFF94s")
for cid in cids:
ff = AllChem.MMFFGetMoleculeForceField(mol, props, confId=cid)
ff.Minimize(maxIts=5000)
energies.append((cid, ff.CalcEnergy()))
else:
for cid in cids:
ff = AllChem.UFFGetMoleculeForceField(mol, confId=cid)
ff.Minimize(maxIts=5000)
energies.append((cid, ff.CalcEnergy()))
best_cid, best_e = min(energies, key=lambda x: x[1])
# Keep best
for cid in sorted([c for c in cids if c != best_cid], reverse=True):
mol.RemoveConformer(cid)
best_conformer = mol.GetConformer()
atom_id_index_map = {}
for atom in mol.GetAtoms():
if atom.HasProp("molAtomMapNumber"):
aid = int(atom.GetProp("molAtomMapNumber"))
else:
raise LogicError(f"Atom without molAtomMapNumber: idx={atom.GetIdx()} symbol={atom.GetSymbol()}")
if aid in atom_id_index_map:
raise LogicError(f"Duplicate atom map number {aid}")
atom_id_index_map[aid] = atom.GetIdx()
n = mol.GetNumAtoms()
expected = set(range(n))
got = set(atom_id_index_map.keys())
if got != expected:
raise LogicError(f"AtomMap IDs mismatch. expected={sorted(expected)} got={sorted(got)}")
for x in sorted(atom_id_index_map.keys()):
idx = atom_id_index_map[x]
coord = best_conformer.GetAtomPosition(idx)
self._vertex_coords[x] = (coord.x, coord.y, coord.z)
self._embedded = True
[docs]
def optimizeXTB(self, fmax=0.1, steps=50):
"""
Local geometry optimization using BFGS
fmax – Energy barrier (eV/Å)
steps – max BFGS steps
"""
if not self._embedded:
raise LogicError("Trying to optimize conformer without prior embedding. Call embed*(...) first.")
try:
# noinspection PyUnresolvedReferences
from ase.io import read
# noinspection PyUnresolvedReferences
from ase.optimize import BFGS
# noinspection PyUnresolvedReferences
from tblite.ase import TBLite
from contextlib import redirect_stdout, redirect_stderr
except ImportError:
raise LogicError("Failed to import ASE or TBLite. Please make sure to install ASE and TBLite.")
atoms = read(StringIO(self.xyz()))
atoms = atoms.copy()
atoms.calc = TBLite(method="GFN2-xTB")
opt = BFGS(atoms, trajectory=f"{tempfile.gettempdir()}/__mf__conf__opt.traj",
logfile=f"{tempfile.gettempdir()}/__mf__conf__opt.log")
try:
# Prevent log spamming from TBLite
with open(os.devnull, "w") as fnull, redirect_stdout(fnull), redirect_stderr(fnull):
opt.run(fmax=fmax, steps=steps)
except CalculationFailed as e:
raise LogicError(f"Failed to optimize embedding: {e}")
for i, atom in enumerate(atoms):
x, y, z = atom.position
self._vertex_coords[i] = (x, y, z)
[docs]
def xyz(self, comment: Optional[str] = None) -> str:
if not self._embedded:
print("Warning: Generating conformer xyz without prior embedding. Coordinates will all be zero.")
result = f"{self._graph.numVertices}\n"
result += (comment or "Generated by MolFoundry").rstrip("\n\r") + "\n"
vertices = self._graph.vertices
for _id in sorted(self._vertex_coords.keys()):
coord = self._vertex_coords[_id]
symbol = vertices[_id].atomId.symbol
result += f"{symbol:<2} {coord[0]: .6f} {coord[1]: .6f} {coord[2]: .6f}\n"
return result