DrugOps / engines /molgate_module2_engineC.py
chihsing's picture
Upload 180 files
af894e0 verified
Raw History Blame Contribute Delete
16.1 kB
"""
MolGate — Module 2 Engine C:配體 PDBQT(Stage 09)
預設(--engine meeko,快速):僅 Meeko。
備援:--engine auto 為 Meeko → obabel;--engine obabel 僅 Open Babel。
輸入 SMILES:
- 優先 stage_05_tautomer.json → selected_smiles(若已跑 Engine B)
- 否則 stage_01_smiles.json → canonical_smiles
輸出:
- ligand/ligand.pdbqt
- stage_09_ligand_pdbqt.json
- 更新 molgate_run_manifest.json
"""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import sys
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Any
_PKG_ROOT = Path(__file__).resolve().parent
if str(_PKG_ROOT) not in sys.path:
sys.path.insert(0, str(_PKG_ROOT))
from molgate_manifest import (
append_registry,
finalize_module2_engine_c_manifest,
try_load_manifest_or_rebuild,
validate_target_matches_manifest,
)
from rdkit import Chem
from rdkit.Chem import AllChem
SCHEMA_VERSION = "0.1"
# Windows cp950 下避免 Unicode 輸出炸掉(如 ❌、⚠、✓)
try:
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
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
session_dir.mkdir(parents=True, exist_ok=True)
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_json(p: Path) -> dict[str, Any]:
return json.loads(p.read_text(encoding="utf-8"))
def resolve_input_smiles(session_dir: Path) -> tuple[str, str]:
p5 = session_dir / "stage_05_tautomer.json"
if p5.is_file():
d = load_json(p5)
smi = d.get("selected_smiles")
if smi:
return smi, "stage_05_tautomer.json:selected_smiles"
p1 = session_dir / "stage_01_smiles.json"
if not p1.is_file():
raise FileNotFoundError("找不到 stage_05_tautomer.json 或 stage_01_smiles.json")
d = load_json(p1)
smi = d.get("canonical_smiles")
if not smi:
raise ValueError("stage_01_smiles.json 缺少 canonical_smiles")
return smi, "stage_01_smiles.json:canonical_smiles (未跑 Engine B)"
def build_3d_mol(smiles: str) -> Chem.Mol:
mol = Chem.MolFromSmiles(smiles)
if mol is None:
raise ValueError("無法自 SMILES 建分子")
Chem.SanitizeMol(mol)
mol = Chem.AddHs(mol)
try:
params = AllChem.ETKDGv3()
except AttributeError:
params = AllChem.ETKDG()
params.randomSeed = 0xC0FFEE
err = AllChem.EmbedMolecule(mol, params)
if err < 0:
err = AllChem.EmbedMolecule(mol, AllChem.ETKDG())
if err < 0:
raise RuntimeError("RDKit EmbedMolecule 失敗,無法產生 3D 構象")
try:
AllChem.MMFFOptimizeMolecule(mol, maxIters=200)
except Exception:
try:
AllChem.UFFOptimizeMolecule(mol, maxIters=200)
except Exception:
pass
return mol
def _coerce_pdbqt_string(raw: Any) -> str:
"""
Meeko 0.7+ 的 write_string 可能回傳 str,或 (pdbqt_str, …) 元組。
"""
if raw is None:
return ""
if isinstance(raw, str):
return raw
if isinstance(raw, bytes):
return raw.decode("utf-8", errors="replace")
if isinstance(raw, (tuple, list)) and len(raw) > 0:
first = raw[0]
if isinstance(first, str):
return first
if isinstance(first, bytes):
return first.decode("utf-8", errors="replace")
raise TypeError(f"PDBQT 輸出型別無法轉成字串: {type(raw)!r}")
def engine_meeko(mol: Chem.Mol, out_pdbqt: Path) -> dict[str, Any]:
"""
Meeko 0.6+:MoleculePreparation(mol) 回傳 setup 列表,PDBQT 用 PDBQTWriterLegacy.write_string。
較舊版:prepare(mol) + setup 上的 write_* 方法。
"""
try:
from meeko import MoleculePreparation
except ImportError as e:
msg = str(e)
hint = ""
if "gemmi" in msg.lower():
hint = " 請執行:pip install gemmi(Meeko 依賴 gemmi)。"
raise RuntimeError(f"Meeko 未安裝或無法 import: {e}.{hint}") from e
preparator = MoleculePreparation()
setups = None
api_route = ""
# 0.6+ 慣用 __call__(mol);舊版用 prepare(mol)
try:
setups = preparator(mol)
api_route = "__call__"
except Exception:
pass
if not setups and hasattr(preparator, "prepare"):
try:
setups = preparator.prepare(mol)
api_route = "prepare"
except Exception as e:
raise RuntimeError(f"Meeko prepare 失敗: {e}") from e
if not setups:
raise RuntimeError("Meeko 未回傳任何 molecule setup(檢查分子是否含氫與 3D)")
setup = setups[0]
out_pdbqt.parent.mkdir(parents=True, exist_ok=True)
pdbqt_text: str | None = None
detail = ""
try:
from meeko import PDBQTWriterLegacy
pdbqt_text = PDBQTWriterLegacy.write_string(setup)
detail = "PDBQTWriterLegacy.write_string"
except Exception as e_legacy:
err_legacy = str(e_legacy)
try:
from meeko import PDBQTWriter
pdbqt_text = PDBQTWriter.write_string(setup)
detail = "PDBQTWriter.write_string"
except Exception:
wfile = getattr(setup, "write_pdbqt_file", None)
if callable(wfile):
wfile(str(out_pdbqt))
return {
"engine": "meeko",
"detail": "write_pdbqt_file",
"api": api_route,
}
writer = getattr(setup, "write_pdbqt_string", None)
if writer is None:
writer = getattr(setup, "write_string", None)
if callable(writer):
pdbqt_text = writer()
detail = "setup.write_pdbqt_string/write_string"
else:
raise RuntimeError(
"Meeko 無法輸出 PDBQT:請升級 meeko(建議 pip install -U meeko)"
f" 並確認可用 PDBQTWriterLegacy。Legacy 錯誤:{err_legacy}"
) from e_legacy
try:
pdbqt_text = _coerce_pdbqt_string(pdbqt_text)
except TypeError as e:
raise RuntimeError(f"Meeko PDBQT 輸出格式異常: {e}") from e
if not pdbqt_text or not pdbqt_text.strip():
raise RuntimeError("Meeko 產生的 PDBQT 字串為空")
out_pdbqt.write_text(pdbqt_text, encoding="utf-8")
return {"engine": "meeko", "detail": detail, "api": api_route}
def _obabel_run(obabel: str, argv: list[str]) -> subprocess.CompletedProcess:
return subprocess.run(
[obabel, *argv],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
def _write_temp_smi(smiles: str) -> Path:
"""單行 SMILES 寫入暫存 .smi,避免 stdin 與參數順序在 OB 3.x 上觸發錯誤。"""
tf = tempfile.NamedTemporaryFile(
mode="w", suffix=".smi", delete=False, encoding="utf-8", newline="\n"
)
try:
tf.write(smiles.strip() + "\n")
path = Path(tf.name)
finally:
tf.close()
return path
def _obabel_try_convert(
obabel: str,
argv: list[str],
out_pdbqt: Path,
*,
strategy: str,
) -> dict[str, Any] | None:
proc = _obabel_run(obabel, argv)
if proc.returncode == 0 and out_pdbqt.is_file() and out_pdbqt.stat().st_size >= 50:
return {
"engine": "obabel",
"obabel_path": obabel,
"strategy": strategy,
"argv": argv,
}
return None
def engine_obabel(smiles: str, out_pdbqt: Path) -> dict[str, Any]:
"""
Open Babel → PDBQT。
不同 OB 版本對「--gen3d / -O」順序要求不一,故依序嘗試多組 argv。
"""
obabel = shutil.which("obabel")
if not obabel:
raise RuntimeError("找不到 obabel(請安裝 Open Babel 並加入 PATH)")
out_pdbqt.parent.mkdir(parents=True, exist_ok=True)
err_parts: list[str] = []
sp = str(out_pdbqt.resolve())
smi_path: Path | None = None
try:
smi_path = _write_temp_smi(smiles)
si = str(smi_path.resolve())
# 策略 A:多種 OB 3.x 可接受的參數順序
smi_to_pdbqt_variants = [
[si, "--gen3d", "-O", sp],
[si, "-O", sp, "--gen3d"],
["-ismi", si, "-O", sp, "--gen3d"],
["-ismi", si, "--gen3d", "-O", sp],
]
for argv in smi_to_pdbqt_variants:
ok = _obabel_try_convert(obabel, argv, out_pdbqt, strategy="smi_to_pdbqt")
if ok:
return ok
proc = _obabel_run(obabel, argv)
err_parts.append((proc.stderr or proc.stdout or "").strip()[:400])
# 策略 B:smi → 暫存 pdb → pdbqt
tmp_pdb = out_pdbqt.with_name(out_pdbqt.stem + "_obabel_tmp.pdb")
tp = str(tmp_pdb.resolve())
try:
smi_to_pdb_variants = [
[si, "--gen3d", "-O", tp],
[si, "-O", tp, "--gen3d"],
["-ismi", si, "-O", tp, "--gen3d"],
]
proc1_ok = False
for argv1 in smi_to_pdb_variants:
proc1 = _obabel_run(obabel, argv1)
if proc1.returncode == 0 and tmp_pdb.is_file():
proc1_ok = True
break
err_parts.append((proc1.stderr or proc1.stdout or "").strip()[:400])
if proc1_ok:
pdb_to_pdbqt_variants = [
[tp, "-O", sp],
["-ipdb", tp, "-O", sp],
]
for argv2 in pdb_to_pdbqt_variants:
ok = _obabel_try_convert(
obabel, argv2, out_pdbqt, strategy="smi_to_pdb_then_pdbqt"
)
if ok:
return ok
proc2 = _obabel_run(obabel, argv2)
err_parts.append((proc2.stderr or proc2.stdout or "").strip()[:400])
else:
err_parts.append("smi→pdb 所有 argv 變體皆失敗")
finally:
if tmp_pdb.is_file():
try:
tmp_pdb.unlink()
except OSError:
pass
finally:
if smi_path is not None and smi_path.is_file():
try:
smi_path.unlink()
except OSError:
pass
err_tail = " | ".join(e for e in err_parts if e)
raise RuntimeError(
"obabel 無法產生 PDBQT(已嘗試暫存 .smi 與 pdb 中繼)。"
" 可能原因:Open Babel 未含 pdbqt 外掛,或 Python 3.13 與 conda-forge openbabel 版本不相容。"
" 建議:① pip install gemmi meeko 後使用 --engine meeko;② 另建 Python 3.11/3.12 conda 環境再裝 openbabel;③ Windows 安裝官方 Open Babel 並確認 obabel 在 PATH。"
f" 末次訊息:{err_tail}"
)
def run_engine_c(
session_dir: Path,
target: str,
engine: str = "meeko",
) -> dict[str, Any] | None:
print("=" * 58)
print(" MolGate — Module 2 Engine C(Stage 09:配體 PDBQT)")
print("=" * 58)
try:
try_load_manifest_or_rebuild(session_dir)
validate_target_matches_manifest(session_dir, target)
except (ValueError, FileNotFoundError) as e:
print(f"[ERROR] {e}")
return None
smiles, smi_src = resolve_input_smiles(session_dir)
print(f"\n SMILES 來源:{smi_src}")
print(f" SMILES:{smiles[:120]}{'...' if len(smiles) > 120 else ''}")
s1_path = session_dir / "stage_01_smiles.json"
if s1_path.is_file():
s1 = load_json(s1_path)
if s1.get("recommend_resolve_stereo_before_module2"):
print(
"\n [WARN] Stage 01 曾標記立體未補齊;PDBQT 仍會產生,解讀風險自負。\n"
)
lig_dir = session_dir / "ligand"
lig_dir.mkdir(parents=True, exist_ok=True)
out_pdbqt = lig_dir / "ligand.pdbqt"
mol_3d = build_3d_mol(smiles)
print(f"\n 引擎模式:{engine}" + ("(預設快速:僅 Meeko)" if engine == "meeko" else ""))
engines_order: list[str]
if engine == "auto":
engines_order = ["meeko", "obabel"]
elif engine == "meeko":
engines_order = ["meeko"]
elif engine == "obabel":
engines_order = ["obabel"]
else:
print(f"[ERROR] 未知 --engine:{engine}")
return None
last_err: str | None = None
meta: dict[str, Any] = {}
for eng in engines_order:
try:
if eng == "meeko":
meta = engine_meeko(mol_3d, out_pdbqt)
break
if eng == "obabel":
meta = engine_obabel(smiles, out_pdbqt)
break
except Exception as e:
last_err = f"{eng}: {e}"
print(f" [WARN] {last_err}")
continue
else:
result = {
"status": "FAIL",
"reason": "所有引擎皆失敗",
"last_error": last_err,
"tried": engines_order,
}
save_stage(session_dir, 9, "ligand_pdbqt", result)
print(f"\n[ERROR] Engine C 失敗:{last_err}")
return None
result = {
"status": "PASS",
"input_smiles": smiles,
"smiles_source": smi_src,
"engine": meta.get("engine"),
"engine_detail": meta,
"ligand_pdbqt": str(out_pdbqt.resolve()),
}
save_stage(session_dir, 9, "ligand_pdbqt", result)
finalize_module2_engine_c_manifest(session_dir, result, out_pdbqt)
append_registry(
session_dir.parent,
{
"event": "module2_engine_c_complete",
"session_id": session_dir.name,
"session_dir": str(session_dir.resolve()),
"target": target,
"ligand_pdbqt": str(out_pdbqt.resolve()),
"engine": result.get("engine"),
"timestamp": datetime.now().isoformat(),
},
)
print("\n" + "=" * 58)
print(" Engine C 完成 [OK]")
print(f" 配體 PDBQT:{out_pdbqt}")
print(f" 引擎:{result.get('engine')}")
print(f" Manifest:{session_dir / 'molgate_run_manifest.json'}")
print("=" * 58)
return result
def main() -> int:
ap = argparse.ArgumentParser(description="MolGate Module 2 Engine C — ligand PDBQT")
ap.add_argument("--session-dir", required=True, type=Path, help="session 目錄(與 Module 1 相同)")
ap.add_argument("--target", required=True, help="須與 manifest 一致,如 COX2")
ap.add_argument(
"--engine",
choices=("auto", "meeko", "obabel"),
default="meeko",
help="預設 meeko(快速);auto=Meeko 失敗再 obabel;obabel=僅 Open Babel",
)
args = ap.parse_args()
sd = args.session_dir
if not sd.is_dir():
print(f"[ERROR] session 目錄不存在:{sd}")
return 1
r = run_engine_c(sd, args.target, engine=args.engine)
return 0 if r is not None else 1
if __name__ == "__main__":
raise SystemExit(main())