"""Gradio front end for BindScout, the BindCraft patch-coverage targetability GNN. Upload a PDB (or give an RCSB id), get the same structure back with the per-residue targetability in the B-factor column, an NGL view coloured by that value, and the scored PDB and per-residue CSV to download. Hovering a residue reports its targetability and marks the 7 A patch the value is a statement about -- the training label is patch coverage, so the number belongs to the neighbourhood, not to the residue alone. """ # ZeroGPU -- and this import MUST come first, above torch and above anything that # imports it. `spaces` patches torch's CUDA layer, and it refuses to load once CUDA has # been initialised: "CUDA has been initialized before importing the `spaces` package". # Merely calling torch.cuda.is_available() counts, which is what TargetabilityPredictor's # constructor does -- so creating the predictor before this line silently disables the # accelerator, and ZeroGPU then rejects the whole Space for exposing no GPU function. # # The try/except covers the two non-ZeroGPU deployments, in both of which `spaces` must # end up as None: on a laptop it is not installed (ImportError), and on a CPU Space it # IS installed -- HF preinstalls it everywhere -- but explodes reading # torch.version.cuda.startswith(...) against a +cpu wheel, where that attribute is None. try: import spaces except Exception as _spaces_exc: # never let an optional accelerator break startup spaces = None _spaces_error = f"{type(_spaces_exc).__name__}: {_spaces_exc}" else: _spaces_error = None import html import json import os import re import tempfile import traceback import urllib.error import urllib.request import gradio as gr import torch as pt from inference import MAX_ATOMS, TargetabilityPredictor from patches import compute_patches # load once, at import, so the first request is not also the first model load PREDICTOR = TargetabilityPredictor() if spaces is not None: # ZeroGPU requires at least one @spaces.GPU function to exist at startup; with none # it refuses to serve at all, however healthy the app is. No CUDA device exists at # import time there -- only inside the decorated call, which is why .to("cuda") # lives in _run_model rather than here. # duration is a RESERVATION, and ZeroGPU quota is charged against it rather than # against the compute actually used -- so an over-generous value burns a viewer's # daily allowance many times faster than the work warrants. Inference is ~2 s for a # 200-residue target and well under a minute at the 20k-atom cap, so 60 s is ample. # At 180 s a full day's quota went in about a dozen clicks. gpu_task = spaces.GPU(duration=60) print("[bindscout] ZeroGPU entry point registered (60 s reservation)") else: print(f"[bindscout] running on CPU ({_spaces_error or 'spaces not installed'})") def gpu_task(fn): return fn @gpu_task def _run_model(pdb_path, chain_list, out_pdb): if pt.cuda.is_available() and PREDICTOR.device.type != "cuda": PREDICTOR.to("cuda") return PREDICTOR.predict(pdb_path, chains=chain_list or None, out_filepath=out_pdb) RCSB_URL = "https://files.rcsb.org/download/{}.pdb" PDB_ID_RE = re.compile(r"^[0-9][A-Za-z0-9]{3}$") # Sequential ramp, neutral grey -> dark red: light = low targetability, dark = high. # One hue with a neutral floor, so near-zero recedes and only real signal reads as # colour -- the whole surface is near-zero on most targets. The floor is a mid grey # rather than near-white: at #eceae7 the protein read as white and the low end was # indistinguishable from the page behind it. RAMP = [ "#c9c7c2", "#c9bcb4", "#c9b1a5", "#c9a595", "#c89886", "#c68a76", "#c17b65", "#ba6b55", "#b05a45", "#a34836", "#943628", "#82231a", "#6d0f0c", ] # residues the model never saw (waters, unselected chains) sit off the ramp entirely UNSCORED_COLOR = "#b9b7b2" # the patch highlight, deliberately off-ramp so it cannot be read as a targetability value PATCH_COLOR = "#1c5cab" # the hovered residue itself -- the centre the patch is defined around. A deeper # step of the same hue, so it reads as one of the patch rather than a second thing. CENTRE_COLOR = "#0d366b" # --------------------------------------------------------------------------- # input resolution # --------------------------------------------------------------------------- def _resolve_input(pdb_file, pdb_id, workdir): """Return a local path to score, or raise gr.Error with something actionable.""" if pdb_file is not None: src = pdb_file if isinstance(pdb_file, str) else pdb_file.name ext = os.path.splitext(src)[1].lower() if ext not in (".pdb", ".ent", ".cif", ".mmcif"): raise gr.Error(f"unsupported file type '{ext}' -- upload a .pdb, .ent or .cif") dst = os.path.join(workdir, os.path.basename(src)) with open(src, "rb") as fi, open(dst, "wb") as fo: fo.write(fi.read()) return dst, os.path.splitext(os.path.basename(src))[0] pdb_id = (pdb_id or "").strip() if not pdb_id: raise gr.Error("upload a structure file, or enter a 4-character RCSB PDB id") if not PDB_ID_RE.match(pdb_id): raise gr.Error(f"'{pdb_id}' is not a 4-character PDB id (e.g. 1M47)") dst = os.path.join(workdir, f"{pdb_id.upper()}.pdb") try: with urllib.request.urlopen(RCSB_URL.format(pdb_id.upper()), timeout=30) as r: data = r.read() except urllib.error.HTTPError as e: if e.code == 404: raise gr.Error(f"RCSB has no entry {pdb_id.upper()}") raise gr.Error(f"could not fetch {pdb_id.upper()} from RCSB ({e.code})") except Exception as e: raise gr.Error(f"could not reach RCSB: {e}") with open(dst, "wb") as fo: fo.write(data) return dst, pdb_id.upper() # --------------------------------------------------------------------------- # rendering # --------------------------------------------------------------------------- def _ramp_color(v): """Sequential ramp lookup for a 0-1 score, linear between the 13 published steps.""" if v is None or v < 0.0: return UNSCORED_COLOR v = min(max(float(v), 0.0), 1.0) x = v * (len(RAMP) - 1) i = int(x) if i >= len(RAMP) - 1: return RAMP[-1] f = x - i a = RAMP[i].lstrip("#") b = RAMP[i + 1].lstrip("#") out = [] for k in (0, 2, 4): ca = int(a[k:k + 2], 16) cb = int(b[k:k + 2], 16) out.append(int(round(ca + (cb - ca) * f))) return "#{:02x}{:02x}{:02x}".format(*out) def _select_payload(records, patches): """One PDBe Mol* QueryParam per residue: its ramp colour and its own tooltip. Addressed by AUTHOR chain / residue number / insertion code, which is the numbering in the file we hand the viewer -- so nothing has to be mapped, and the tooltip Mol* shows on hover is the same residue the score belongs to. """ out = [] for r in records: key = "{}|{}|{}".format(r["chain"], r["resid"], r["icode"]) patch = patches.get(key) if r["score"] < 0: tip = "{} {}{} - not scored".format(r["resname"], r["chain"], r["resid"]) elif patch: tip = "{} {}{} - targetability {:.3f} - 7 A patch: {} residues, rASA {}".format( r["resname"], r["chain"], r["resid"], r["score"], patch["size"], patch["rasa"]) else: tip = "{} {}{} - targetability {:.3f} - buried (rASA <= 0.20), no patch".format( r["resname"], r["chain"], r["resid"], r["score"]) q = { "auth_asym_id": r["chain"], "auth_residue_number": int(r["resid"]), "color": _ramp_color(r["score"]), "tooltip": tip, } if r["icode"]: q["auth_ins_code_id"] = r["icode"] out.append(q) return out def _viewer_document(pdb_text, records, patches, style): """Self-contained page: a PDBe Mol* view of the scored structure. Third viewer, and the reasoning is worth keeping. 3Dmol takes secondary structure from HELIX/SHEET records, which PeSTo-processed files do not have, so every residue rendered as coil. Mol*'s own CDN bundle derives secondary structure correctly but exports only `Viewer` -- no StructureElement, no Script -- so a custom hover readout or patch selection cannot be built against it. NGL exports a real API and picks cartoons correctly, but its molecular-surface picking resolves mesh vertices to the wrong atoms (it ships `disablePicking: true` by default, which now looks deliberate). PDBe Mol* is Mol*'s renderer behind a documented component API, which is the combination that actually exists. It matters here because colour and readout stop being custom code: `visual.select` takes one entry per residue carrying its own `color` AND `tooltip`, keyed by AUTHOR chain/residue/insertion code -- the numbering already in the file. So Mol* renders its own tooltip on its own correct pick, and the only interactive code left is highlighting the patch. """ sel_js = json.dumps(records, separators=(",", ":")) # key -> the line shown in the readout strip. Reuses the same text as the QueryParam # tooltip, which Mol* does not surface in this path. tips = {} for q in records: k = "{}|{}|{}".format(q["auth_asym_id"], q["auth_residue_number"], q.get("auth_ins_code_id", "")) tips[k] = q.get("tooltip", "") tips_js = json.dumps(tips, separators=(",", ":")) patches_js = json.dumps(patches, separators=(",", ":")) bar_stops = ", ".join( f"{c} {int(round(100 * i / (len(RAMP) - 1)))}%" for i, c in enumerate(RAMP) ) visual_style = "molecular-surface" if style == "surface" else "cartoon" return f"""
inference.py "
f"locally."
)
with gr.Column(scale=2):
out_summary = gr.Markdown()
out_view = gr.HTML()
with gr.Row():
out_pdb = gr.File(label="Scored PDB (targetability in B-factors)")
out_csv = gr.File(label="Per-residue CSV")
go.click(
score,
inputs=[f_in, id_in, ch_in, st_in],
outputs=[out_view, out_summary, out_pdb, out_csv],
)
# No UI: this exists to be called. With mcp_server=True it becomes an MCP tool,
# its schema taken from the type hints and its description from the docstring.
gr.api(score_target, api_name="score_target")
examples_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "examples")
example_files = sorted(
os.path.join(examples_dir, f) for f in os.listdir(examples_dir)
) if os.path.isdir(examples_dir) else []
if example_files:
gr.Examples(
examples=[[f, "", "", "surface"] for f in example_files],
inputs=[f_in, id_in, ch_in, st_in],
label="Examples",
)
if __name__ == "__main__":
# ssr_mode=False: gradio 5 turns on server-side rendering whenever Node is present,
# which it is in the Spaces image but not in a plain conda env -- and there the Node
# process dies on startup and takes the app down with it ("Stopping Node.js server").
# Nothing here needs SSR; the page is a normal client-rendered gradio app.
# mcp_server=True publishes the API endpoints as MCP tools at /gradio_api/mcp/sse.
# HF already installs gradio[oauth,mcp], so this needs no extra dependency.
demo.queue(max_size=12).launch(ssr_mode=False, mcp_server=True)