File size: 16,752 Bytes
fe05da9
 
8f9cafc
fb8c550
8f9cafc
fe05da9
fb8c550
fe05da9
 
8f9cafc
fe05da9
 
 
0c3a0da
 
 
 
 
 
8f9cafc
fe05da9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f9cafc
fe05da9
 
 
 
8f9cafc
fe05da9
 
 
48bf0d7
9125057
0c3a0da
dade2b6
 
 
 
 
fe05da9
 
 
48bf0d7
fe05da9
 
 
 
 
48bf0d7
fe05da9
 
32957ff
 
0c3a0da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f9cafc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fe05da9
 
 
 
 
 
 
 
 
 
 
 
31d3bd8
fe05da9
8be46a8
fe05da9
fb8c550
a6857c7
fe05da9
8f9cafc
fe05da9
640d93b
fe05da9
 
0c3a0da
fe05da9
 
 
8f9cafc
fe05da9
 
 
 
640d93b
fe05da9
 
 
 
 
 
 
 
 
 
0c3a0da
fe05da9
 
 
 
 
8f9cafc
 
 
fe05da9
9125057
8f9cafc
9125057
 
 
 
 
 
 
 
 
 
0c3a0da
9125057
 
8f9cafc
9125057
 
 
 
 
 
 
 
 
 
 
 
 
 
033c1d2
 
 
 
 
 
 
 
 
 
 
 
 
9125057
0c3a0da
9125057
 
 
 
 
 
 
8f9cafc
 
 
 
4320faf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fe05da9
 
 
 
 
 
 
 
8f9cafc
fe05da9
 
 
 
 
 
 
a6857c7
fe05da9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a6857c7
fe05da9
 
 
 
 
 
 
 
 
8f9cafc
4320faf
8f9cafc
 
 
 
 
 
fe05da9
 
8f9cafc
 
 
 
4320faf
 
8f9cafc
fe05da9
 
8f9cafc
 
 
4320faf
 
 
 
 
 
 
8f9cafc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5bb077e
 
 
 
 
 
4320faf
8f9cafc
 
 
5bb077e
4320faf
 
 
8f9cafc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
from __future__ import annotations

import math
import os
import re
import subprocess
import tempfile
import urllib.request
from pathlib import Path
from typing import Optional

# AutoDock Vina binary location
_VINA_BINARY: str | None = None
_IS_WINDOWS = os.name == "nt"
if _IS_WINDOWS:
    _VINA_URL = "https://github.com/ccsb-scripps/AutoDock-Vina/releases/download/v1.2.7/vina_1.2.7_win.exe"
else:
    _VINA_URL = "https://github.com/ccsb-scripps/AutoDock-Vina/releases/download/v1.2.7/vina_1.2.7_linux_x86_64"
_EXE_NAME = "vina.exe" if _IS_WINDOWS else "vina"
_VINA_SHA256 = ""


def _verify_checksum(path: Path) -> None:
    if not _VINA_SHA256:
        return
    import hashlib

    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(8192), b""):
            h.update(chunk)
    digest = h.hexdigest()
    if digest != _VINA_SHA256:
        path.unlink(missing_ok=True)
        raise RuntimeError(
            f"Vina binary checksum mismatch (got {digest}, expected {_VINA_SHA256})."
        )


def _ensure_vina() -> str:
    """Locate the AutoDock Vina binary."""
    global _VINA_BINARY
    if _VINA_BINARY and os.path.isfile(_VINA_BINARY):
        return _VINA_BINARY

    import shutil

    for candidate in ["/usr/local/bin/vina", shutil.which("vina") or ""]:
        if candidate and os.path.isfile(candidate):
            _VINA_BINARY = candidate
            return _VINA_BINARY

    bin_dir = Path(tempfile.gettempdir()) / "vina_bin"
    bin_dir.mkdir(exist_ok=True)
    exe_path = bin_dir / _EXE_NAME

    if not exe_path.is_file():
        print(f"[docking] Downloading AutoDock Vina from {_VINA_URL} ...")
        urllib.request.urlretrieve(_VINA_URL, str(exe_path))
        _verify_checksum(exe_path)
        os.chmod(str(exe_path), 0o755)

    _VINA_BINARY = str(exe_path)
    return _VINA_BINARY


