"""AceFF-2: interactive machine-learning potential for small molecules. Enter a molecule (XYZ block, PDB, or SMILES), pick a net charge, and run a single-point calculation (energy + per-atom forces) and optionally an ASE LBFGS geometry relaxation with the AceFF-2 TensorNet-2 potential (https://huggingface.co/papers/2601.00581, Acellera, Apache-2.0). """ import json import os import time import traceback # Keep warp JIT kernels out of the home dir os.environ.setdefault("WARP_CACHE_DIR", "/tmp/warp_cache") import spaces # must be first (before any CUDA-touching import) import gradio as gr MODELS = { "AceFF-2.0 (H B C N O F Si P S Cl Br I)": ("Acellera/AceFF-2.0", "aceff_v2.0.ckpt"), "AceFF-2-OMOL (adds Na, K; OMol25 data)": ("Acellera/AceFF-2-OMOL", "aceff_v2_omol.ckpt"), } SUPPORTED_Z = {1, 5, 6, 7, 8, 9, 14, 15, 16, 17, 35, 53} # AceFF-2 elements SUPPORTED_Z_OMOL = SUPPORTED_Z | {11, 19} EV_TO_KCAL_MOL = 23.0605 MAX_ATOMS = 60 DEFAULT_MODEL = list(MODELS)[0] _state = {} # per-GPU-worker cache: models + ASE calculators def _sym_z(symbol): from ase.data import atomic_numbers return int(atomic_numbers.get(symbol.title(), 0)) def _get_model(model_key): """Load (and cache) an AceFF checkpoint on the GPU worker via load_model.""" if "model" in _state.get(model_key, {}): return _state[model_key]["model"] import torch from huggingface_hub import hf_hub_download from torchmdnet.models.model import load_model repo_id, filename = MODELS[model_key] path = hf_hub_download(repo_id=repo_id, filename=filename) model = load_model(path, derivative=False, remove_ref_energy=True) model.eval() for p in model.parameters(): p.requires_grad = False model.to("cuda") _state.setdefault(model_key, {})["model"] = model return model def _get_calculator(model_key): """Load (and cache) the TorchMD-Net ASE calculator on the GPU worker.""" calcs = _state.setdefault(model_key, {}) if "calc" in calcs: return calcs["calc"] from huggingface_hub import hf_hub_download from torchmdnet.calculators import TMDNETCalculator repo_id, filename = MODELS[model_key] calc = TMDNETCalculator( hf_hub_download(repo_id=repo_id, filename=filename), device="cuda" ) calcs["calc"] = calc return calc def _energy_forces(model, z, pos, q): """Single-point energy + forces: fresh graph, autograd.grad each call. The checkpoint is loaded with derivative=False so the model returns only the energy; forces come from torch.autograd.grad, which is repeatable across calls (a derivative=True model frees its graph on .backward()). """ import torch pos = pos.detach().clone().requires_grad_(True) energy, _ = model(z, pos, q=q) grad = torch.autograd.grad(energy, pos)[0] return float(energy.item()), (-grad).detach().cpu().numpy() def _parse_molecule(mol_text): """Parse XYZ / PDB / SMILES input into (symbols, positions Nx3 ndarray).""" import numpy as np from ase.io import read as ase_read raw = mol_text or "" if not raw.strip(): raise ValueError( "Please provide a molecule: paste an XYZ block or PDB, or type a SMILES string." ) nonempty = [l for l in raw.splitlines() if l.strip()] all_lines = raw.splitlines() is_pdb = any(l.startswith(("ATOM", "HETATM")) for l in nonempty) n_first = None try: n_first = int(all_lines[0].strip().split()[0]) except (ValueError, IndexError): pass counts_ok = False if n_first is not None and n_first > 0: coord_lines = [ l for l in all_lines[2:] if l.strip() and len(l.split()) >= 4 ] counts_ok = len(coord_lines) >= n_first and len(all_lines) >= n_first + 2 is_xyz = counts_ok if not is_xyz and not is_pdb: if len(nonempty) == 1 and len(nonempty[0]) < 120: return _smiles_to_atoms(nonempty[0]) raise ValueError( "Input must be an XYZ block (atom count on line 1, comment on line 2), " "a PDB, or a SMILES string on a single line." ) import tempfile with tempfile.NamedTemporaryFile( "w", suffix=".pdb" if is_pdb else ".xyz", delete=False ) as f: f.write(raw + "\n") path = f.name try: atoms = ase_read(path) finally: os.unlink(path) symbols = list(atoms.get_chemical_symbols()) pos = np.array(atoms.get_positions(), dtype=np.float32) if len(symbols) > 1 and float(pos.std()) < 1e-6: raise ValueError( "All atoms sit at the same position — is this a valid 3D structure?" ) return symbols, pos def _smiles_to_atoms(smiles): """Embed a 3D geometry from SMILES with RDKit (ETKDG).""" import numpy as np from rdkit import Chem from rdkit.Chem import AllChem mol = Chem.MolFromSmiles(smiles) if mol is None: raise ValueError(f"Could not parse input as SMILES: {smiles!r}") mol = Chem.AddHs(mol) status = AllChem.EmbedMultipleConfs( mol, numConfs=1, useRandomCoords=True, randomSeed=42 ) confs = list(status) if not confs: raise ValueError(f"RDKit could not generate a 3D geometry for: {smiles}") conf = mol.GetConformer(confs[0]) pos = np.array(conf.GetPositions(), dtype=np.float32) symbols = [a.GetSymbol() for a in mol.GetAtoms()] return symbols, pos def _xyz_block(symbols, pos, comment=""): import numpy as np lines = [str(len(symbols)), comment] for s, (x, y, z) in zip(symbols, np.asarray(pos)): lines.append(f"{s:<2s} {x:>12.6f} {y:>12.6f} {z:>12.6f}") return "\n".join(lines) def _viewer_html(title, xyz_in, xyz_out, arrows, arrow_scale_label): """Build the 3Dmol.js viewer as an escaped srcdoc iframe. Gradio injects gr.HTML values via innerHTML, and browsers refuse to run