Download engines/molgate_module5_engineA.py from chihsing/DrugOps: direct link, hf CLI and curl.
- Browser
- Download file 33.7 kB
-
https://huggingface.co/spaces/chihsing/DrugOps/resolve/main/engines/molgate_module5_engineA.py
- Command line
-
hf download hf://spaces/chihsing/DrugOps/engines/molgate_module5_engineA.py
-
curl -L -o molgate_module5_engineA.py https://huggingface.co/spaces/chihsing/DrugOps/resolve/main/engines/molgate_module5_engineA.py
33.7 kB
| """ | |
| MolGate — Module 5: Docking & Output (Engine A) | |
| Stage 24: AutoDock Vina 執行 | |
| Stage 25: Pose QC | |
| Stage 26: 結果寫入 docking_result.json | |
| 輸入: | |
| pocket_config.json ← Module 4 Engine C | |
| ligand/ligand.pdbqt ← Module 2 Engine C | |
| protein/receptor.pdbqt ← Module 3 Engine C | |
| 輸出: | |
| docking/output.pdbqt ← Vina 所有 pose | |
| stage_24_vina.json | |
| stage_25_qc.json | |
| stage_26_result.json | |
| docking_result.json ← Engine B 契約 | |
| 依賴:AutoDock Vina 1.2.x(PATH 或 --vina-bin 指定) | |
| 契約對齊:pocket_config.json(Module 4 Engine C,含 target / pdb_id / common_name / target_drug / resseq) | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import os | |
| import re | |
| import shutil | |
| import subprocess | |
| import sys | |
| from datetime import datetime | |
| from pathlib import Path | |
| from typing import Any | |
| _PKG_ROOT = Path(__file__).resolve().parent | |
| _UI_ROOT = _PKG_ROOT.parent | |
| if str(_PKG_ROOT) not in sys.path: | |
| sys.path.insert(0, str(_PKG_ROOT)) | |
| if str(_UI_ROOT) not in sys.path: | |
| sys.path.insert(0, str(_UI_ROOT)) | |
| from molgate_ui.console_utf8 import configure_console_utf8 | |
| configure_console_utf8() | |
| SCHEMA_VERSION = "0.2" | |
| POCKET_CONFIG_SCHEMA_MIN = "0.2" | |
| # QC 閾值 | |
| QC_SCORE_THRESHOLD = -6.0 # kcal/mol,低於此值才算合理(較佳結合) | |
| RMSD_OUT_OF_POCKET_CUTOFF = 8.0 # Å,超過視為偏離主口袋 | |
| _FLOAT_RE = re.compile(r"^[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?$") | |
| def _is_float_token(s: str) -> bool: | |
| return bool(_FLOAT_RE.match(s.strip())) | |
| def _normalize_resname_3(raw: str) -> str: | |
| """MGLTools 偶發把欄位併成 'A1LYS' 類;取末三碼為殘基名。""" | |
| r = raw.strip().upper() | |
| if len(r) >= 3 and r[-3:].isalpha(): | |
| return r[-3:] | |
| return (r + "XXX")[:3] | |
| def _split_merged_atom_resname(merged: str) -> tuple[str, str] | None: | |
| """ | |
| MGLTools 常把原子名與三字母殘基併成單一 token(如 HH1A1ARG、NH1AARG)。 | |
| 自尾端取三個字母為殘基名,其餘為原子名(供 _format 再截成 4 字元)。 | |
| """ | |
| m = merged.strip().upper() | |
| if len(m) < 4: | |
| return None | |
| r3 = m[-3:] | |
| if not r3.isalpha(): | |
| return None | |
| atom = m[:-3].strip() | |
| if not atom: | |
| return None | |
| return atom, r3 | |
| def _pdbqt_coord_slices_valid(line: str) -> bool: | |
| """Vina 依 PDB 固定欄讀 x/y/z(30–54);欄內不得含空白,否則會報座標解析錯誤。""" | |
| if len(line) < 54: | |
| return False | |
| for a, b in ((30, 38), (38, 46), (46, 54)): | |
| t = line[a:b].strip() | |
| if not _is_float_token(t): | |
| return False | |
| return True | |
| def _format_pdbqt_atom_line( | |
| rec: str, | |
| serial: int, | |
| atom_name: str, | |
| resname: str, | |
| chain_id: str, | |
| resseq: int, | |
| x: float, | |
| y: float, | |
| z: float, | |
| occ: float, | |
| temp_f: float, | |
| charge: float, | |
| ad_type: str, | |
| ) -> str: | |
| """輸出 Vina 可讀的 PDBQT 行(欄寬對齊)。rec 為 'ATOM' 或 'HETATM'。""" | |
| an = (atom_name.strip()[:4]).ljust(4) | |
| rn = (_normalize_resname_3(resname))[:3].rjust(3) | |
| ch = (chain_id.strip() or "A")[:1] | |
| typ = ad_type.strip() | |
| prefix = (rec + " ")[:6] | |
| # PDB:13–16 atom、17 altLoc(空白)、18–20 resName、21 chain、22–26 resSeq | |
| return ( | |
| f"{prefix}{serial:5d} {an} {rn} {ch}{resseq:4d} " | |
| f"{x:8.3f}{y:8.3f}{z:8.3f}{occ:6.2f}{temp_f:6.2f} {charge:8.3f} {typ}\n" | |
| ) | |
| def _parse_pdbqt_atom_line_tokens(line: str) -> dict[str, Any] | None: | |
| """ | |
| 以空白切分還原 ATOM 欄位(處理 MGLTools 把 resname 寫成 A1LYS 等情形)。 | |
| 期待尾端:x y z occ temp charge ad_type | |
| """ | |
| raw = line.rstrip("\n\r") | |
| if not (raw.startswith("ATOM") or raw.startswith("HETATM")): | |
| return None | |
| parts = raw.split() | |
| if len(parts) < 12: | |
| return None | |
| # 尾端錨定:最後 7 token 為 x,y,z,occ,temp,charge,ad_type(支援 12-token 合併欄位) | |
| tail = parts[-7:] | |
| if len(tail) == 7 and all(_is_float_token(t) for t in tail[:-1]) and not _is_float_token(tail[-1]): | |
| x, y, z = float(tail[0]), float(tail[1]), float(tail[2]) | |
| occ, temp_f, charge = float(tail[3]), float(tail[4]), float(tail[5]) | |
| ad_type = tail[6] | |
| head = parts[:-7] | |
| if not head or head[0] not in ("ATOM", "HETATM"): | |
| return None | |
| if len(head) == 6: | |
| try: | |
| serial = int(head[1]) | |
| except ValueError: | |
| return None | |
| atom_name = head[2] | |
| resname = head[3] | |
| chain_id = head[4] | |
| try: | |
| resseq = int(head[5]) | |
| except ValueError: | |
| return None | |
| return { | |
| "serial": serial, | |
| "atom_name": atom_name, | |
| "resname": resname, | |
| "chain_id": chain_id, | |
| "resseq": resseq, | |
| "x": x, | |
| "y": y, | |
| "z": z, | |
| "occ": occ, | |
| "temp_f": temp_f, | |
| "charge": charge, | |
| "ad_type": ad_type, | |
| } | |
| if len(head) == 5: | |
| try: | |
| serial = int(head[1]) | |
| except ValueError: | |
| return None | |
| merged = head[2] | |
| chain_id = head[3] | |
| try: | |
| resseq = int(head[4]) | |
| except ValueError: | |
| return None | |
| split = _split_merged_atom_resname(merged) | |
| if split is None: | |
| return None | |
| atom_name, resname = split | |
| return { | |
| "serial": serial, | |
| "atom_name": atom_name, | |
| "resname": resname, | |
| "chain_id": chain_id, | |
| "resseq": resseq, | |
| "x": x, | |
| "y": y, | |
| "z": z, | |
| "occ": occ, | |
| "temp_f": temp_f, | |
| "charge": charge, | |
| "ad_type": ad_type, | |
| } | |
| if len(parts) < 13: | |
| return None | |
| # 自尾端找連續三個座標 | |
| for i in range(len(parts) - 6, 5, -1): | |
| if i + 2 >= len(parts): | |
| continue | |
| if not ( | |
| _is_float_token(parts[i]) | |
| and _is_float_token(parts[i + 1]) | |
| and _is_float_token(parts[i + 2]) | |
| ): | |
| continue | |
| if not _is_float_token(parts[i + 3]) or not _is_float_token(parts[i + 4]): | |
| continue | |
| if not _is_float_token(parts[i + 5]): | |
| continue | |
| ad_type = parts[-1] | |
| charge = float(parts[i + 5]) | |
| temp_f = float(parts[i + 4]) | |
| occ = float(parts[i + 3]) | |
| x, y, z = float(parts[i]), float(parts[i + 1]), float(parts[i + 2]) | |
| head = parts[:i] | |
| if len(head) < 6: | |
| return None | |
| try: | |
| serial = int(head[1]) | |
| except ValueError: | |
| return None | |
| atom_name = head[2] | |
| resname = head[3] | |
| chain_id = head[4] | |
| try: | |
| resseq = int(head[5]) | |
| except ValueError: | |
| return None | |
| return { | |
| "serial": serial, | |
| "atom_name": atom_name, | |
| "resname": resname, | |
| "chain_id": chain_id, | |
| "resseq": resseq, | |
| "x": x, | |
| "y": y, | |
| "z": z, | |
| "occ": occ, | |
| "temp_f": temp_f, | |
| "charge": charge, | |
| "ad_type": ad_type, | |
| } | |
| return None | |
| def _try_read_pdb_fixed_columns(line: str) -> dict[str, Any] | None: | |
| """標準 PDB 欄位讀取(座標在 31–54 欄);成功表示此行已對齊。""" | |
| if len(line) < 68: | |
| return None | |
| try: | |
| rec = line[0:6].strip() | |
| if rec not in ("ATOM", "HETATM"): | |
| return None | |
| serial = int(line[6:11]) | |
| atom_name = line[12:16] | |
| resname = line[17:20] | |
| chain_id = line[21] | |
| resseq = int(line[22:26]) | |
| x = float(line[30:38]) | |
| y = float(line[38:46]) | |
| z = float(line[46:54]) | |
| occ = float(line[54:60]) | |
| temp_f = float(line[60:66]) | |
| tail = line[66:].strip().split() | |
| if len(tail) < 2: | |
| return None | |
| charge = float(tail[0]) | |
| ad_type = tail[1] | |
| return { | |
| "rec": rec, | |
| "serial": serial, | |
| "atom_name": atom_name, | |
| "resname": resname, | |
| "chain_id": chain_id, | |
| "resseq": resseq, | |
| "x": x, | |
| "y": y, | |
| "z": z, | |
| "occ": occ, | |
| "temp_f": temp_f, | |
| "charge": charge, | |
| "ad_type": ad_type, | |
| } | |
| except (ValueError, IndexError): | |
| return None | |
| def _ad4_type_is_hydrogen(ad_type: str) -> bool: | |
| t = ad_type.strip().upper() | |
| if not t: | |
| return False | |
| # AD4 氫類型多以 H 開頭;受體中極少見 Hg(若遇到可再擴充白名單) | |
| if t == "H" or (t[0] == "H" and len(t) == 2): | |
| return True | |
| return False | |
| _PDBQT_TORSION_TAGS = frozenset({ | |
| "ROOT", "ENDROOT", "BRANCH", "ENDBRANCH", "TORSDOF", | |
| "BEGIN_RES", "END_RES", | |
| }) | |
| def _is_pdbqt_meta_line(bare: str) -> bool: | |
| s = bare.strip() | |
| if not s: | |
| return True | |
| tag = s.split()[0].upper() | |
| if tag in _PDBQT_TORSION_TAGS: | |
| return True | |
| if tag.startswith("TORSDOF"): | |
| return True | |
| return False | |
| def _sanitize_receptor_pdbqt_for_vina( | |
| receptor_in: Path, | |
| receptor_out: Path, | |
| *, | |
| drop_hydrogens: bool, | |
| ) -> tuple[int, int, int]: | |
| """ | |
| 重寫 receptor PDBQT:修正欄位錯位;剔除 ROOT/BRANCH(rigid receptor 僅允許 ATOM/HETATM)。 | |
| 回傳 (輸出的 ATOM/HETATM 行數, 重新格式化行數, 剔除的非原子標籤行數)。 | |
| """ | |
| text = receptor_in.read_text(encoding="utf-8", errors="replace") | |
| out_lines: list[str] = [] | |
| n_atom = 0 | |
| n_reformatted = 0 | |
| n_meta_stripped = 0 | |
| for line in text.splitlines(keepends=True): | |
| bare = line.rstrip("\n\r") | |
| if not bare.strip(): | |
| continue | |
| if not (bare.startswith("ATOM") or bare.startswith("HETATM")): | |
| if _is_pdbqt_meta_line(bare): | |
| n_meta_stripped += 1 | |
| continue | |
| padded = bare + " " * 120 | |
| col = _try_read_pdb_fixed_columns(padded) | |
| if col is not None and _pdbqt_coord_slices_valid(padded): | |
| if drop_hydrogens and _ad4_type_is_hydrogen(col["ad_type"]): | |
| continue | |
| out_lines.append(line if line.endswith("\n") else line + "\n") | |
| n_atom += 1 | |
| continue | |
| parsed = _parse_pdbqt_atom_line_tokens(bare) | |
| if parsed is None: | |
| continue | |
| if drop_hydrogens and _ad4_type_is_hydrogen(parsed["ad_type"]): | |
| continue | |
| rec = "HETATM" if bare.startswith("HETATM") else "ATOM" | |
| out_lines.append( | |
| _format_pdbqt_atom_line( | |
| rec, | |
| parsed["serial"], | |
| parsed["atom_name"], | |
| parsed["resname"], | |
| parsed["chain_id"], | |
| parsed["resseq"], | |
| parsed["x"], | |
| parsed["y"], | |
| parsed["z"], | |
| parsed["occ"], | |
| parsed["temp_f"], | |
| parsed["charge"], | |
| parsed["ad_type"], | |
| ) | |
| ) | |
| n_atom += 1 | |
| n_reformatted += 1 | |
| receptor_out.parent.mkdir(parents=True, exist_ok=True) | |
| receptor_out.write_text("".join(out_lines), encoding="utf-8") | |
| return n_atom, n_reformatted, n_meta_stripped | |
| def _prepare_receptor_for_vina(session_dir: Path, receptor: Path) -> Path: | |
| """ | |
| 產生供 Vina 使用的 receptor:修正 MGLTools 等造成的欄位錯位。 | |
| 輸出 protein/receptor_for_vina.pdbqt(至少會複製並掃描;有修正時會提示)。 | |
| """ | |
| fixed = session_dir / "protein" / "receptor_for_vina.pdbqt" | |
| n_atom, n_ref, n_meta = _sanitize_receptor_pdbqt_for_vina(receptor, fixed, drop_hydrogens=False) | |
| if n_atom <= 0: | |
| return receptor | |
| if n_ref > 0 or n_meta > 0: | |
| parts = [] | |
| if n_ref > 0: | |
| parts.append(f"修正 {n_ref} 行欄位") | |
| if n_meta > 0: | |
| parts.append(f"剔除 {n_meta} 行 ROOT/BRANCH 標籤") | |
| print(f" → receptor PDBQT 已正規化供 Vina:{fixed}({', '.join(parts)},共 {n_atom} 行原子)") | |
| else: | |
| print(f" → 使用正規化副本供 Vina:{fixed}({n_atom} 行,欄位已對齊無需改寫)") | |
| return fixed | |
| # ── 工具函式 ────────────────────────────────────────── | |
| def save_stage(session_dir: Path, stage: int, name: str, data: dict[str, Any]) -> dict[str, Any]: | |
| data = dict(data) | |
| data["_stage"] = stage | |
| data["_name"] = name | |
| data["_timestamp"] = datetime.now().isoformat() | |
| data["_schema_version"] = SCHEMA_VERSION | |
| path = session_dir / f"stage_{stage:02d}_{name}.json" | |
| path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") | |
| return data | |
| def load_pocket_config(session_dir: Path) -> dict[str, Any]: | |
| path = session_dir / "pocket_config.json" | |
| if not path.exists(): | |
| raise FileNotFoundError("找不到 pocket_config.json,請先跑 Module 4 Engine C") | |
| data = json.loads(path.read_text(encoding="utf-8")) | |
| if not data.get("ready_for_docking"): | |
| raise ValueError("pocket_config.json 未標記 ready_for_docking=True,請重跑 Module 4") | |
| sv = (data.get("schema_version") or "").strip() | |
| if sv and sv != POCKET_CONFIG_SCHEMA_MIN: | |
| print( | |
| f" ⚠ pocket_config.schema_version={sv!r},建議與 Module 4 Engine C({POCKET_CONFIG_SCHEMA_MIN})一致後再跑" | |
| ) | |
| return data | |
| def load_prep_decisions(session_dir: Path) -> dict[str, Any] | None: | |
| p = session_dir / "prep_decisions.json" | |
| if not p.is_file(): | |
| return None | |
| try: | |
| return json.loads(p.read_text(encoding="utf-8")) | |
| except (json.JSONDecodeError, OSError): | |
| return None | |
| def resolve_and_validate_target( | |
| session_dir: Path, | |
| pocket: dict[str, Any], | |
| cli_target: str | None, | |
| *, | |
| skip_manifest_target_check: bool = False, | |
| skip_prep_target_check: bool = False, | |
| ) -> str: | |
| """ | |
| 標靶:CLI --target 優先,否則 pocket_config.target; | |
| 與 prep_decisions.target、molgate_run_manifest.json 交叉驗證(可 skip)。 | |
| """ | |
| pt_pocket = (pocket.get("target") or "").strip().upper() | |
| cli = (cli_target or "").strip().upper() | |
| target = cli or pt_pocket | |
| if not target: | |
| raise ValueError( | |
| "缺少 target:請使用 --target,或確認 pocket_config.json 已寫入 target(Module 4 Engine C)" | |
| ) | |
| if pt_pocket and cli and pt_pocket != cli: | |
| raise ValueError( | |
| f"pocket_config.target={pocket.get('target')!r} 與 --target={cli_target!r} 不一致" | |
| ) | |
| if not skip_prep_target_check: | |
| prep = load_prep_decisions(session_dir) | |
| if prep: | |
| pt = (prep.get("target") or "").strip().upper() | |
| if pt and pt != target: | |
| raise ValueError( | |
| f"prep_decisions.target={prep.get('target')!r} 與標靶 {target!r} 不一致" | |
| ) | |
| if not skip_manifest_target_check: | |
| try: | |
| from molgate_manifest import validate_target_matches_manifest | |
| validate_target_matches_manifest(session_dir, target) | |
| except FileNotFoundError: | |
| pass | |
| except ValueError: | |
| raise | |
| return target | |
| def _update_manifest_module5_engine_a( | |
| session_dir: Path, | |
| docking_result_path: Path, | |
| output_pdbqt: Path, | |
| ) -> None: | |
| try: | |
| from molgate_manifest import read_manifest, rel_path, write_manifest | |
| except ImportError: | |
| return | |
| mp = session_dir / "molgate_run_manifest.json" | |
| if not mp.is_file(): | |
| return | |
| try: | |
| m = read_manifest(session_dir) | |
| paths = dict(m.get("paths") or {}) | |
| rmap = dict(paths.get("relative") or {}) | |
| rmap["docking_result"] = rel_path(session_dir, docking_result_path) | |
| rmap["docking_output_pdbqt"] = rel_path(session_dir, output_pdbqt) | |
| paths["relative"] = rmap | |
| paths["docking_result_absolute"] = str(docking_result_path.resolve()) | |
| paths["docking_output_pdbqt_absolute"] = str(output_pdbqt.resolve()) | |
| ns = dict(paths.get("next_step") or {}) | |
| ns["after_module5_engine_a"] = { | |
| "read_docking_result_from": rmap["docking_result"], | |
| "hint": "Engine B:審核 docking_result.json", | |
| } | |
| paths["next_step"] = ns | |
| m["paths"] = paths | |
| pl = dict(m.get("pipeline") or {}) | |
| pl["module5_engine_a"] = { | |
| "status": "complete", | |
| "completed_at": datetime.now().isoformat(), | |
| "stages": ["24", "25", "26"], | |
| } | |
| m["pipeline"] = pl | |
| write_manifest(session_dir, m) | |
| except Exception: | |
| pass | |
| def find_vina(vina_bin: str | None) -> str: | |
| """找 AutoDock Vina 執行檔。""" | |
| if vina_bin: | |
| p = Path(vina_bin).expanduser() | |
| if p.is_file(): | |
| return str(p.resolve()) | |
| raise FileNotFoundError(f"找不到指定的 Vina:{vina_bin}") | |
| ui_home = _PKG_ROOT.parent | |
| candidates: list[Path] = [] | |
| env_bin = os.environ.get("MOLGATE_VINA_BIN", "").strip() | |
| if env_bin: | |
| candidates.append(Path(env_bin).expanduser()) | |
| candidates.extend( | |
| [ | |
| ui_home / "tools" / "vina" / "vina.exe", | |
| ui_home / "tool" / "vina" / "vina.exe", | |
| ui_home.parent / "MolGate" / "tool" / "vina" / "vina.exe", | |
| _PKG_ROOT / "tool" / "vina" / "vina.exe", | |
| ] | |
| ) | |
| seen: set[str] = set() | |
| for cand in candidates: | |
| key = str(cand) | |
| if key in seen: | |
| continue | |
| seen.add(key) | |
| if cand.is_file(): | |
| return str(cand.resolve()) | |
| for name in ("vina", "vina.exe", "autodock_vina", "autodock_vina.exe"): | |
| found = shutil.which(name) | |
| if found: | |
| return found | |
| raise FileNotFoundError( | |
| "找不到 AutoDock Vina。請將 vina.exe 放到 moltgate_UI/tools/vina/," | |
| "或設定環境變數 MOLGATE_VINA_BIN,或安裝後加入 PATH,或用 --vina-bin 指定路徑" | |
| ) | |
| def parse_vina_output(output_text: str) -> list[dict[str, Any]]: | |
| """解析 Vina stdout 的 pose 分數列表。""" | |
| poses = [] | |
| for line in output_text.splitlines(): | |
| # Vina 1.2.x 輸出格式: | |
| # mode | affinity | dist from best mode | |
| # | (kcal/mol) | rmsd l.b.| rmsd u.b. | |
| # 1 -8.612 0.000 0.000 | |
| m = re.match(r'\s*(\d+)\s+([-\d.]+)\s+([\d.]+)\s+([\d.]+)', line) | |
| if m: | |
| poses.append({ | |
| "mode": int(m.group(1)), | |
| "affinity": float(m.group(2)), | |
| "rmsd_lb": float(m.group(3)), | |
| "rmsd_ub": float(m.group(4)), | |
| }) | |
| return poses | |
| def run_cmd(argv: list[str], timeout: int = 600) -> tuple[int, str, str]: | |
| try: | |
| r = subprocess.run( | |
| argv, | |
| capture_output=True, | |
| text=True, | |
| timeout=timeout, | |
| check=False, | |
| ) | |
| return r.returncode, r.stdout or "", r.stderr or "" | |
| except subprocess.TimeoutExpired: | |
| return -1, "", "Timeout" | |
| except OSError as e: | |
| return -1, "", str(e) | |
| # ── Stage 24:AutoDock Vina 執行 ────────────────────── | |
| def stage24_vina( | |
| session_dir: Path, | |
| pocket: dict[str, Any], | |
| vina_bin: str, | |
| exhaustiveness: int = 8, | |
| num_modes: int = 9, | |
| ) -> dict[str, Any]: | |
| print("\n[Stage 24] AutoDock Vina 執行...") | |
| receptor = session_dir / "protein" / "receptor.pdbqt" | |
| ligand = session_dir / "ligand" / "ligand.pdbqt" | |
| if not receptor.is_file(): | |
| raise FileNotFoundError(f"找不到 receptor.pdbqt:{receptor}") | |
| if not ligand.is_file(): | |
| raise FileNotFoundError(f"找不到 ligand.pdbqt:{ligand}") | |
| out_dir = session_dir / "docking" | |
| out_dir.mkdir(exist_ok=True) | |
| out_pdbqt = out_dir / "output.pdbqt" | |
| log_file = out_dir / "vina.log" | |
| receptor_use = _prepare_receptor_for_vina(session_dir, receptor) | |
| print(f" → Receptor:{receptor_use}") | |
| print(f" → Ligand:{ligand}") | |
| print(f" → Center:({pocket['center_x']}, {pocket['center_y']}, {pocket['center_z']})") | |
| print(f" → Box:{pocket['box_size_x']} × {pocket['box_size_y']} × {pocket['box_size_z']} Å") | |
| print(f" → Exhaustiveness:{exhaustiveness} | Modes:{num_modes}") | |
| print(f" → 執行中(請稍候)...") | |
| def _run_vina(rec_path: Path) -> tuple[int, str, str, str]: | |
| argv = [ | |
| vina_bin, | |
| "--receptor", str(rec_path), | |
| "--ligand", str(ligand), | |
| "--center_x", str(pocket["center_x"]), | |
| "--center_y", str(pocket["center_y"]), | |
| "--center_z", str(pocket["center_z"]), | |
| "--size_x", str(pocket["box_size_x"]), | |
| "--size_y", str(pocket["box_size_y"]), | |
| "--size_z", str(pocket["box_size_z"]), | |
| "--out", str(out_pdbqt), | |
| "--exhaustiveness", str(exhaustiveness), | |
| "--num_modes", str(num_modes), | |
| ] | |
| code, stdout, stderr = run_cmd(argv) | |
| combined = stdout + "\n" + stderr | |
| return code, stdout, stderr, combined | |
| code, stdout, stderr, combined = _run_vina(receptor_use) | |
| poses = parse_vina_output(combined) | |
| pdbqt_parse_fail = ( | |
| code != 0 | |
| and not poses | |
| and ( | |
| "PDBQT parsing" in combined | |
| or "parsing error" in combined.lower() | |
| or "not valid" in combined.lower() | |
| ) | |
| ) | |
| if pdbqt_parse_fail: | |
| noh_path = session_dir / "protein" / "receptor_for_vina_noh.pdbqt" | |
| n_kept, n_ref, n_meta = _sanitize_receptor_pdbqt_for_vina( | |
| receptor, noh_path, drop_hydrogens=True | |
| ) | |
| extra = f",另剔除 {n_meta} 行 ROOT/BRANCH 標籤" if n_meta else "" | |
| print( | |
| f" ⚠ Vina 無法解析 receptor PDBQT;已剔除 AD4 氫原子列後重試" | |
| f"(保留 {n_kept} 行{extra})" | |
| ) | |
| receptor_use = noh_path | |
| code, stdout, stderr, combined = _run_vina(receptor_use) | |
| poses = parse_vina_output(combined) | |
| # 寫 log | |
| log_file.write_text(combined, encoding="utf-8") | |
| # 若 stdout 沒有,嘗試從 log 再解析 | |
| if not poses: | |
| poses = parse_vina_output(combined) | |
| if code != 0 and not poses: | |
| result = { | |
| "status": "FAIL", | |
| "reason": f"Vina 執行失敗(exit {code})", | |
| "stderr": stderr[:500], | |
| } | |
| save_stage(session_dir, 24, "vina", result) | |
| raise ValueError(f"Stage 24 FAIL: {result['reason']}\n{stderr[:200]}") | |
| print(f" ✓ Vina 完成,找到 {len(poses)} 個 pose") | |
| for p in poses: | |
| print(f" Mode {p['mode']:2d}: {p['affinity']:7.3f} kcal/mol " | |
| f"| RMSD lb={p['rmsd_lb']:.2f} ub={p['rmsd_ub']:.2f}") | |
| result = { | |
| "status": "PASS", | |
| "vina_bin": vina_bin, | |
| "exhaustiveness": exhaustiveness, | |
| "num_modes": num_modes, | |
| "pose_count": len(poses), | |
| "poses": poses, | |
| "output_pdbqt": str(out_pdbqt.as_posix()), | |
| "log_file": str(log_file.as_posix()), | |
| "pocket_het_id": pocket.get("het_id", ""), | |
| "pocket_chain": pocket.get("chain", ""), | |
| } | |
| return save_stage(session_dir, 24, "vina", result) | |
| # ── Stage 25:Pose QC ───────────────────────────────── | |
| def stage25_qc( | |
| session_dir: Path, | |
| r24: dict[str, Any], | |
| pocket: dict[str, Any], | |
| ) -> dict[str, Any]: | |
| print("\n[Stage 25] Pose QC...") | |
| poses = r24.get("poses", []) | |
| if not poses: | |
| result = { | |
| "status": "FAIL", | |
| "reason": "Stage 24 無 pose 輸出", | |
| } | |
| save_stage(session_dir, 25, "qc", result) | |
| raise ValueError("Stage 25 FAIL: 無 pose 可評估") | |
| best = poses[0] | |
| score = best["affinity"] | |
| # QC 判斷 | |
| flags = [] | |
| qc_pass = True | |
| # 分數檢查 | |
| if score > QC_SCORE_THRESHOLD: | |
| flags.append(f"SCORE_WEAK: best pose {score:.3f} kcal/mol > 閾值 {QC_SCORE_THRESHOLD}") | |
| qc_pass = False | |
| print(f" ⚠ 分數偏弱:{score:.3f} kcal/mol(閾值 {QC_SCORE_THRESHOLD})") | |
| else: | |
| print(f" ✓ 分數合理:{score:.3f} kcal/mol") | |
| # RMSD 檢查(若多個 pose 都一樣,可能有問題) | |
| if len(poses) > 1: | |
| rmsds = [p["rmsd_lb"] for p in poses[1:]] | |
| if all(r < 0.5 for r in rmsds): | |
| flags.append("RMSD_COLLAPSED: 所有 pose RMSD 相近,搜索空間可能太小") | |
| print(f" ⚠ 所有 pose RMSD 相近,建議增大 box size 或 exhaustiveness") | |
| # RMSD lb 偏大:僅當口袋外 pose 佔多數才降級 | |
| out_of_pocket_modes = [p["mode"] for p in poses if float(p["rmsd_lb"]) > RMSD_OUT_OF_POCKET_CUTOFF] | |
| out_of_pocket_count = len(out_of_pocket_modes) | |
| total_poses = len(poses) | |
| if out_of_pocket_count > 0 and total_poses > 0: | |
| if (out_of_pocket_count / total_poses) > 0.5: | |
| flags.append("OUT_OF_POCKET_MAJORITY") | |
| qc_pass = False | |
| print( | |
| f" ⚠ OUT_OF_POCKET_MAJORITY:{out_of_pocket_count}/{total_poses} " | |
| f"poses RMSD lb > {RMSD_OUT_OF_POCKET_CUTOFF:.1f} Å" | |
| ) | |
| else: | |
| flags.append(f"OUT_OF_POCKET_MINOR: {out_of_pocket_count}/{total_poses} poses outside") | |
| print( | |
| f" ℹ OUT_OF_POCKET_MINOR:{out_of_pocket_count}/{total_poses} " | |
| f"poses RMSD lb > {RMSD_OUT_OF_POCKET_CUTOFF:.1f} Å(不降級)" | |
| ) | |
| status = "PASS" if qc_pass else "WARN" | |
| if qc_pass: | |
| print(f" ✓ QC 通過") | |
| else: | |
| print(f" ⚠ QC 有警告,建議確認結果") | |
| print(f" → Status: {status}") | |
| result = { | |
| "status": status, | |
| "best_pose_mode": best["mode"], | |
| "best_affinity": score, | |
| "best_rmsd_lb": best["rmsd_lb"], | |
| "best_rmsd_ub": best["rmsd_ub"], | |
| "score_threshold": QC_SCORE_THRESHOLD, | |
| "out_of_pocket_rmsd_lb_cutoff": RMSD_OUT_OF_POCKET_CUTOFF, | |
| "out_of_pocket_modes": out_of_pocket_modes, | |
| "qc_pass": qc_pass, | |
| "flags": flags, | |
| "all_poses": poses, | |
| } | |
| return save_stage(session_dir, 25, "qc", result) | |
| # ── Stage 26:結果寫入 ──────────────────────────────── | |
| def stage26_result( | |
| session_dir: Path, | |
| r24: dict[str, Any], | |
| r25: dict[str, Any], | |
| pocket: dict[str, Any], | |
| target: str, | |
| ) -> dict[str, Any]: | |
| print("\n[Stage 26] 結果寫入...") | |
| flags_all = r25.get("flags", []) | |
| # confidence tier | |
| if r25["qc_pass"] and not flags_all: | |
| tier = "A" | |
| elif r25["qc_pass"]: | |
| tier = "B" | |
| else: | |
| tier = "C" | |
| result: dict[str, Any] = { | |
| "status": "PASS", | |
| "target": target.upper(), | |
| "pdb_id": pocket.get("pdb_id", ""), | |
| "het_id": pocket.get("het_id", ""), | |
| "chain": pocket.get("chain", ""), | |
| "resseq": pocket.get("resseq"), | |
| "pocket_rule": pocket.get("pocket_rule", ""), | |
| "best_affinity": r25["best_affinity"], | |
| "best_pose_mode": r25["best_pose_mode"], | |
| "confidence_tier": tier, | |
| "qc_pass": r25["qc_pass"], | |
| "flags": flags_all, | |
| "all_poses": r25["all_poses"], | |
| "output_pdbqt": r24["output_pdbqt"], | |
| "pocket_center": { | |
| "x": pocket["center_x"], | |
| "y": pocket["center_y"], | |
| "z": pocket["center_z"], | |
| }, | |
| "ready_for_review": True, | |
| } | |
| cn = (pocket.get("common_name") or "").strip() | |
| td = (pocket.get("target_drug") or "").strip() | |
| if cn: | |
| result["common_name"] = cn | |
| if td: | |
| result["target_drug"] = td | |
| r = save_stage(session_dir, 26, "result", result) | |
| # 寫 docking_result.json(Engine B 契約) | |
| docking_result_path = session_dir / "docking_result.json" | |
| docking_result: dict[str, Any] = { | |
| "schema_version": SCHEMA_VERSION, | |
| "target": result["target"], | |
| "pdb_id": result["pdb_id"], | |
| "het_id": result["het_id"], | |
| "chain": result.get("chain", ""), | |
| "resseq": result.get("resseq"), | |
| "pocket_rule": result.get("pocket_rule", ""), | |
| "best_affinity": result["best_affinity"], | |
| "best_pose_mode": result["best_pose_mode"], | |
| "confidence_tier": tier, | |
| "qc_pass": result["qc_pass"], | |
| "flags": flags_all, | |
| "all_poses": result["all_poses"], | |
| "output_pdbqt": result["output_pdbqt"], | |
| "pocket_center": result["pocket_center"], | |
| "ready_for_review": True, | |
| "_timestamp": datetime.now().isoformat(), | |
| } | |
| if "common_name" in result: | |
| docking_result["common_name"] = result["common_name"] | |
| if "target_drug" in result: | |
| docking_result["target_drug"] = result["target_drug"] | |
| docking_result_path.write_text( | |
| json.dumps(docking_result, indent=2, ensure_ascii=False), encoding="utf-8" | |
| ) | |
| print(f" ✓ Confidence tier:{tier}") | |
| print(f" ✓ docking_result.json 寫入完成") | |
| print(f" → Status: PASS") | |
| return r | |
| # ── 主流程 ──────────────────────────────────────────── | |
| def run_engine_a( | |
| session_dir: Path, | |
| target: str | None, | |
| vina_bin: str | None = None, | |
| exhaustiveness: int = 8, | |
| num_modes: int = 9, | |
| *, | |
| skip_manifest_target_check: bool = False, | |
| skip_prep_target_check: bool = False, | |
| ): | |
| print("=" * 55) | |
| print(" MolGate — Module 5 Engine A") | |
| print(" Stage 24/25/26:Vina + QC + 結果寫入") | |
| print("=" * 55) | |
| try: | |
| pocket = load_pocket_config(session_dir) | |
| except (FileNotFoundError, ValueError) as e: | |
| print(f"❌ {e}") | |
| sys.exit(1) | |
| try: | |
| resolved_target = resolve_and_validate_target( | |
| session_dir, | |
| pocket, | |
| target, | |
| skip_manifest_target_check=skip_manifest_target_check, | |
| skip_prep_target_check=skip_prep_target_check, | |
| ) | |
| except ValueError as e: | |
| print(f"❌ {e}") | |
| sys.exit(1) | |
| try: | |
| vina = find_vina(vina_bin) | |
| print(f"\n Vina:{vina}") | |
| except FileNotFoundError as e: | |
| print(f"❌ {e}") | |
| sys.exit(1) | |
| td = (pocket.get("target_drug") or "").strip() | |
| drug_note = f" | target_drug:{td}" if td else "" | |
| print( | |
| f" Target:{resolved_target} | PDB:{pocket.get('pdb_id', '?')}" | |
| f"{drug_note}" | |
| ) | |
| try: | |
| r24 = stage24_vina(session_dir, pocket, vina, exhaustiveness, num_modes) | |
| r25 = stage25_qc(session_dir, r24, pocket) | |
| r26 = stage26_result(session_dir, r24, r25, pocket, resolved_target) | |
| except ValueError as e: | |
| print(f"\n❌ Pipeline stopped: {e}") | |
| sys.exit(1) | |
| except Exception as e: | |
| print(f"\n❌ Pipeline stopped: {e}") | |
| sys.exit(1) | |
| dr_path = session_dir / "docking_result.json" | |
| out_pdbqt = Path(r24["output_pdbqt"]) | |
| _update_manifest_module5_engine_a(session_dir, dr_path, out_pdbqt) | |
| print("\n" + "=" * 55) | |
| print(" Engine A 完成 ✓") | |
| print(f" Best pose:Mode {r25['best_pose_mode']} | " | |
| f"{r25['best_affinity']:.3f} kcal/mol") | |
| print(f" Confidence tier:{r26['confidence_tier']}") | |
| if r25.get("flags"): | |
| print(f" Flags:{' | '.join(r25['flags'])}") | |
| print(f" 契約:{session_dir / 'docking_result.json'}") | |
| print(f" ready_for_review = True") | |
| print("=" * 55) | |
| return {"stage24": r24, "stage25": r25, "stage26": r26} | |
| # ── CLI ────────────────────────────────────────────── | |
| if __name__ == "__main__": | |
| ap = argparse.ArgumentParser(description="MolGate Module 5 Engine A") | |
| ap.add_argument("--session-dir", required=True) | |
| ap.add_argument( | |
| "--target", | |
| default=None, | |
| help="標靶 ID(建議指定;若省略則使用 pocket_config.json 的 target)", | |
| ) | |
| ap.add_argument( | |
| "--vina-bin", | |
| default=None, | |
| help="AutoDock Vina 執行檔路徑(預設從 PATH 找)", | |
| ) | |
| ap.add_argument("--exhaustiveness", type=int, default=8) | |
| ap.add_argument("--num-modes", type=int, default=9) | |
| ap.add_argument( | |
| "--skip-manifest-target-check", | |
| action="store_true", | |
| help="不與 molgate_run_manifest.json 的 target 交叉驗證", | |
| ) | |
| ap.add_argument( | |
| "--skip-prep-target-check", | |
| action="store_true", | |
| help="不檢查 prep_decisions.target 與標靶一致", | |
| ) | |
| args = ap.parse_args() | |
| sd = Path(args.session_dir) | |
| if not sd.is_dir(): | |
| print(f"❌ Session 目錄不存在:{sd}") | |
| sys.exit(1) | |
| run_engine_a( | |
| sd, | |
| args.target, | |
| vina_bin=args.vina_bin, | |
| exhaustiveness=args.exhaustiveness, | |
| num_modes=args.num_modes, | |
| skip_manifest_target_check=args.skip_manifest_target_check, | |
| skip_prep_target_check=args.skip_prep_target_check, | |
| ) | |