_OBABEL_BINARY: str | None = None


def _ensure_obabel() -> str:
    """Locate the Open Babel (`obabel`) binary.

    Checks PATH first (Linux/Docker), then the current Python interpreter's
    directory (Windows venvs keep `obabel.exe` in ``Scripts/`` next to
    ``python.exe``, which is not on PATH when uvicorn is launched via the
    interpreter directly).
    """
    global _OBABEL_BINARY
    if _OBABEL_BINARY and os.path.isfile(_OBABEL_BINARY):
        return _OBABEL_BINARY

    import shutil
    import sys

    candidates: list[str] = []
    which = shutil.which("obabel")
    if which:
        candidates.append(which)
    exe_dir = Path(sys.executable).resolve().parent
    for name in ("obabel.exe", "obabel", "obabel.bat"):
        candidates.append(str(exe_dir / name))

    for candidate in candidates:
        if candidate and os.path.isfile(candidate):
            _OBABEL_BINARY = candidate
            return _OBABEL_BINARY

    raise RuntimeError(
        "Open Babel (`obabel`) is not installed. "
        "Install it (e.g. `pip install openbabel-wheel` on Windows, or "
        "`RUN apt-get update && apt-get install -y openbabel` in the Dockerfile)."
    )


# ---------------------------------------------------------------------------
# PDB fetching
# ---------------------------------------------------------------------------

def fetch_pdb_from_rcsb(pdb_id: str) -> str:
    """Download a PDB file from RCSB by 4-character PDB ID."""
    pdb_id = pdb_id.strip().upper()
    if len(pdb_id) != 4:
        raise ValueError(f"Invalid PDB ID: {pdb_id!r}")
    url = f"https://files.rcsb.org/download/{pdb_id}.pdb"
    try:
        data = urllib.request.urlopen(url, timeout=30).read().decode("utf-8", errors="replace")
    except Exception as e:
        raise RuntimeError(f"Failed to fetch PDB {pdb_id} from RCSB: {e}")
    if "ATOM" not in data and "HETATM" not in data:
        raise RuntimeError(f"PDB {pdb_id} from RCSB contains no coordinate data")
    return data


# ---------------------------------------------------------------------------
# Grid center computation
# ---------------------------------------------------------------------------

_ATOM_RE = re.compile(
    r"^(ATOM|HETATM)\s+\d+\s+\S+\s+(\S)\s+(\d+)\s+"
    r"([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)"
)


def compute_grid_center(pdb_text: str) -> list[float]:
    """Compute the geometric centre of all ATOM (non-ligand) records."""
    xs, ys, zs = [], [], []
    for line in pdb_text.splitlines():
        if line.startswith("ATOM"):
            m = _ATOM_RE.match(line)
            if m:
                xs.append(float(m.group(4)))
                ys.append(float(m.group(5)))
                zs.append(float(m.group(6)))
    if not xs:
        return [0.0, 0.0, 0.0]
    return [sum(xs) / len(xs), sum(ys) / len(ys), sum(zs) / len(zs)]


# ---------------------------------------------------------------------------
# Ligand prep (SMILES -> PDBQT via NCI CACTUS + Open Babel)
# ---------------------------------------------------------------------------

def smiles_to_pdbqt(smiles: str) -> str:
    """Convert SMILES to PDBQT via NCI CACTUS (3D SDF) + Open Babel."""
    try:
        url = f"https://cactus.nci.nih.gov/chemical/structure/{smiles}/file?format=sdf&get3d=true"
        sdf_bytes = urllib.request.urlopen(url, timeout=30).read()
    except Exception as e:
        raise RuntimeError(f"Failed to get 3D structure from CACTUS: {e}")

    with tempfile.NamedTemporaryFile(suffix=".sdf", delete=False, mode="wb") as f:
        f.write(sdf_bytes)
        sdf_path = f.name

    try:
        return _sdf_to_pdbqt(sdf_path)
    finally:
        os.unlink(sdf_path)


