Download engines/molgate_module4_engineB.py from chihsing/DrugOps: direct link, hf CLI and curl.
- Browser
- Download file 16.4 kB
-
https://huggingface.co/spaces/chihsing/DrugOps/resolve/main/engines/molgate_module4_engineB.py
- Command line
-
hf download hf://spaces/chihsing/DrugOps/engines/molgate_module4_engineB.py
-
curl -L -o molgate_module4_engineB.py https://huggingface.co/spaces/chihsing/DrugOps/resolve/main/engines/molgate_module4_engineB.py
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, | |
| ) | |