#!/usr/bin/env python3 import argparse import os import re import shutil import subprocess from pathlib import Path import pandas as pd from rdkit import Chem from rdkit.Chem import AllChem ENERGY_RE = re.compile( r"Estimated Free Energy of Binding\s*=\s*([-+]?\d+(?:\.\d+)?)\s*kcal/mol", re.IGNORECASE, ) def parse_best_binding_energy(dlg_path: Path): if not dlg_path.exists(): return None text = dlg_path.read_text(errors="ignore") values = [float(x) for x in ENERGY_RE.findall(text)] if not values: return None # More negative is better. return min(values) def make_3d_sdf(smiles: str, sdf_path: Path, seed: int = 42): mol = Chem.MolFromSmiles(str(smiles)) if mol is None: raise ValueError(f"Invalid SMILES: {smiles}") mol = Chem.AddHs(mol) params = AllChem.ETKDGv3() params.randomSeed = seed status = AllChem.EmbedMolecule(mol, params) if status != 0: raise RuntimeError(f"3D embedding failed for SMILES: {smiles}") try: AllChem.UFFOptimizeMolecule(mol, maxIters=500) except Exception: pass sdf_path.parent.mkdir(parents=True, exist_ok=True) writer = Chem.SDWriter(str(sdf_path)) writer.write(mol) writer.close() def run_cmd(cmd, cwd=None): return subprocess.run( cmd, cwd=str(cwd) if cwd else None, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False, ) def dock_one(smiles, ligand_id, work_dir, adgpu_bin, grid_file, nrun): work_dir.mkdir(parents=True, exist_ok=True) sdf_path = work_dir / f"{ligand_id}.sdf" pdbqt_path = work_dir / f"{ligand_id}.pdbqt" make_3d_sdf(smiles, sdf_path) mk_prepare = shutil.which("mk_prepare_ligand.py") if mk_prepare is None: raise RuntimeError("mk_prepare_ligand.py was not found in PATH.") prep = run_cmd([ mk_prepare, "-i", str(sdf_path), "-o", str(pdbqt_path), ]) if prep.returncode != 0 or not pdbqt_path.exists(): raise RuntimeError(f"Meeko ligand preparation failed:\n{prep.stdout}") dock = run_cmd([ adgpu_bin, "--ffile", str(grid_file.resolve()), "--lfile", str(pdbqt_path.resolve()), "--nrun", str(nrun), ], cwd=work_dir) if dock.returncode != 0: raise RuntimeError(f"AutoDock-GPU failed:\n{dock.stdout}") candidates = sorted(work_dir.glob("*.dlg")) if not candidates: raise RuntimeError(f"No DLG file was produced in {work_dir}") dlg_path = candidates[0] score = parse_best_binding_energy(dlg_path) if score is None: raise RuntimeError(f"Could not parse binding energy from {dlg_path}") xml_candidates = sorted(work_dir.glob("*.xml")) result_file = xml_candidates[0] if xml_candidates else dlg_path return score, str(result_file) def main(): parser = argparse.ArgumentParser() parser.add_argument("--input", required=True, help="Input enriched CSV.") parser.add_argument("--output", required=True, help="Output CSV with docking columns.") parser.add_argument("--docking-mode", choices=["off", "top_k", "all"], default="off") parser.add_argument("--dock-top-k", type=int, default=10) parser.add_argument("--nrun", type=int, default=8) parser.add_argument("--adgpu-bin", default=os.environ.get("ADGPU_BIN")) parser.add_argument("--grid-file", default="docking/maps_current/4WKQ_receptor_v5_SBr.maps.fld") parser.add_argument("--work-dir", default="outputs/docking") args = parser.parse_args() df = pd.read_csv(args.input) # Ensure stable dtypes for docking columns. if "docking_score" not in df.columns: df["docking_score"] = None if "docking_status" not in df.columns: df["docking_status"] = "not_run" if "docking_pose_file" not in df.columns: df["docking_pose_file"] = None df["docking_status"] = df["docking_status"].astype("object") df["docking_pose_file"] = df["docking_pose_file"].astype("object") if args.docking_mode == "off": df["docking_score"] = None df["docking_status"] = "not_run" df["docking_pose_file"] = None Path(args.output).parent.mkdir(parents=True, exist_ok=True) df.to_csv(args.output, index=False) print(f"Docking mode off. Wrote: {args.output}") return if not args.adgpu_bin: raise RuntimeError("Set ADGPU_BIN or pass --adgpu-bin.") adgpu_bin = Path(args.adgpu_bin) if not adgpu_bin.exists(): raise FileNotFoundError(f"AutoDock-GPU binary not found: {adgpu_bin}") grid_file = Path(args.grid_file) if not grid_file.exists(): raise FileNotFoundError(f"Grid file not found: {grid_file}") if "canonical_smiles" not in df.columns: raise ValueError("Input CSV must contain canonical_smiles column.") if args.docking_mode == "all": indices = list(df.index) else: indices = list(df.index[: args.dock_top_k]) work_root = Path(args.work_dir) work_root.mkdir(parents=True, exist_ok=True) for count, idx in enumerate(indices, start=1): smiles = df.at[idx, "canonical_smiles"] ligand_id = f"ligand_{count:04d}" try: score, result_file = dock_one( smiles=smiles, ligand_id=ligand_id, work_dir=work_root / ligand_id, adgpu_bin=str(adgpu_bin), grid_file=grid_file, nrun=args.nrun, ) df.at[idx, "docking_score"] = score df.at[idx, "docking_status"] = "completed" df.at[idx, "docking_pose_file"] = result_file print(f"[OK] {ligand_id}: {score:.2f} kcal/mol") except Exception as e: df.at[idx, "docking_score"] = None df.at[idx, "docking_status"] = f"failed: {str(e)[:160]}" df.at[idx, "docking_pose_file"] = None print(f"[FAILED] {ligand_id}: {e}") Path(args.output).parent.mkdir(parents=True, exist_ok=True) df.to_csv(args.output, index=False) print(f"Wrote: {args.output}") if __name__ == "__main__": main()