def _sdf_to_pdbqt(sdf_path: str) -> str:
    """Convert SDF to PDBQT using Open Babel."""
    pdbqt_path = sdf_path.rsplit(".", 1)[0] + ".pdbqt"
    try:
        result = subprocess.run(
            [
                _ensure_obabel(),
                sdf_path,
                "-O", pdbqt_path,
                "--partialcharge", "gasteiger",
                "-p", "7.4",
            ],
            capture_output=True,
            text=True,
            timeout=60,
        )
        if result.returncode != 0:
            raise RuntimeError(f"Open Babel ligand conversion failed: {result.stderr[:1000]}")
        if not os.path.isfile(pdbqt_path):
            raise RuntimeError("Open Babel did not produce a PDBQT output file")
        with open(pdbqt_path, "r") as f:
            content = f.read()
        if not content.strip():
            raise RuntimeError("PDBQT conversion produced empty output")
        return content
    except FileNotFoundError:
        raise RuntimeError(_ensure_obabel())
    finally:
        if os.path.isfile(pdbqt_path):
            os.unlink(pdbqt_path)


# ---------------------------------------------------------------------------
# Receptor prep (PDB -> PDBQT rigid receptor)
# ---------------------------------------------------------------------------

def pdb_to_pdbqt_receptor(pdb_text: str) -> str:
    """Convert a plain PDB receptor to PDBQT (rigid, for Vina)."""
    in_path = None
    out_path = None
    try:
        with tempfile.NamedTemporaryFile(suffix=".pdb", delete=False, mode="w") as f:
            f.write(pdb_text)
            in_path = f.name
        out_path = in_path.rsplit(".", 1)[0] + ".pdbqt"

        result = subprocess.run(
            [
                _ensure_obabel(),
                in_path,
                "-O", out_path,
                "-xr",
                "--partialcharge", "gasteiger",
            ],
            capture_output=True,
            text=True,
            timeout=120,
        )
        if result.returncode != 0:
            raise RuntimeError(f"Open Babel receptor conversion failed: {result.stderr[:1000]}")
        if not os.path.isfile(out_path):
            raise RuntimeError("Open Babel did not produce a receptor PDBQT output file")
        with open(out_path, "r") as f:
            content = f.read()
        if not content.strip():
            raise RuntimeError("Receptor PDBQT conversion produced empty output")

        # Flatten to a single rigid model. Open Babel wraps each chain of a
        # multi-chain/NMR structure in MODEL/ENDMDL blocks, and Vina rejects
        # multi-model rigid receptors ("Unexpected multi-MODEL tag found in
        # rigid receptor"). Dropping the block markers keeps every atom as one
        # receptor. TORSDOF/ROOT/BRANCH markers are dropped too so the output
        # is always a plain rigid receptor.
        flattened = "\n".join(
            l for l in content.splitlines()
            if not l.startswith(("MODEL", "ENDMDL", "ROOT", "ENDROOT",
                                 "BRANCH", "ENDBRANCH", "TORSDOF"))
        )
        return flattened
    except FileNotFoundError:
        raise RuntimeError(_ensure_obabel())
    finally:
        if in_path and os.path.isfile(in_path):
            os.unlink(in_path)
        if out_path and os.path.isfile(out_path):
            os.unlink(out_path)


# ---------------------------------------------------------------------------
# Vina execution + multi-pose parsing
# ---------------------------------------------------------------------------

