DrugOps / engines /molgate_module4_engineB.py
chihsing's picture
Upload 180 files
af894e0 verified
Raw History Blame Contribute Delete
16.4 kB
"""
MolGate — Module 4: Pocket Definition (Engine B)
Stage 22: Ligand identity confirmation (人機)
輸入:stage_21_hetatm.json + Master Index(het_id / cocrystal_het、pocket_rule、可選 common_name、target_drug)
輸出:stage_22_ligand.json(選定配體)
依賴:與 molgate_module4_engineA 共用 Index 解析(molgate_module3_engineA)
"""
from __future__ import annotations
import argparse
import json
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(_UI_ROOT) not in sys.path:
sys.path.insert(0, str(_UI_ROOT))
if str(_PKG_ROOT) not in sys.path:
sys.path.insert(0, str(_PKG_ROOT))
from molgate_ui.console_utf8 import configure_console_utf8, should_prompt_interactive
configure_console_utf8()
from molgate_module4_engineA import load_target_index_entry
SCHEMA_VERSION = "0.2"
def load_prep_overlay(session_dir: Path, target: str) -> dict[str, Any]:
"""合併 prep_decisions.json 與 CLI target(CLI 優先)。"""
prep: dict[str, Any] = {"target": target.strip()}
p = session_dir / "prep_decisions.json"
if p.is_file():
try:
data = json.loads(p.read_text(encoding="utf-8"))
if isinstance(data, dict):
for k, v in data.items():
if k.startswith("_"):
continue
prep[k] = v
except (json.JSONDecodeError, OSError):
pass
prep["target"] = target.strip()
return prep
def expected_het_id(entry: dict[str, Any]) -> str:
"""比對用單一代碼:優先 het_id,否則 cocrystal_het。"""
for key in ("het_id", "cocrystal_het"):
v = entry.get(key)
if isinstance(v, str) and v.strip():
return v.strip().upper()
return ""
# ── 工具函式 ──────────────────────────────────────────
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_stage21(session_dir: Path) -> dict[str, Any]:
path = session_dir / "stage_21_hetatm.json"
if not path.exists():
raise FileNotFoundError("找不到 stage_21_hetatm.json,請先跑 Engine A")
data = json.loads(path.read_text(encoding="utf-8"))
if data.get("_schema_version") != SCHEMA_VERSION:
raise ValueError("Stage 21 schema version mismatch,請重跑 Engine A")
return data
def prompt_select(
candidates: list[dict],
label: str,
*,
non_interactive: bool = False,
) -> dict:
if not candidates:
raise ValueError(f"無候選可選:{label}")
if not should_prompt_interactive(non_interactive=non_interactive):
pick = candidates[0]
print(
f" [AUTO] 非互動:{label} → [0] {pick.get('resname')} "
f"chain {pick.get('chain')} res {pick.get('resseq')}"
)
return pick
print(f"\n ── {label} ──")
for i, c in enumerate(candidates):
tag = "【目標】 " if c.get("ligand_role") == "target" else ""
print(
f" [{i}] {tag}{c['resname']} chain {c['chain']} "
f"res {c['resseq']} (~{c['mw_estimate']:.0f} Da, "
f"{c['heavy_atom_count']} heavy atoms)"
)
while True:
try:
raw = input(" 請輸入編號選擇:").strip()
idx = int(raw)
if 0 <= idx < len(candidates):
return candidates[idx]
except (ValueError, KeyboardInterrupt):
pass
print(" 請輸入有效編號")
# ── Stage 22:配體身份確認 ────────────────────────────
def stage22_ligand(
session_dir: Path,
target: str,
index_path: Path | None,
*,
non_interactive: bool = False,
selected: dict[str, Any] | None = None,
) -> dict[str, Any]:
print("\n[Stage 22] 配體身份確認...")
prep = load_prep_overlay(session_dir, target)
entry = load_target_index_entry(session_dir, prep, index_path)
if not entry:
result = {
"status": "FAIL",
"reason": "無法載入 Master Index 條目(請確認 --target 與 master_index.json)",
}
save_stage(session_dir, 22, "ligand", result)
raise ValueError(f"Stage 22 FAIL: {result['reason']}")
s21 = load_stage21(session_dir)
candidates = s21.get("candidates", [])
route_fallback_status = ""
if not candidates:
try:
from molgate_ui.anchor_routing import m4_recover_empty_candidates
recovery = m4_recover_empty_candidates(session_dir, entry)
except ImportError:
recovery = None
if recovery:
if recovery.get("needs_rerun_from_m1"):
res_obj = recovery.get("resolution")
result = {
"status": "ROUTE_FALLBACK",
"reason": (
"Direct 共晶 HET 不存在;已寫入 cross-PDB proxy 路由,"
"請以更新後的 pdb_id 自 Module 1 重跑"
),
"route_resolution": res_obj.to_json() if hasattr(res_obj, "to_json") else res_obj,
}
save_stage(session_dir, 22, "ligand", result)
raise ValueError(f"Stage 22 ROUTE_FALLBACK: {result['reason']}")
entry = recovery.get("effective_entry") or entry
candidates = recovery.get("candidates") or []
route_fallback_status = getattr(recovery.get("resolution"), "reason", "") or "route_fallback"
print(f" → Route fallback:{route_fallback_status}")
if not candidates:
result = {
"status": "FAIL",
"reason": "Stage 21 無候選配體,無法確認配體身份",
}
save_stage(session_dir, 22, "ligand", result)
raise ValueError(f"Stage 22 FAIL: {result['reason']}")
het_id = expected_het_id(entry)
pocket_rule = (entry.get("pocket_rule") or "").strip()
common_name = (entry.get("common_name") or "").strip()
target_drug = (entry.get("target_drug") or "").strip()
pdb_id = (entry.get("pdb_id") or prep.get("pdb_id") or "").strip().upper()
print(f" → Master Index expected het(het_id/cocrystal_het):{het_id or '(未設定)'}")
if common_name:
print(f" → 共晶配體俗名(common_name):{common_name}")
if target_drug:
print(f" → Docking 目標藥物(target_drug):{target_drug}")
print(f" → Pocket rule: {pocket_rule}")
print(f" → 候選配體數:{len(candidates)}")
if selected is not None:
want = (
str(selected.get("resname") or "").strip().upper(),
str(selected.get("chain") or "").strip(),
int(selected.get("resseq")),
)
picked = None
for c in candidates:
if (
str(c.get("resname") or "").strip().upper() == want[0]
and str(c.get("chain") or "").strip() == want[1]
and int(c.get("resseq")) == want[2]
):
picked = c
break
if not picked:
raise ValueError(
f"UI 選定的配體 {want[0]} chain {want[1]} res {want[2]} 不在 Stage 21 候選列表中"
)
selected = picked
status = "MANUAL"
print(f" ✓ UI 確認:{selected['resname']} chain {selected['chain']} res {selected['resseq']}")
elif not het_id:
print(f" ⚠ 無法自動比對(Index 缺 het_id / cocrystal_het),請手動選擇")
if non_interactive:
selected = candidates[0]
status = "AUTO_FORCED"
print(f" ✓ --non-interactive:採用候選 [0] {selected['resname']} chain {selected['chain']}")
else:
selected = prompt_select(
candidates,
"請選擇用於口袋定義的配體",
non_interactive=non_interactive,
)
status = "MANUAL"
print(f" ✓ 選定:{selected['resname']} chain {selected['chain']}")
else:
matched = [c for c in candidates if c["resname"].upper() == het_id]
if len(matched) == 1:
selected = matched[0]
status = "AUTO"
print(f" ✓ 自動比對成功:{selected['resname']} chain {selected['chain']}")
elif len(matched) > 1:
print(f" ⚠ 多個 {het_id} 存在於不同 chain,需要確認")
if non_interactive:
selected = matched[0]
status = "AUTO_FORCED"
print(
f" ✓ --non-interactive:採用排序後第一個 {selected['resname']} "
f"chain {selected['chain']} res {selected['resseq']}"
)
else:
selected = prompt_select(
matched,
f"選擇 {het_id} 所在 chain",
non_interactive=non_interactive,
)
status = "MANUAL"
print(f" ✓ 選定:{selected['resname']} chain {selected['chain']}")
elif len(candidates) == 1:
print(
f" ⚠ WARN: 唯一候選 {candidates[0]['resname']} "
f"與 Index 期望 {het_id} 不符"
)
print(f" → 可能是 PDB/CCD 代碼與 Index 不一致")
if non_interactive:
selected = candidates[0]
status = "AUTO_FORCED"
print(f" ✓ --non-interactive:採用唯一候選 {selected['resname']}")
else:
confirm = input(
f" 是否使用 {candidates[0]['resname']} 作為口袋定義配體?[y/N]:"
).strip().lower()
if confirm == "y":
selected = candidates[0]
status = "MANUAL"
print(f" ✓ 手動確認:{selected['resname']}")
else:
result = {
"status": "FAIL",
"reason": f"用戶拒絕使用 {candidates[0]['resname']},無法繼續",
}
save_stage(session_dir, 22, "ligand", result)
raise ValueError(f"Stage 22 FAIL: {result['reason']}")
else:
print(f" ⚠ 無法自動比對,候選列表如下:")
if non_interactive:
preferred = [c for c in candidates if c.get("ligand_role") == "target"]
selected = (preferred[0] if preferred else candidates[0])
status = "AUTO_FORCED"
print(
f" ✓ --non-interactive:採用"
f"{'【目標】' if selected.get('ligand_role') == 'target' else ''}"
f"{selected['resname']} chain {selected['chain']}"
)
else:
selected = prompt_select(
candidates,
"請選擇用於口袋定義的配體",
non_interactive=non_interactive,
)
status = "MANUAL"
print(f" ✓ 選定:{selected['resname']} chain {selected['chain']}")
print(f" → Status: {status}")
result: dict[str, Any] = {
"status": status,
"selected_het_id": selected["resname"],
"chain": selected["chain"],
"resseq": selected["resseq"],
"mw_estimate": selected["mw_estimate"],
"heavy_atom_count": selected["heavy_atom_count"],
"master_index_expected_het": het_id,
"master_index_het_id": het_id,
"match": selected["resname"].upper() == het_id if het_id else None,
"pocket_rule": pocket_rule,
"ready_for_engine_c": True,
}
if route_fallback_status:
result["route_fallback"] = route_fallback_status
if pdb_id:
result["pdb_id"] = pdb_id
if common_name:
result["common_name"] = common_name
if target_drug:
result["target_drug"] = target_drug
return save_stage(session_dir, 22, "ligand", result)
# ── 主流程 ────────────────────────────────────────────
def run_engine_b(
session_dir: Path,
target: str,
index_path: Path | None = None,
*,
non_interactive: bool = False,
selected: dict[str, Any] | None = None,
):
print("=" * 55)
print(" MolGate — Module 4 Engine B")
print(" Stage 22: 配體身份確認(人機)")
print("=" * 55)
prep = load_prep_overlay(session_dir, target)
entry = load_target_index_entry(session_dir, prep, index_path)
if not entry:
print(f"❌ Target '{target}' 無法從 Master Index 載入條目(檔案或鍵名錯誤)")
sys.exit(1)
pdb_id = (entry.get("pdb_id") or prep.get("pdb_id") or "?").strip()
het_hint = expected_het_id(entry) or "?"
print(f"\n Target:{target.upper()} | PDB:{pdb_id}")
print(f" Index 期望共晶代碼:{het_hint}")
try:
result = stage22_ligand(
session_dir,
target,
index_path,
non_interactive=non_interactive,
selected=selected,
)
except FileNotFoundError as e:
print(f"❌ {e}")
sys.exit(1)
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)
print("\n" + "=" * 55)
print(" Engine B 完成 ✓")
print(
f" 選定配體:{result['selected_het_id']} "
f"chain {result['chain']} res {result['resseq']}"
)
print(f" ready_for_engine_c = True")
print(f" 輸出:{session_dir / 'stage_22_ligand.json'}")
print("=" * 55)
return result
# ── CLI ──────────────────────────────────────────────
if __name__ == "__main__":
ap = argparse.ArgumentParser(description="MolGate Module 4 Engine B")
ap.add_argument("--session-dir", required=True, help="Session 目錄路徑")
ap.add_argument("--target", required=True, help="Target 名稱(如 COX2,須對應 master_index.json)")
ap.add_argument(
"--index",
default=None,
help="master_index.json 路徑(預設:session_meta / manifest 或 MolGate 目錄下)",
)
ap.add_argument(
"--non-interactive",
action="store_true",
help="Stage 22 無法唯一自動比對時,採用規則化預設(多命中取第一個/無命中取候選[0]或【目標】)",
)
ap.add_argument(
"--selected-json",
default=None,
help="UI 人機選配體 JSON 檔(含 resname、chain、resseq)",
)
args = ap.parse_args()
sd = Path(args.session_dir)
if not sd.is_dir():
print(f"❌ Session 目錄不存在:{sd}")
sys.exit(1)
idx = Path(args.index) if args.index else None
ui_selected: dict[str, Any] | None = None
if args.selected_json:
pick_path = Path(args.selected_json)
if not pick_path.is_file():
print(f"❌ 找不到 --selected-json:{pick_path}")
sys.exit(1)
try:
ui_selected = json.loads(pick_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as e:
print(f"❌ 無法讀取 --selected-json:{e}")
sys.exit(1)
if not isinstance(ui_selected, dict):
print("❌ --selected-json 必須為 JSON 物件")
sys.exit(1)
run_engine_b(
sd,
args.target,
index_path=idx,
non_interactive=args.non_interactive,
selected=ui_selected,
)