From b563f56c668bc49a4b32f9cd27b89b4553d0595d Mon Sep 17 00:00:00 2001 From: jvogan <6239693+jvogan@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:40:08 -0700 Subject: [PATCH] Add interface analysis and binding-pocket figure - interface_report.py: zero-dependency protein-protein interface residues between chains - pymol_agent.py: pocket figure subcommand, non-empty-PNG guard, no-clip spin framing Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 ++ README.md | 5 + SKILL.md | 20 ++- scripts/interface_report.py | 235 ++++++++++++++++++++++++++++++++++++ scripts/proteus_doctor.py | 1 + scripts/pymol_agent.py | 99 ++++++++++++++- tests/test_scripts.py | 37 ++++++ 7 files changed, 399 insertions(+), 5 deletions(-) create mode 100755 scripts/interface_report.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b5c9457..15b159d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +- Added `interface_report.py`: zero-dependency protein-protein interface residue + analysis between chains (the chain-chain analog of `pocket_report.py`). +- Added a `pocket` subcommand to `pymol_agent.py` for one-command annotated + binding-pocket figures (ligand + pocket sticks + polar contacts + transparent + context), and a non-empty-PNG guard so renders fail loudly instead of blank. +- Improved turntable framing: orthoscopic, bounding-sphere fit, and a widened + depth slab so the structure never clips mid-rotation. - Added `chimerax_rest.py`: a managed ChimeraX REST renderer that launches a GUI session on an ephemeral port, renders via GPU, defeats the 0-byte-PNG save race, and guarantees teardown. diff --git a/README.md b/README.md index 638a978..370c2b9 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,11 @@ Rosetta-oriented protein design guidance without building a custom plugin. - **23 documented gotchas** for PyMOL, ChimeraX, AlphaFold DB, rendering, and cryo-EM maps — hard-won from real debugging - Tool detection for PyMOL, ChimeraX, and ffmpeg across macOS and Linux installs - Headless PyMOL rendering for publication-quality structure figures, with publication/illustration/soft presets and pLDDT coloring +- One-command annotated binding-pocket figures (ligand + pocket sticks + polar contacts + transparent context) - Headless turntable movies (PyMOL ray-traced frames + ffmpeg), degrading gracefully when ffmpeg is absent - Managed ChimeraX REST rendering — launches a GUI session, renders via GPU, defeats the 0-byte-PNG save race, and tears down cleanly - ChimeraX analysis helpers for alignment, SASA, and hydrogen-bond workflows +- Zero-dependency protein-protein interface residue analysis between chains - HELIX-record injection for CA-only backbones (RFdiffusion / Genie designs) so cartoons render correctly - MRC/CCP4 map inspection with sigma-based contour-level suggestions - AlphaFold DB fetch with confidence interpretation and pLDDT coloring @@ -162,8 +164,10 @@ python3 scripts/fetch_alphafold.py P04637 --pae --json # AlphaFold f python3 scripts/pae_report.py AF-P04637-F1_pae.json --json # PAE/domain hints python3 scripts/validation_report.py 4HHB --json # wwPDB validation metrics python3 scripts/pocket_report.py 1HSG --json # ligand-pocket contacts +python3 scripts/interface_report.py 1BRS --chains A,D --json # protein-protein interface residues python3 scripts/resolve_structure.py TP53 --json # one-command resolver python3 scripts/pymol_agent.py render structure.pdb output.png # headless render +python3 scripts/pymol_agent.py pocket 1HSG.pdb pocket.png --label # annotated binding-pocket figure python3 scripts/pymol_agent.py spin structure.pdb spin.mp4 # turntable movie (needs ffmpeg) python3 scripts/chimerax_agent.py align reference.pdb mobile.pdb # structure alignment python3 scripts/chimerax_rest.py render structure.pdb out.png # GPU render via managed REST @@ -192,6 +196,7 @@ proteus/ ├── compare_structures.py ├── fetch_pdb.py ├── fetch_alphafold.py + ├── interface_report.py ├── map_info.py ├── pae_report.py ├── pdb_info.py diff --git a/SKILL.md b/SKILL.md index c34956f..8729a0b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -104,8 +104,9 @@ debugging, patching, or the help text is insufficient for the task. | `scripts/pae_report.py` | Summarize AlphaFold PAE domain/flexibility hints | `python3 scripts/pae_report.py AF-P04637-F1_pae.json --json` | | `scripts/validation_report.py` | Fetch wwPDB/RCSB validation quality metrics | `python3 scripts/validation_report.py 4HHB --json` | | `scripts/pocket_report.py` | Zero-dep ligand pocket contacts from PDB/PDB ID | `python3 scripts/pocket_report.py 1HSG --json` | +| `scripts/interface_report.py` | Zero-dep protein-protein interface residues between chains | `python3 scripts/interface_report.py 1BRS --chains A,D --json` | | `scripts/compare_structures.py` | PyMOL CE alignment + optional per-residue deviations | `python3 scripts/compare_structures.py ref.pdb mobile.pdb --json` | -| `scripts/pymol_agent.py` | Headless PyMOL driver (info, render, **spin movie**) | `python3 scripts/pymol_agent.py render structure.pdb out.png --color plddt` | +| `scripts/pymol_agent.py` | Headless PyMOL driver (info, render, **pocket figure, spin movie**) | `python3 scripts/pymol_agent.py render structure.pdb out.png --color plddt` | | `scripts/chimerax_agent.py` | Headless ChimeraX driver (analysis, `--nogui`) | `python3 scripts/chimerax_agent.py run "open 1ubq; info chains #1"` | | `scripts/chimerax_rest.py` | Managed ChimeraX REST GUI render (GPU) + turntable | `python3 scripts/chimerax_rest.py render structure.pdb out.png --color plddt` | | `scripts/add_helix_records.py` | Add HELIX records to CA-only backbones so cartoons render | `python3 scripts/add_helix_records.py model.pdb --json` | @@ -272,9 +273,22 @@ Then color by pLDDT bins — see `references/alphafold.md` for the standard colo 4. Extract per-residue deviations with `iterate_state` 5. Render side-by-side or overlay +### Protein-Protein Interface Analysis +```bash +# Zero-dependency: interface residues between chains, JSON for chaining +python3 scripts/interface_report.py complex.pdb --json # all chain pairs +python3 scripts/interface_report.py 1BRS --chains A,D --cutoff 4.5 --json +``` +For a visual interface dissection (contacts/H-bonds/buried surface), use +ChimeraX — see `references/chimerax.md`. + ### Binding Pocket Analysis +```bash +# One-command annotated figure: ligand + pocket sticks + polar contacts + context +python3 scripts/pymol_agent.py pocket 1HSG.pdb pocket.png --label +``` ```python -# PyMOL: select residues within 5A of any ligand +# Or build it by hand. PyMOL: select residues within 5A of any ligand cmd.select("pocket", "byres organic around 5") # Show pocket as sticks, ligand as ball-and-stick cmd.show("sticks", "pocket") @@ -391,9 +405,11 @@ For multi-step workflows, write a summary JSON report at the end with: | Summarize AlphaFold PAE | `python3 scripts/pae_report.py AF-P04637-F1_pae.json --json` | | Fetch validation metrics | `python3 scripts/validation_report.py 4HHB --json` | | Report ligand pocket contacts | `python3 scripts/pocket_report.py 1HSG --json` | +| Report protein-protein interface residues | `python3 scripts/interface_report.py complex.pdb --json` | | Compare two structures | `python3 scripts/compare_structures.py ref.pdb mobile.pdb --per-residue --json` | | Get structure info via PyMOL | `python3 scripts/pymol_agent.py info file.pdb` | | Render a structure headless | `python3 scripts/pymol_agent.py render file.pdb out.png` | +| Render an annotated binding-pocket figure | `python3 scripts/pymol_agent.py pocket file.pdb out.png --label` | | Render a turntable movie | `python3 scripts/pymol_agent.py spin file.pdb out.mp4` | | Render via ChimeraX GPU (REST) | `python3 scripts/chimerax_rest.py render file.pdb out.png` | | Fix a CA-only backbone for cartoons | `python3 scripts/add_helix_records.py model.pdb` | diff --git a/scripts/interface_report.py b/scripts/interface_report.py new file mode 100755 index 0000000..8677df9 --- /dev/null +++ b/scripts/interface_report.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +"""Report protein-protein interface residues from a PDB file or PDB ID. + +For each pair of polymer chains, find residues whose atoms come within a cutoff +of the other chain — the chain-chain analog of pocket_report.py (which reports +ligand pockets). Zero-dependency and PDB-format only; for mmCIF or deeper +chemistry, use ChimeraX after this preflight. + +The pairwise scan is O(atoms_A x atoms_B) per chain pair with a bounding-box +prefilter, which is fine for typical complexes and small assemblies. + +Usage: + python3 interface_report.py complex.pdb --json + python3 interface_report.py 1BRS --chains A,D --cutoff 4.5 --json +""" + +import argparse +import json +import math +import re +import sys +from itertools import combinations +from pathlib import Path + +import fetch_pdb + + +def _error_payload(message: str) -> dict: + return {"status": "error", "error": message} + + +def _ok_payload(data: dict) -> dict: + output = {"status": "ok", "data": data} + output.update(data) + return output + + +def _looks_like_pdb_id(value: str) -> bool: + return bool(re.fullmatch(r"[0-9][A-Za-z0-9]{3}", value.strip())) + + +def _parse_chain_atoms(path: str) -> dict: + """Return {chain: [atom, ...]} for polymer ATOM records.""" + chains: dict = {} + with open(path) as handle: + for line in handle: + if line[:6].strip() != "ATOM" or len(line) < 54: + continue + chain = line[21].strip() or "?" + try: + atom = { + "atom": line[12:16].strip(), + "resname": line[17:20].strip(), + "resi": line[22:26].strip(), + "x": float(line[30:38]), + "y": float(line[38:46]), + "z": float(line[46:54]), + } + except ValueError: + continue + chains.setdefault(chain, []).append(atom) + return chains + + +def _bbox(atoms: list) -> tuple: + xs = [a["x"] for a in atoms] + ys = [a["y"] for a in atoms] + zs = [a["z"] for a in atoms] + return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) + + +def _bbox_too_far(b1: tuple, b2: tuple, cutoff: float) -> bool: + """True if two bounding boxes cannot contain atoms within cutoff.""" + return (b1[0] - b2[3] > cutoff or b2[0] - b1[3] > cutoff or + b1[1] - b2[4] > cutoff or b2[1] - b1[4] > cutoff or + b1[2] - b2[5] > cutoff or b2[2] - b1[5] > cutoff) + + +def _residue_key(atom: dict) -> tuple: + return (atom["resi"], atom["resname"]) + + +def analyze_pair(atoms_a: list, atoms_b: list, cutoff: float) -> tuple: + """Return (res_a, res_b, pair_min) for one chain pair. + + res_a/res_b map residue keys to their minimum distance across the interface; + pair_min maps (residue_a, residue_b) to the closest contact distance. + """ + cutoff_sq = cutoff * cutoff + res_a: dict = {} + res_b: dict = {} + pair_min: dict = {} + for a in atoms_a: + ax, ay, az = a["x"], a["y"], a["z"] + ka = _residue_key(a) + for b in atoms_b: + dx = ax - b["x"] + dy = ay - b["y"] + dz = az - b["z"] + d2 = dx * dx + dy * dy + dz * dz + if d2 <= cutoff_sq: + d = math.sqrt(d2) + kb = _residue_key(b) + if ka not in res_a or d < res_a[ka]: + res_a[ka] = d + if kb not in res_b or d < res_b[kb]: + res_b[kb] = d + pk = (ka, kb) + if pk not in pair_min or d < pair_min[pk]: + pair_min[pk] = d + return res_a, res_b, pair_min + + +def _residue_list(distances: dict, chain: str) -> list: + return sorted( + [{"chain": chain, "resi": key[0], "resname": key[1], "min_distance": round(dist, 2)} + for key, dist in distances.items()], + key=lambda r: r["min_distance"], + ) + + +def _prepare_input(query: str, outdir: str) -> tuple: + path = Path(query) + if path.exists(): + return str(path), {"kind": "local_file", "query": query} + if not _looks_like_pdb_id(query): + raise ValueError("Input must be a local .pdb file or a four-character PDB ID.") + + pdb_id = query.upper() + output_dir = Path(outdir) + output_dir.mkdir(parents=True, exist_ok=True) + url, filename = fetch_pdb.build_download_url(pdb_id, "pdb", None, False) + destination = output_dir / filename + if not destination.exists(): + fetch_pdb.download(url, destination) + return str(destination), {"kind": "pdb_id", "query": pdb_id, "downloaded": str(destination.resolve())} + + +def analyze_interfaces(query: str, cutoff: float = 5.0, chains_filter: list = None, + outdir: str = ".") -> dict: + pdb_path, provenance = _prepare_input(query, outdir) + chain_atoms = _parse_chain_atoms(pdb_path) + all_chains = sorted(chain_atoms) + + if chains_filter: + pairs = [tuple(chains_filter)] + else: + pairs = list(combinations(all_chains, 2)) + + bboxes = {c: _bbox(atoms) for c, atoms in chain_atoms.items() if atoms} + interfaces = [] + for ca, cb in pairs: + if ca not in chain_atoms or cb not in chain_atoms: + continue + if _bbox_too_far(bboxes[ca], bboxes[cb], cutoff): + continue + res_a, res_b, pair_min = analyze_pair(chain_atoms[ca], chain_atoms[cb], cutoff) + if not res_a and not res_b: + continue + closest = sorted( + [{"a": {"chain": ca, "resi": ka[0], "resname": ka[1]}, + "b": {"chain": cb, "resi": kb[0], "resname": kb[1]}, + "min_distance": round(dist, 2)} + for (ka, kb), dist in pair_min.items()], + key=lambda r: r["min_distance"], + )[:10] + interfaces.append({ + "chains": [ca, cb], + "interface_residue_count": {ca: len(res_a), cb: len(res_b)}, + "contact_pair_count": len(pair_min), + "residues": {ca: _residue_list(res_a, ca), cb: _residue_list(res_b, cb)}, + "closest_contacts": closest, + }) + + data = { + "input": provenance, + "file": str(Path(pdb_path).resolve()), + "cutoff_angstrom": cutoff, + "chains": all_chains, + "interface_count": len(interfaces), + "interfaces": interfaces, + } + if len(all_chains) < 2: + data["note"] = "Fewer than two polymer chains found; no interfaces to report." + return _ok_payload(data) + + +def main(): + parser = argparse.ArgumentParser( + description="Report protein-protein interface residues from a PDB file or PDB ID.") + parser.add_argument("input", help="Local .pdb path or four-character PDB ID") + parser.add_argument("--chains", help="Comma-separated chain pair, e.g. A,B (default: all pairs)") + parser.add_argument("--cutoff", type=float, default=5.0, help="Contact cutoff in Angstroms (default: 5)") + parser.add_argument("--outdir", default=".", help="Output directory for PDB ID downloads") + parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON") + args = parser.parse_args() + + chains_filter = None + if args.chains: + parts = [c.strip() for c in args.chains.split(",") if c.strip()] + if len(parts) != 2: + message = "--chains must name exactly two chains, e.g. A,B" + if args.json: + print(json.dumps(_error_payload(message), indent=2)) + else: + print(f"ERROR: {message}", file=sys.stderr) + sys.exit(1) + chains_filter = parts + + try: + output = analyze_interfaces(args.input, args.cutoff, chains_filter, args.outdir) + except (ValueError, OSError, fetch_pdb.PDBFetchError) as exc: + if args.json: + print(json.dumps(_error_payload(str(exc)), indent=2)) + else: + print(f"ERROR: {exc}", file=sys.stderr) + sys.exit(1) + + if args.json: + print(json.dumps(output, indent=2)) + else: + data = output["data"] + print(f"File: {data['file']}") + print(f"Chains: {', '.join(data['chains']) or 'none'}") + if data.get("note"): + print(data["note"]) + for iface in data["interfaces"]: + ca, cb = iface["chains"] + counts = iface["interface_residue_count"] + print(f"{ca}-{cb}: {counts[ca]}/{counts[cb]} interface residues, " + f"{iface['contact_pair_count']} residue contacts") + + +if __name__ == "__main__": + main() diff --git a/scripts/proteus_doctor.py b/scripts/proteus_doctor.py index c52763c..a0a598f 100644 --- a/scripts/proteus_doctor.py +++ b/scripts/proteus_doctor.py @@ -99,6 +99,7 @@ def _script_smoke() -> dict: "pocket_report.py", "compare_structures.py", "add_helix_records.py", + "interface_report.py", "map_info.py", ] results = {} diff --git a/scripts/pymol_agent.py b/scripts/pymol_agent.py index e9821e4..8653b53 100755 --- a/scripts/pymol_agent.py +++ b/scripts/pymol_agent.py @@ -277,6 +277,21 @@ def get_structure_info(pdb_path: str) -> dict: return run_pymol_script(script) +def _verify_png(result: dict, output_path: str) -> dict: + """Downgrade an 'ok' result to error if the PNG is missing or empty. + + PyMOL can return without writing pixels (an empty selection, a silently + failed render). Fail loudly instead of reporting a blank success. + """ + if result.get("status") != "ok": + return result + if not os.path.exists(output_path) or os.path.getsize(output_path) == 0: + return {"status": "error", + "error": f"Render produced no image (empty or missing file: {output_path})", + "data": result.get("data", {})} + return result + + def render_structure(pdb_path: str, output_png: str, width: int = 1200, height: int = 900, style: str = "cartoon", color: str = "spectrum", preset: str = "publication") -> dict: @@ -309,7 +324,8 @@ def render_structure(pdb_path: str, output_png: str, width: int = 1200, height: _output["data"]["rendered"] = {_py_literal(abs_out)} _output["data"]["size"] = "{width}x{height}" ''' - return run_pymol_script(script, timeout=300) # Rendering can take longer + result = run_pymol_script(script, timeout=300) # Rendering can take longer + return _verify_png(result, abs_out) def render_spin(pdb_path: str, output: str, frames: int = 60, width: int = 800, @@ -340,7 +356,17 @@ def render_spin(pdb_path: str, output: str, frames: int = 60, width: int = 800, {_color_script(color)} {_preset_script(preset)} cmd.set("cache_frames", 0) # else PyMOL caches every frame in RAM -> OOM on long spins +cmd.set("orthoscopic", 1) # stable apparent size through the rotation cmd.orient() +# No-clip framing: fit the bounding SPHERE (rotation-invariant) and widen the +# depth slab so nothing clips as wide axes rotate toward the camera. +cmd.zoom("all", buffer=4, complete=1) +_ext = cmd.get_extent("all") +if _ext: + _dx = _ext[1][0] - _ext[0][0] + _dy = _ext[1][1] - _ext[0][1] + _dz = _ext[1][2] - _ext[0][2] + cmd.clip("slab", (_dx * _dx + _dy * _dy + _dz * _dz) ** 0.5 * 1.6) _n = {frames} for _i in range(_n): cmd.ray({width}, {height}) @@ -368,14 +394,67 @@ def render_spin(pdb_path: str, output: str, frames: int = 60, width: int = 800, }} +def render_pocket(pdb_path: str, output: str, ligand: str = "organic", radius: float = 5.0, + width: int = 1200, height: int = 900, label: bool = False, + preset: str = "publication") -> dict: + """Render an annotated binding-pocket figure. + + Shows the ligand and the residues within `radius` as sticks with polar + contacts drawn, the rest of the protein as a transparent cartoon for + context, framed on the pocket. `ligand` is any PyMOL selection (default: + `organic`, i.e. non-polymer/non-solvent ligands). + """ + if not PYMOL: + return {"status": "error", "error": "PyMOL not found. Install it or set PYMOL_BIN."} + if not os.path.isfile(pdb_path): + return {"status": "error", "error": f"Structure file not found: {pdb_path}"} + + abs_pdb = os.path.abspath(pdb_path) + abs_out = os.path.abspath(output) + label_cmd = 'cmd.label("pocket and name CA", "resn+resi")' if label else "" + script = f''' +cmd.load({_py_literal(abs_pdb)}, "struct") +cmd.select("ligand", "(%s)" % {_py_literal(ligand)}) +if cmd.count_atoms("ligand") == 0: + raise RuntimeError("No ligand atoms matched selection %r; pass --ligand" % {_py_literal(ligand)}) +cmd.select("pocket", "byres (polymer within {radius} of ligand)") +cmd.hide("everything") +cmd.show("cartoon", "polymer") +cmd.set("cartoon_transparency", 0.55) +cmd.show("sticks", "pocket") +cmd.show("sticks", "ligand") +cmd.set("stick_radius", 0.18, "pocket") +cmd.color("gray70", "pocket and elem C") +cmd.color("yellow", "ligand and elem C") +cmd.do("util.cnc('pocket or ligand')") +cmd.distance("contacts", "ligand", "pocket", 3.5, mode=2) +cmd.set("dash_color", "yellow") +cmd.set("dash_width", 2.5) +cmd.hide("labels", "contacts") +{label_cmd} +{_preset_script(preset)} +cmd.orient("ligand") +cmd.zoom("ligand", {radius} + 3) +cmd.ray({width}, {height}) +cmd.png({_py_literal(abs_out)}) +_output["data"]["rendered"] = {_py_literal(abs_out)} +_output["data"]["ligand_selection"] = {_py_literal(ligand)} +_output["data"]["ligand_atoms"] = cmd.count_atoms("ligand") +_output["data"]["pocket_residues"] = cmd.count_atoms("pocket and name CA") +''' + result = run_pymol_script(script, timeout=300) + return _verify_png(result, abs_out) + + def main(): parser = argparse.ArgumentParser( description="PyMOL headless agent helper — run commands, inspect structures, render images.", epilog="Examples:\n" " %(prog)s run 'fetch 1ubq; show cartoon'\n" " %(prog)s info structure.pdb\n" - " %(prog)s render structure.pdb output.png\n" - " %(prog)s render structure.pdb output.png --style surface --color chain", + " %(prog)s render structure.pdb output.png --color plddt\n" + " %(prog)s pocket 1HSG.pdb pocket.png --label\n" + " %(prog)s spin structure.pdb spin.mp4 --frames 60", formatter_class=argparse.RawDescriptionHelpFormatter, ) sub = parser.add_subparsers(dest="command", required=True) @@ -398,6 +477,17 @@ def main(): p_render.add_argument("--color", default="spectrum", help="spectrum, bfactor, chain, plddt, or PyMOL color name") p_render.add_argument("--preset", default="publication", choices=["publication", "illustration", "soft"]) + # pocket + p_pocket = sub.add_parser("pocket", help="Render an annotated binding-pocket figure") + p_pocket.add_argument("pdb", help="Path to structure file") + p_pocket.add_argument("output", nargs="?", default="/tmp/pymol_pocket.png", help="Output PNG path") + p_pocket.add_argument("--ligand", default="organic", help="Ligand selection (default: organic)") + p_pocket.add_argument("--radius", type=float, default=5.0, help="Pocket radius in Angstroms (default: 5)") + p_pocket.add_argument("--width", type=int, default=1200) + p_pocket.add_argument("--height", type=int, default=900) + p_pocket.add_argument("--label", action="store_true", help="Label pocket residues (resn+resi)") + p_pocket.add_argument("--preset", default="publication", choices=["publication", "illustration", "soft"]) + # spin p_spin = sub.add_parser("spin", help="Render a 360-degree turntable movie (frames -> ffmpeg)") p_spin.add_argument("pdb", help="Path to structure file") @@ -419,6 +509,9 @@ def main(): elif args.command == "render": result = render_structure(args.pdb, args.output, args.width, args.height, args.style, args.color, args.preset) + elif args.command == "pocket": + result = render_pocket(args.pdb, args.output, args.ligand, args.radius, + args.width, args.height, args.label, args.preset) elif args.command == "spin": result = render_spin(args.pdb, args.output, args.frames, args.width, args.height, args.style, args.color, args.preset, args.fps) diff --git a/tests/test_scripts.py b/tests/test_scripts.py index 5adee84..507a9c6 100644 --- a/tests/test_scripts.py +++ b/tests/test_scripts.py @@ -15,6 +15,7 @@ import chimerax_agent import fetch_alphafold import fetch_pdb +import interface_report import map_info import pymol_agent import uniprot_lookup @@ -135,6 +136,41 @@ def test_add_helix_detects_ideal_helix(self): self.assertEqual(len(segments), 1) self.assertEqual(segments[0], ("A", 1, 14)) + def test_interface_report_pair_detection(self): + atoms_a = [{"atom": "CA", "resname": "ALA", "resi": "1", "x": 0, "y": 0, "z": 0}, + {"atom": "CA", "resname": "ARG", "resi": "2", "x": 3, "y": 0, "z": 0}] + atoms_b = [{"atom": "CA", "resname": "ASP", "resi": "1", "x": 3, "y": 3, "z": 0}, + {"atom": "CA", "resname": "GLU", "resi": "2", "x": 10, "y": 10, "z": 0}] + res_a, res_b, pair_min = interface_report.analyze_pair(atoms_a, atoms_b, 5.0) + self.assertEqual(set(res_a.keys()), {("1", "ALA"), ("2", "ARG")}) + self.assertEqual(set(res_b.keys()), {("1", "ASP")}) + self.assertAlmostEqual(min(pair_min.values()), 3.0, places=2) + + def test_interface_report_single_chain_note(self): + out = interface_report.analyze_interfaces("tests/fixtures/tiny.pdb") + self.assertEqual(out["status"], "ok") + self.assertEqual(out["data"]["chains"], ["A"]) + self.assertEqual(out["data"]["interface_count"], 0) + self.assertIn("note", out["data"]) + + def test_pymol_verify_png(self): + err = {"status": "error", "error": "boom"} + self.assertIs(pymol_agent._verify_png(err, "/no/such.png"), err) + downgraded = pymol_agent._verify_png({"status": "ok", "data": {}}, "/no/such.png") + self.assertEqual(downgraded["status"], "error") + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as fh: + fh.write(b"\x89PNG") + path = fh.name + try: + kept = pymol_agent._verify_png({"status": "ok", "data": {}}, path) + self.assertEqual(kept["status"], "ok") + finally: + Path(path).unlink() + + def test_pymol_pocket_missing_file(self): + result = pymol_agent.render_pocket("tests/fixtures/does_not_exist.pdb", "out.png") + self.assertEqual(result["status"], "error") + def test_map_info_sigma_from_synthetic_mrc(self): nx = ny = nz = 4 vals = [float(i % 7) for i in range(nx * ny * nz)] @@ -188,6 +224,7 @@ def test_cli_help(self): "scripts/pocket_report.py", "scripts/compare_structures.py", "scripts/add_helix_records.py", + "scripts/interface_report.py", "scripts/map_info.py", ]: proc = run_script(script, "--help")