_VINA_MODE_RE = re.compile(r"^\s*(\d+)\s+([-\d.eE+]+)\s+([-\d.eE+]+)\s+([-\d.eE+]+)\s*$")
_GRID_CENTER_RE = re.compile(r"Grid center:\s*X\s+(-?[\d.]+)\s+Y\s+(-?[\d.]+)\s+Z\s+(-?[\d.]+)")
_GRID_SIZE_RE = re.compile(r"Grid size\s*:\s*X\s+(-?[\d.]+)\s+Y\s+(-?[\d.]+)\s+Z\s+(-?[\d.]+)")


def parse_vina_log(vina_log: str) -> dict:
    """Parse AutoDock Vina 1.2.x stdout into structured metadata + mode table.

    Handles the literal Vina 1.2.7 header layout:

        AutoDock Vina v1.2.7
        Grid center: X 2 Y 2 Z 2
        Grid size  : X 20 Y 20 Z 20
        Exhaustiveness: 8
        Performing docking (random seed: 1431381492) ...
        mode |   affinity | dist from best mode
             | (kcal/mol) | rmsd l.b.| rmsd u.b.
        -----+------------+----------+----------
           1            0          0          0
           2            0      6.008      8.028
    """
    version = ""
    grid_center: list[float] = []
    grid_size: list[float] = []
    exhaustiveness: int | None = None
    random_seed: int | None = None
    modes: list[dict] = []

    in_table = False
    for line in vina_log.splitlines():
        if not version:
            m = re.match(r"AutoDock Vina v([0-9.]+)", line)
            if m:
                version = m.group(1)

        m = _GRID_CENTER_RE.match(line)
        if m:
            grid_center = [float(m.group(1)), float(m.group(2)), float(m.group(3))]
            continue
        m = _GRID_SIZE_RE.match(line)
        if m:
            grid_size = [float(m.group(1)), float(m.group(2)), float(m.group(3))]
            continue
        m = re.match(r"Exhaustiveness:\s+(\d+)", line)
        if m:
            exhaustiveness = int(m.group(1))
            continue
        m = re.search(r"random seed:\s+(-?\d+)", line)
        if m:
            random_seed = int(m.group(1))
            continue

        if re.match(r"^\s*-{5,}", line):
            in_table = True
            continue
        if in_table:
            m = _VINA_MODE_RE.match(line)
            if m:
                modes.append({
                    "model": int(m.group(1)),
                    "affinity": float(m.group(2)),
                    "rmsd_lb": float(m.group(3)),
                    "rmsd_ub": float(m.group(4)),
                })
            else:
                in_table = False

    return {
        "vina_version": version,
        "grid_center": grid_center,
        "grid_size": grid_size,
        "exhaustiveness": exhaustiveness,
        "random_seed": random_seed,
        "modes": modes,
    }


def run_vina(
    protein_pdbqt: str | bytes,
    ligand_pdbqt: str,
    grid_center: list[float] = [0, 0, 0],
    grid_size: list[float] = [20, 20, 20],
    exhaustiveness: int = 8,
    num_modes: int = 9,
) -> dict:
    """Run AutoDock Vina and return parsed multi-pose results."""
    vina_bin = _ensure_vina()

    with tempfile.TemporaryDirectory() as tmp:
        prot_path = os.path.join(tmp, "protein.pdbqt")
        if isinstance(protein_pdbqt, bytes):
            with open(prot_path, "wb") as f:
                f.write(protein_pdbqt)
        else:
            with open(prot_path, "w") as f:
                f.write(protein_pdbqt)

        lig_path = os.path.join(tmp, "ligand.pdbqt")
        with open(lig_path, "w") as f:
            f.write(ligand_pdbqt)

        out_path = os.path.join(tmp, "output.pdbqt")

        cmd = [
            vina_bin,
            "--receptor", prot_path,
            "--ligand", lig_path,
            "--center_x", str(grid_center[0]),
            "--center_y", str(grid_center[1]),
            "--center_z", str(grid_center[2]),
            "--size_x", str(grid_size[0]),
            "--size_y", str(grid_size[1]),
            "--size_z", str(grid_size[2]),
            "--exhaustiveness", str(exhaustiveness),
            "--num_modes", str(num_modes),
            "--out", out_path,
        ]

        result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)

        if result.returncode != 0:
            raise RuntimeError(f"Vina failed: {result.stderr[:2000]}")

        with open(out_path, "r") as f:
            output_pdbqt = f.read()

        vina_log = result.stdout
        parsed = parse_vina_log(vina_log)
        poses = _parse_vina_poses(output_pdbqt, vina_log)
        ligand_pdb = _extract_ligand_pdb(output_pdbqt)

        best_affinity = None
        if poses:
            best_affinity = poses[0]["affinity"]

        return {
            "poses": poses,
            "num_poses": len(poses),
            "affinity": best_affinity,
            "vina_log": vina_log,
            "vina_version": parsed.get("vina_version", ""),
            "vina_meta": parsed,
            "ligand_pdb": ligand_pdb,
            "result_sdf": output_pdbqt,
        }


def _parse_vina_poses(output_pdbqt: str, vina_log: str) -> list[dict]:
    """Parse Vina output PDBQT into a list of per-pose dicts.

    Affinity + RMSD (l.b./u.b.) come from the scored mode table in the log;
    atom counts come from the multi-model output PDBQT.
    """
    parsed = parse_vina_log(vina_log)
    mode_table = {m["model"]: m for m in parsed["modes"]}

    models: dict[int, list[str]] = {}
    current_model: int | None = None
    for line in output_pdbqt.splitlines():
        if line.startswith("MODEL"):
            parts = line.split()
            if len(parts) >= 2:
                current_model = int(parts[1])
                models[current_model] = []
        elif line.startswith("ENDMDL"):
            current_model = None
        elif current_model is not None:
            models.setdefault(current_model, []).append(line)

    poses = []
    for model_id in sorted(models.keys()):
        atom_lines = [l for l in models[model_id] if l.startswith("HETATM") or l.startswith("ATOM")]
        atom_count = len(atom_lines)
        hydrogen_count = sum(
            1 for l in atom_lines
            if (l.split()[2].startswith("H") if len(l.split()) > 2 else False)
        )
        entry = mode_table.get(model_id, {})
        poses.append({
            "model": model_id,
            "atoms": atom_count,
            "hydrogens": hydrogen_count,
            "affinity": entry.get("affinity"),
            "rmsd_lb": entry.get("rmsd_lb"),
            "rmsd_ub": entry.get("rmsd_ub"),
        })

    return poses


def _extract_ligand_pdb(output_pdbqt: str) -> str:
    """Extract HETATM lines from the best (first) model as PDB for 3D viewer."""
    in_model = False
    lines: list[str] = []
    for line in output_pdbqt.splitlines():
        if line.startswith("MODEL") and not in_model:
            in_model = True
            continue
        if line.startswith("ENDMDL"):
            break
        if in_model and (line.startswith("HETATM") or line.startswith("ATOM")):
            pdb_line = _pdbqt_line_to_pdb(line)
            lines.append(pdb_line)

    if not lines:
        return ""
    lines.append("END")
    return "\n".join(lines)


def _pdbqt_line_to_pdb(pdbqt_line: str) -> str:
    """Convert a PDBQT ATOM/HETATM line to a standard PDB ATOM/HETATM line."""
    fields = pdbqt_line.split()
    if len(fields) < 7:
        return pdbqt_line
    record = fields[0]
    atom_num = fields[1]
    atom_name = fields[2]
    res_name = fields[3]
    chain = fields[4] if len(fields[4]) == 1 and fields[4].isalpha() else "A"
    res_seq = fields[5]
    x = float(fields[6])
    y = float(fields[7])
    z = float(fields[8]) if len(fields) > 8 else 0.0

    return (
        f"{record:<6}{atom_num:>5s}  {atom_name:<4s}{res_name:<3s} "
        f"{chain}{res_seq:>4s}    "
        f"{x:8.3f}{y:8.3f}{z:8.3f}  1.00  0.00           "
    )