Ugo96's picture
Deploy Proteoform Analyzer (ZeroGPU) 2026-07-26T14:47:30Z
eb23a18
Raw
History Blame Contribute Delete
107 kB
"""Gradio web GUI for the Proteoform Analyzer.
Three tabs (Setup / Run / Results) built with gradio.Blocks so it can be deployed
as a Hugging Face Space / webapp with no refactor. Shares the same
``run_analysis(config)`` entry point as the CLI.
The Results tab is organized into subsections per result type, each with a
searchable dataframe, plus an embedded 3Dmol.js PDB viewer, MD trajectory overlay,
and interactive PCN centrality/community visualization.
"""
from __future__ import annotations
import html
import os
import sys
import io
import ast
import json
import re
import time
import base64
import logging
import threading
import traceback
import gradio as gr
import pandas as pd
from .core.config import (
AnalysisConfig, EngineChoice, ProteoformMode, BindingSiteMethod,
PTMConfig, MDConfig, Boltz2Config, AntibodyConfig, HotspotSource,
hemoglobin_fast_config, ttr_fast_config, p53_fast_config,
PRESETS,
)
from .core.pipeline import run_analysis, STEP_REGISTRY
from .core import viz
log = logging.getLogger("proteoform_analyzer.gui")
def _html_iframe(html_path, height=560):
"""Embed a saved interactive Plotly .html file in a sandboxed iframe."""
if not html_path or not os.path.exists(html_path):
return "<p>No plot available yet. Run the pipeline and click Refresh.</p>"
with open(html_path) as f:
doc = f.read()
escaped = html.escape(doc, quote=True)
return (f'<iframe srcdoc="{escaped}" width="100%" height="{height}" '
f'style="border:1px solid #ccc; border-radius:6px;" '
f'sandbox="allow-scripts allow-same-origin"></iframe>')
# ---------------------------------------------------------------------------
# Config builder
# ---------------------------------------------------------------------------
def _build_config(
uniprot_ids, n_subunits, stoichiometry, proteoform_mode, proteoform_cap,
mutations_text, max_mutations, structure_source, local_pdb_id,
ptm_residues, ptm_types, md_engine, md_steps,
docking_engine, ligand_design_engine, binding_site_method,
ensemble_docking, selected_steps,
antibody_enabled=True, antibody_framework="nanobody",
antibody_hotspot_source="user", antibody_hotspots="", antibody_num_designs=20,
boltz_api_key="", boltz_prefer_local=True,
boltz_allow_graft=True,
):
"""Build an AnalysisConfig from GUI widget values."""
ids = [u.strip() for u in uniprot_ids.split(",") if u.strip()]
mutations = []
if mutations_text.strip():
for block in mutations_text.split("|"):
mutations.append([m.strip() for m in block.split() if m.strip()])
stoich = [int(x.strip()) for x in stoichiometry.split(",") if x.strip()] if stoichiometry.strip() else [int(n_subunits)]
ptm_res = [r.strip() for r in ptm_residues.split(",") if r.strip()] if ptm_residues else []
ptm_typ = [t.strip() for t in ptm_types.split(",") if t.strip()] if ptm_types else \
[]
steps = [s.strip() for s in selected_steps] if selected_steps else list(STEP_REGISTRY.keys())
# Antibody (RFAntibody) — opt-in; add the 'antibody' step when enabled.
hotspots = [h.strip() for h in (antibody_hotspots or "").split(",") if h.strip()]
antibody_cfg = AntibodyConfig(
enabled=bool(antibody_enabled),
framework=antibody_framework,
hotspot_source=antibody_hotspot_source,
hotspot_residues=hotspots,
num_designs=int(antibody_num_designs),
)
if antibody_enabled and "antibody" not in steps:
steps = steps + ["antibody"]
# Boltz-2 backend config (folding, docking, binder design share one resolver:
# API key -> local -> graft fallback (folding only).
boltz2_cfg = Boltz2Config(
api_key=(boltz_api_key or None),
prefer_local=bool(boltz_prefer_local),
allow_graft_fallback=bool(boltz_allow_graft),
)
config = AnalysisConfig(
uniprot_ids=ids,
n_subunits=int(n_subunits),
subunit_stoichiometry=stoich,
mutations=mutations,
max_mutations=int(max_mutations),
proteoform_mode=proteoform_mode,
proteoform_cap=int(proteoform_cap),
structure_source=structure_source,
local_pdb_id=local_pdb_id or None,
antibody=antibody_cfg,
boltz2=boltz2_cfg,
run_ptm="ptm" in steps,
run_md="md" in steps,
md=MDConfig(engine=md_engine, production_steps=int(md_steps)),
docking_engine=docking_engine,
ligand_design_engine=ligand_design_engine,
binding_site_method=binding_site_method,
ensemble_docking=ensemble_docking,
ptm=PTMConfig(residues=ptm_res, ptm_types=ptm_typ),
steps=steps,
)
return config
# ---------------------------------------------------------------------------
# Preset selector callback
# ---------------------------------------------------------------------------
def _load_preset(preset_name):
"""Load a preset config and return values for all Setup widgets."""
if not preset_name or preset_name == "Custom (manual)":
return (gr.update(),) * 18 # no change to any widget
fn = PRESETS.get(preset_name)
if not fn:
return (gr.update(),) * 18
cfg = fn()
# Build widget values from config
uniprot_str = ",".join(cfg.uniprot_ids)
stoich_str = ",".join(str(s) for s in cfg.subunit_stoichiometry)
# Mutations: pipe-separated per subunit
mut_blocks = []
for mut_list in cfg.mutations:
mut_blocks.append(" ".join(mut_list))
mutations_str = "|".join(mut_blocks) if mut_blocks else ""
max_mut = str(cfg.max_mutations) if cfg.max_mutations is not None else "-1"
ptm_res_str = ",".join(cfg.ptm.residues)
ptm_types_str = ",".join(cfg.ptm.ptm_types)
md_steps = str(cfg.md.production_steps)
binding_site = cfg.binding_site_method
ensemble = cfg.ensemble_docking
steps = cfg.steps
return (
uniprot_str, # uniprot_ids
cfg.n_subunits, # n_subunits
stoich_str, # stoichiometry
cfg.proteoform_mode, # proteoform_mode
cfg.proteoform_cap, # proteoform_cap
mutations_str, # mutations_text
int(max_mut), # max_mutations
cfg.structure_source, # structure_source
cfg.local_pdb_id or "", # local_pdb_id
ptm_res_str, # ptm_residues
ptm_types_str, # ptm_types
cfg.md.engine, # md_engine
int(md_steps), # md_steps
cfg.docking_engine, # docking_engine
cfg.ligand_design_engine, # ligand_design_engine
binding_site, # binding_site_method
ensemble, # ensemble_docking
steps, # selected_steps
)
def _find_structure_pdb(results_dir, structure_name):
"""Find a PDB file for a structure in pdbs or proteoforms directory."""
for subdir in ["pdbs/tetramer", "pdbs/monomer", "proteoforms"]:
path = os.path.join(results_dir, subdir, f"{structure_name}.pdb")
if os.path.exists(path):
return path
return None
# ---------------------------------------------------------------------------
# Fix 1: Live log streaming via generator + background thread
# ---------------------------------------------------------------------------
def _run_pipeline_threaded(config, log_buf, status_state):
"""Run the pipeline in a background thread, appending logs to log_buf."""
def _emit(step, status, message):
flag = {"ok": "[+]", "skipped": "[~]", "failed": "[!]"}.get(status, "[?]")
log_buf.append(f"{flag} {step}: {message}")
config.progress_callback = _emit
try:
results = run_analysis(config)
rows = []
for r in results:
flag = {"ok": "OK", "skipped": "SKIP", "failed": "FAIL"}.get(r.status, "?")
rows.append([flag, r.step, r.message, f"{r.elapsed_s:.1f}s", len(r.outputs)])
status_state["results"] = rows
status_state["done"] = True
pd.DataFrame(rows, columns=["status", "step", "message", "elapsed_s", "n_outputs"]).to_csv(
os.path.join(config.results_dir(), "step_status.csv"), index=False)
status_state["results_dir"] = config.results_dir()
except Exception as e:
log_buf.append(f"[!] FATAL: {e}")
log_buf.append(traceback.format_exc())
status_state["done"] = True
status_state["error"] = str(e)
def run_from_gui(
preset_name,
uniprot_ids, n_subunits, stoichiometry, proteoform_mode, proteoform_cap,
mutations_text, max_mutations, structure_source, local_pdb_id,
ptm_residues, ptm_types, md_engine, md_steps,
docking_engine, ligand_design_engine, binding_site_method,
ensemble_docking, selected_steps,
antibody_enabled, antibody_framework, antibody_hotspot_source,
antibody_hotspots, antibody_num_designs,
boltz_api_key, boltz_prefer_local, boltz_allow_graft,
state,
):
"""Gradio generator handler: streams live log + status table in real time."""
# If a preset is selected, use it directly (avoids re-parsing text fields).
# Antibody design is opt-in and not part of any preset, so honour the antibody
# widgets even when a preset is chosen by enabling it on the preset config.
if preset_name and preset_name != "Custom (manual)" and preset_name in PRESETS:
config = PRESETS[preset_name]()
if antibody_enabled:
hotspots = [h.strip() for h in (antibody_hotspots or "").split(",") if h.strip()]
config.antibody = AntibodyConfig(
enabled=True, framework=antibody_framework,
hotspot_source=antibody_hotspot_source, hotspot_residues=hotspots,
num_designs=int(antibody_num_designs),
)
if "antibody" not in config.steps:
config.steps = list(config.steps) + ["antibody"]
# Honour the Boltz backend widgets on top of the preset too, so users can
# supply an API key / toggle prefer-local / graft without leaving the
# preset.
config.boltz2.api_key = (boltz_api_key or None)
config.boltz2.prefer_local = bool(boltz_prefer_local)
config.boltz2.allow_graft_fallback = bool(boltz_allow_graft)
else:
config = _build_config(
uniprot_ids, n_subunits, stoichiometry, proteoform_mode, proteoform_cap,
mutations_text, max_mutations, structure_source, local_pdb_id,
ptm_residues, ptm_types, md_engine, md_steps,
docking_engine, ligand_design_engine, binding_site_method,
ensemble_docking, selected_steps,
antibody_enabled, antibody_framework, antibody_hotspot_source,
antibody_hotspots, antibody_num_designs,
boltz_api_key, boltz_prefer_local, boltz_allow_graft,
)
log_buf = []
new_state = {"done": False, "results": [], "results_dir": config.results_dir()}
# Start pipeline in background thread
thread = threading.Thread(target=_run_pipeline_threaded,
args=(config, log_buf, new_state), daemon=True)
thread.start()
# Stream logs until done
while True:
time.sleep(0.5)
log_text = "\n".join(log_buf)
results_df = new_state.get("results", [])
yield log_text, results_df, new_state
if new_state.get("done"):
break
thread.join(timeout=5)
log_text = "\n".join(log_buf)
results_df = new_state.get("results", [])
yield log_text, results_df, new_state
# ---------------------------------------------------------------------------
# Results tab helpers
# ---------------------------------------------------------------------------
def _find_csv(results_dir, *patterns):
if not results_dir or not os.path.isdir(results_dir):
return None
for root, _, fnames in os.walk(results_dir):
for f in fnames:
if f.endswith(".csv"):
for pat in patterns:
if pat in f:
return os.path.join(root, f)
return None
def _find_files(results_dir, ext):
out = []
if not results_dir or not os.path.isdir(results_dir):
return out
for root, _, fnames in os.walk(results_dir):
for f in fnames:
if f.endswith(ext):
full = os.path.join(root, f)
rel = os.path.relpath(full, results_dir)
out.append((rel, full))
return sorted(out)
def _load_csv(path):
if not path or not os.path.exists(path):
return []
df = pd.read_csv(path)
return [list(df.columns)] + df.values.tolist()
def _load_csv_fmt(path, decimals_by_col=None):
"""Like ``_load_csv`` but formats chosen numeric columns to a fixed number
of decimals for DISPLAY only (the CSV on disk keeps full precision).
``decimals_by_col`` maps column name -> number of decimals. Used so metrics
with small meaningful differences (TM-scores) show enough resolution in the
table instead of being visually rounded.
"""
if not path or not os.path.exists(path):
return []
df = pd.read_csv(path)
decimals_by_col = decimals_by_col or {}
for col, nd in decimals_by_col.items():
if col in df.columns:
num = pd.to_numeric(df[col], errors="coerce")
df[col] = [format(v, f".{nd}f") if pd.notna(v) else df[col].iloc[i]
for i, v in enumerate(num)]
return [list(df.columns)] + df.values.tolist()
def _df_to_table_fmt(df, decimals_by_col=None):
"""Convert a DataFrame to the ``[[headers], [row], ...]`` table format used
by ``gr.Dataframe``, formatting chosen numeric columns to a fixed number of
decimals for DISPLAY (the CSV on disk is untouched)."""
if df is None or len(df) == 0:
return []
d = df.copy()
decimals_by_col = decimals_by_col or {}
for col, nd in decimals_by_col.items():
if col in d.columns:
num = pd.to_numeric(d[col], errors="coerce")
d[col] = [format(v, f".{nd}f") if pd.notna(v) else d[col].iloc[i]
for i, v in enumerate(num)]
return [list(d.columns)] + d.values.tolist()
def _pdb_viewer_label(rel_path):
"""Build a readable, grouped dropdown label for a discovered PDB.
Distinguishes docked receptor+ligand complexes and designed antibody
complexes from plain (receptor-only) structures so the user can pick the
one that actually contains a ligand / antibody. The dropdown VALUE stays the
relative path; only the shown label changes.
"""
rp = rel_path.replace("\\", "/")
base = os.path.basename(rp).replace(".pdb", "")
low = rp.lower()
# Docked receptor+ligand complex (written by the docking step to
# docking*/complexes/<structure>__<ligand>_complex.pdb)
if "complexes/" in low and base.endswith("_complex"):
stem = base[: -len("_complex")]
engine = "Boltz-2" if "boltz" in low else "Vina"
if "__" in stem:
struct, lig = stem.split("__", 1)
return f"[docked · {engine}] {struct} + {lig}"
return f"[docked · {engine}] {stem}"
# Designed antibody complex (antibody/designs/*.pdb from RFAntibody)
if "antibody/designs/" in low or "/designs/" in low and "antibody" in low:
return f"[designed antibody] {base}"
# Reference / co-crystal PDB (4-char PDB id like 5e83, 4dst)
if re.match(r"^[0-9][a-z0-9]{3}$", base.lower()):
return f"[reference PDB] {base.upper()}"
# Plain structures grouped by subdir
if "pdbs/tetramer/" in low:
return f"[structure · tetramer] {base}"
if "pdbs/monomer/" in low:
return f"[structure · monomer] {base}"
if "proteoforms/" in low:
return f"[proteoform] {base}"
return base
def _pdb_viewer_choices(pdb_files):
"""Return ``[(label, rel_path), …]`` for the structure-viewer dropdown.
Complexes (docked / antibody) are listed FIRST so a user looking for the
ligand or designed antibody finds them immediately, then plain structures,
then reference PDBs. ``pdb_files`` is the output of ``_find_files(rd, '.pdb')``.
"""
def _group_rank(rel):
low = rel.lower()
base = os.path.basename(low).replace(".pdb", "")
if "complexes/" in low and base.endswith("_complex"):
return 0 # docked complexes first
if "/designs/" in low:
return 1 # designed antibodies
if re.match(r"^[0-9][a-z0-9]{3}$", base):
return 3 # reference PDBs last
return 2 # plain structures
choices = []
for rel, _full in sorted(pdb_files, key=lambda t: (_group_rank(t[0]), t[0])):
choices.append((_pdb_viewer_label(rel), rel))
return choices
# ---------------------------------------------------------------------------
# Fix 5: 3Dmol.js viewer with proper script execution
# ---------------------------------------------------------------------------
_3DMOL_VIEWER_COUNTER = [0]
def _3dmol_html(pdb_text, style_script, width=500, height=400):
if not pdb_text:
return "<p>No PDB data.</p>"
lines = pdb_text.split("\n")
if len(lines) > 8000:
pdb_text = "\n".join(lines[:8000]) + "\nEND"
b64 = base64.b64encode(pdb_text.encode()).decode()
_3DMOL_VIEWER_COUNTER[0] += 1
vid = f"viewer_{_3DMOL_VIEWER_COUNTER[0]}_{int(time.time()*1000) % 1000000}"
inner_doc = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script src="https://3Dmol.org/build/3Dmol-min.js"></script>
<style>html,body{{margin:0;padding:0;}}</style>
</head>
<body>
<div id="{vid}" style="width:{width}px; height:{height}px;"></div>
<script>
window.onload = function() {{
try {{
var element = document.getElementById('{vid}');
var viewer = $3Dmol.createViewer(element, {{backgroundColor: 'white'}});
var pdbData = atob('{b64}');
viewer.addModel(pdbData, 'pdb');
{style_script}
viewer.zoomTo();
viewer.render();
viewer.zoom(1.2, 800);
}} catch(e) {{
document.body.innerHTML = '<p style="color:red;">3Dmol error: ' + e.message + '</p>';
}}
}};
</script>
</body>
</html>
"""
escaped = html.escape(inner_doc, quote=True)
return f'<iframe srcdoc="{escaped}" width="{width}" height="{height}" style="border:1px solid #ccc; border-radius:6px;" sandbox="allow-scripts allow-same-origin"></iframe>'
_STD_AA = {
"ALA", "ARG", "ASN", "ASP", "CYS", "GLN", "GLU", "GLY", "HIS", "ILE",
"LEU", "LYS", "MET", "PHE", "PRO", "SER", "THR", "TRP", "TYR", "VAL",
"MSE", "SEC", "PYL", "HSD", "HSE", "HSP",
}
_WATER = {"HOH", "WAT", "TIP", "TIP3", "SOL", "H2O"}
# Immunoglobulin chain IDs commonly emitted by RFAntibody/ImmuneBuilder outputs
_AB_CHAINS = {"H", "L"}
# Colorblind-safe qualitative palette (Okabe-Ito, 8 colors) for coloring protein
# cartoons by chain ID. Distinguishable under the common forms of colour-vision
# deficiency. Cycled if a structure has more chains than colours.
_CHAIN_PALETTE = [
"#0072B2", # blue
"#E69F00", # orange
"#009E73", # bluish green
"#CC79A7", # reddish purple
"#56B4E9", # sky blue
"#D55E00", # vermillion
"#F0E442", # yellow
"#000000", # black
]
def _chain_color_map(chains):
"""Map an iterable of chain IDs -> hex colors using the colorblind-safe
``_CHAIN_PALETTE`` (cycled). Returns an ordered dict keyed by chain ID
(sorted) so the 3D coloring and the legend agree."""
out = {}
for i, ch in enumerate(sorted(str(c) for c in chains if str(c).strip())):
out[ch] = _CHAIN_PALETTE[i % len(_CHAIN_PALETTE)]
return out
def _chain_legend_html(chain_colors, extra_items=None):
"""Build a compact HTML legend (colored chips + labels) for a chain->color
map. ``extra_items`` is an optional list of (label, color) tuples appended
after the chains (e.g. ligands)."""
chips = []
for ch, col in chain_colors.items():
chips.append(
f"<span style=\"display:inline-flex;align-items:center;margin:2px 10px 2px 0;"
f"font-size:0.85em;\"><span style=\"display:inline-block;width:12px;height:12px;"
f"background:{col};border:1px solid #888;border-radius:2px;margin-right:5px;\">"
f"</span>Chain {ch}</span>")
for label, col in (extra_items or []):
chips.append(
f"<span style=\"display:inline-flex;align-items:center;margin:2px 10px 2px 0;"
f"font-size:0.85em;\"><span style=\"display:inline-block;width:12px;height:12px;"
f"background:{col};border:1px solid #888;border-radius:2px;margin-right:5px;\">"
f"</span>{label}</span>")
if not chips:
return ""
return ("<div style=\"margin-top:6px;padding:4px 2px;line-height:1.6;\">"
"<b style=\"font-size:0.85em;color:#444;\">Chains:</b>&nbsp;"
+ "".join(chips) + "</div>")
def _classify_pdb_contents(pdb_text):
"""Inspect a PDB and report which molecule types are present.
Returns dict with sets: ``protein_chains``, ``ligand_resns``,
``ligand_chains``, plus booleans ``has_protein``, ``has_ligand``,
``looks_like_antibody``.
"""
protein_chains, ligand_resns, ligand_chains = set(), set(), set()
for line in pdb_text.splitlines():
rec = line[:6].strip()
if rec == "ATOM":
ch = line[21:22].strip()
if ch:
protein_chains.add(ch)
elif rec == "HETATM":
resn = line[17:20].strip()
ch = line[21:22].strip()
if resn in _WATER:
continue
if resn in _STD_AA: # modified residue treated as protein
if ch:
protein_chains.add(ch)
continue
ligand_resns.add(resn)
if ch:
ligand_chains.add(ch)
looks_ab = bool(protein_chains) and _AB_CHAINS.issubset(protein_chains)
return {
"protein_chains": protein_chains,
"ligand_resns": ligand_resns,
"ligand_chains": ligand_chains,
"has_protein": bool(protein_chains),
"has_ligand": bool(ligand_resns),
"looks_like_antibody": looks_ab,
}
def _pdb_to_html_viewer(pdb_path, width=500, height=400):
"""Molecule-type-aware 3Dmol viewer.
Styling rules:
* protein -> cartoon coloured **by chain ID** (colorblind-safe
palette) with a chain legend
* ligands / hetero -> licorice/stick + coloured by element
* antibody complexes -> cartoon by chain (H / L / antigen) + sticks on the
antigen interface residues near the H/L chains
Cartoons are coloured per chain (user request: "color cartoons by chain ID").
A small HTML legend below the viewer maps each chain to its colour.
"""
if not pdb_path or not os.path.exists(pdb_path):
return "<p>No PDB file selected.</p>"
with open(pdb_path, "r") as f:
pdb_text = f.read()
info = _classify_pdb_contents(pdb_text)
parts = []
legend_html = ""
if info["looks_like_antibody"]:
# Antibody: semantic per-chain colours (H / L / antigen) + interface
# sticks. This is still "colour by chain", with meaningful labels.
antigen_chains = sorted(info["protein_chains"] - _AB_CHAINS)
parts.append("viewer.setStyle({}, {cartoon: {color: 'spectrum'}});")
parts.append("viewer.setStyle({chain: 'H'}, {cartoon: {color: '#0279EE'}});")
parts.append("viewer.setStyle({chain: 'L'}, {cartoon: {color: '#75A025'}});")
legend_items = {"H (heavy)": "#0279EE", "L (light)": "#75A025"}
if antigen_chains:
ag = "[" + ",".join(f"'{c}'" for c in antigen_chains) + "]"
parts.append(f"viewer.setStyle({{chain: {ag}}}, {{cartoon: {{color: '#FF9400'}}}});")
# interface sticks: antigen residues within 5A of the H/L chains
parts.append(
f"viewer.addStyle({{chain: {ag}, within: {{distance: 5, sel: {{chain: ['H','L']}}}}}}, "
"{stick: {radius: 0.2, colorscheme: 'orangeCarbon'}});")
legend_items["antigen (" + ",".join(antigen_chains) + ")"] = "#FF9400"
# Build a legend from the semantic antibody colours.
extra = [("ligand", "#2ca02c")] if info["has_ligand"] else None
legend_html = _chain_legend_html(dict(legend_items), extra_items=extra)
else:
# General protein: colour each chain a distinct colorblind-safe colour.
chain_colors = _chain_color_map(info["protein_chains"])
if chain_colors:
# default so any unclassified atoms still get a cartoon
parts.append("viewer.setStyle({}, {cartoon: {color: 'spectrum'}});")
for ch, col in chain_colors.items():
parts.append(
f"viewer.setStyle({{chain: '{ch}'}}, {{cartoon: {{color: '{col}'}}}});")
else:
parts.append("viewer.setStyle({}, {cartoon: {color: 'spectrum'}});")
extra = [("ligand", "#2ca02c")] if info["has_ligand"] else None
legend_html = _chain_legend_html(chain_colors, extra_items=extra)
if info["has_ligand"]:
resns = "[" + ",".join(f"'{r}'" for r in sorted(info["ligand_resns"])) + "]"
# ligands as licorice (thick sticks) + ball, coloured by element
parts.append(
f"viewer.setStyle({{resn: {resns}}}, "
"{stick: {radius: 0.25, colorscheme: 'greenCarbon'}, "
"sphere: {scale: 0.28}});")
style = "\n ".join(parts)
viewer_html = _3dmol_html(pdb_text, style, width, height)
if legend_html:
return (f"<div style=\"width:{width}px;\">{viewer_html}{legend_html}</div>")
return viewer_html
# ---------------------------------------------------------------------------
# Fix 3: PCN interactive visualization helpers
# ---------------------------------------------------------------------------
# PCN file path patterns (generated by pcn_miner's save functions)
_CENTRALITY_MEASURES = ["betweenness", "closeness", "degree_c", "eigenvector_c"]
_CENTRALITY_LABELS = {
"betweenness": "Betweenness",
"closeness": "Closeness",
"degree_c": "Degree",
"eigenvector_c": "Eigenvector",
}
_COMMUNITY_ALGOS = ["louvain", "leiden", "infomap"]
# Color palette for communities (up to 20 distinct colors)
_COMMUNITY_COLORS = [
"#e6194b", "#3cb44b", "#ffe119", "#4363d8", "#f58231",
"#911eb4", "#46f0f0", "#f032e6", "#bcf60c", "#fabebe",
"#008080", "#e6beff", "#9a6324", "#fffac8", "#800000",
"#aaffc3", "#808000", "#ffd8b1", "#000075", "#808080",
]
def _parse_pcn_dict(filepath):
"""Parse a PCN output file (Python dict repr with np.str_ keys)."""
if not filepath or not os.path.exists(filepath):
return {}
with open(filepath) as f:
content = f.read()
# Strip np.str_('...') -> '...'
cleaned = re.sub(r"np\.str_\(['\"]([^'\"]+)['\"]\)", r"'\1'", content)
try:
return ast.literal_eval(cleaned)
except Exception:
return {}
def _parse_residue_label(label):
"""Parse 'VAL1 A' -> ('VAL', 1, 'A') or None."""
m = re.match(r"([A-Z]+)(\d+)\s+([A-Z])", label.strip())
if m:
return m.group(1), int(m.group(2)), m.group(3)
return None
def _find_centrality_file(pcn_dir, measure, structure):
"""Find the centrality file for a given measure and structure."""
# Pattern: Centralities/{measure}{measure}Centralities/{measure}/Txt/{structure}_{measure}.txt
path = os.path.join(pcn_dir, "Centralities", f"{measure}{measure}Centralities",
measure, "Txt", f"{structure}_{measure}.txt")
if os.path.exists(path):
return path
# Fallback: search for it
for root, _, fnames in os.walk(os.path.join(pcn_dir, "Centralities")):
target = f"{structure}_{measure}.txt"
if target in fnames:
return os.path.join(root, target)
return None
def _find_community_file(pcn_dir, algo, structure):
"""Find the community labels file for a given algorithm and structure."""
# Pattern: Communities/{algo}{algo}/Communities/{structure}_Communities_{algo}_ncoms{N}.txt
base = os.path.join(pcn_dir, "Communities", f"{algo}{algo}", "Communities")
if not os.path.isdir(base):
# Fallback: search
for root, _, fnames in os.walk(os.path.join(pcn_dir, "Communities")):
for f in fnames:
if f.startswith(f"{structure}_Communities_{algo}_ncoms"):
return os.path.join(root, f)
return None
# There may be multiple files (different ncoms from re-runs); pick the first
candidates = [f for f in os.listdir(base)
if f.startswith(f"{structure}_Communities_{algo}_ncoms")]
if candidates:
return os.path.join(base, sorted(candidates)[0])
return None
def _find_wt_structure(structures):
"""Find the WT structure name from a list of structure names."""
for name in structures:
if name.lower() in ("wt", "wt-tetramer", "wt-monomer"):
return name
for name in structures:
if name.lower().startswith("wt"):
return name
# Fallback: look for a PDB ID-like name (e.g. 1a3n)
for name in structures:
if re.match(r"^[0-9][a-z0-9]{3}$", name.lower()):
return name
return structures[0] if structures else None
def _diverging_color(value):
"""Map a value in [-1, 1] to a blue-white-red diverging color hex.
-1 = blue (#2166ac), 0 = white (#f7f7f7), +1 = red (#b2182b).
"""
v = max(-1.0, min(1.0, value))
if v >= 0:
# white -> red
r = int(247 + (178 - 247) * v)
g = int(247 + (24 - 247) * v)
b = int(247 + (43 - 247) * v)
else:
# white -> blue
r = int(247 + (33 - 247) * (-v))
g = int(247 + (102 - 247) * (-v))
b = int(247 + (172 - 247) * (-v))
return f"#{r:02x}{g:02x}{b:02x}"
def _structure_provenance_banner(rd):
"""Return a Markdown warning banner (or "") describing how structures were
produced. When structures were built by ptm-psi side-chain grafting (no
folding backend available), show a prominent red/bold warning so users know
the backbone is identical to WT (TM-scores == 1.0 by construction).
Reads ``structure_provenance.json`` (keys: method, backbone_identical,
detail) written by the folding step via ``write_structure_provenance``.
"""
if not rd:
return ""
p = os.path.join(rd, "structure_provenance.json")
if not os.path.exists(p):
return ""
try:
with open(p) as f:
prov = json.load(f)
except Exception:
return ""
method = str(prov.get("method", "")).lower()
detail = str(prov.get("detail", "")).strip()
if method == "graft":
try:
from .core.steps._boltz_backend import GRAFT_WARNING
except Exception:
GRAFT_WARNING = (
"Structures were built by ptm-psi side-chain grafting onto the "
"wild-type backbone because no folding backend was available. "
"The backbone is IDENTICAL to WT for every variant, so "
"TM-scores are 1.0 by construction and there is no structural, "
"pocket, or docking signal.")
msg = (
"<div style=\"background:#fdecea;border:2px solid #b2182b;"
"border-radius:6px;padding:10px 14px;\">"
"\u26a0\ufe0f&nbsp;<b style=\"color:#b2182b;\">GRAFT FALLBACK "
"\u2014 backbone-identical structures.</b><br>"
f"<span style=\"color:#7a1a12;\">{GRAFT_WARNING}</span>")
if detail:
msg += f"<br><span style=\"color:#7a1a12;font-size:0.9em;\">{detail}</span>"
msg += "</div>"
return msg
# Real folding backends: a short, non-alarming provenance note.
label = {"api": "Boltz-2 API (api.boltz.bio)",
"local": "local boltz install"}.get(method, method or "unknown")
return (f"<div style=\"background:#eef7ee;border:1px solid #75A025;"
f"border-radius:6px;padding:8px 12px;\">\u2705 Structures folded via "
f"<b>{label}</b>. TM-score / pocket / docking signals are meaningful."
+ (f"<br><span style=\"font-size:0.9em;\">{detail}</span>" if detail else "")
+ "</div>")
def _pocket_warning_banner(rd):
"""Return a Markdown warning banner (or "") for the pocket step.
Reads ``pocket_warnings.json`` (keys: binding_site_method, all_volumes_zero,
warning) written by the pocket step when ``binding_site_method == 'reference'``
(fixed reference box -> volume/drift always 0) or when every detector
produced a zero volume. Surfaces the same warning shown in the step message
inside the Pocket Prediction and Pocket Drift sections of the Results tab.
"""
if not rd:
return ""
# pocket_warnings.json is written next to pocket_predictions.csv. Look in the
# results dir and common pocket subdirectories.
candidates = [os.path.join(rd, "pocket_warnings.json"),
os.path.join(rd, "pocket", "pocket_warnings.json"),
os.path.join(rd, "pockets", "pocket_warnings.json")]
warn_path = next((c for c in candidates if os.path.exists(c)), None)
if warn_path is None:
# Fall back to a recursive search (results trees are small).
for root, _, fnames in os.walk(rd):
if "pocket_warnings.json" in fnames:
warn_path = os.path.join(root, "pocket_warnings.json")
break
if warn_path is None:
return ""
try:
with open(warn_path) as f:
w = json.load(f)
except Exception:
return ""
text = str(w.get("warning", "")).strip()
if not text:
return ""
method = w.get("binding_site_method")
heading = ("Binding-site method = 'reference'"
if method == "reference" else "Pocket detection limitation")
return ("<div style=\"background:#fff4e5;border:2px solid #FF9400;"
"border-radius:6px;padding:10px 14px;\">"
f"\u26a0\ufe0f&nbsp;<b style=\"color:#a15c00;\">{heading} \u2014 "
"pocket prediction and pocket drift may not change.</b><br>"
f"<span style=\"color:#7a4a00;\">{text}</span></div>")
def _get_cmap(name):
"""Return a matplotlib colormap by name, compatible across matplotlib
versions. ``matplotlib.cm.get_cmap`` was removed in matplotlib 3.9; the
modern access is ``matplotlib.colormaps[name]``."""
import matplotlib
try:
return matplotlib.colormaps[name] # matplotlib >= 3.5 (preferred)
except Exception:
import matplotlib.cm as _cm
return _cm.get_cmap(name) # legacy fallback
def _viridis_color(value):
"""Map a value in [0, 1] to a viridis hex color (perceptually-uniform,
colorblind-friendly). Uses matplotlib's viridis colormap."""
import matplotlib.colors as _mcolors
v = max(0.0, min(1.0, float(value)))
rgba = _get_cmap("viridis")(v)
return _mcolors.to_hex(rgba)
def _colorbar_svg(vmin, vmax, label, cmap="viridis", diverging=False, width=260, height=54):
"""Return a small inline SVG colorbar (gradient + min/mid/max ticks).
``diverging=True`` uses the blue-white-red delta scale (symmetric about 0);
otherwise a sequential viridis ramp from ``vmin`` to ``vmax``.
"""
n = 32
stops = []
for i in range(n + 1):
f = i / n
if diverging:
col = _diverging_color(2.0 * f - 1.0) # map [0,1] -> [-1,1]
else:
col = _viridis_color(f)
stops.append(f'<stop offset="{f*100:.1f}%" stop-color="{col}"/>')
grad = "".join(stops)
if diverging:
lo, mid, hi = f"{vmin:.3g}", "0", f"{vmax:.3g}"
else:
lo, mid, hi = f"{vmin:.3g}", f"{(vmin+vmax)/2:.3g}", f"{vmax:.3g}"
bar_w = width - 20
fam = "Liberation Sans, Arimo, DejaVu Sans, sans-serif"
return (
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
f'style="font-family:{fam};">'
f'<defs><linearGradient id="cbgrad" x1="0%" y1="0%" x2="100%" y2="0%">{grad}'
f'</linearGradient></defs>'
f'<text x="10" y="12" font-size="11" font-weight="bold">{html.escape(label)}</text>'
f'<rect x="10" y="18" width="{bar_w}" height="14" fill="url(#cbgrad)" '
f'stroke="#888" stroke-width="0.5"/>'
f'<text x="10" y="46" font-size="10">{lo}</text>'
f'<text x="{10+bar_w/2:.0f}" y="46" font-size="10" text-anchor="middle">{mid}</text>'
f'<text x="{10+bar_w:.0f}" y="46" font-size="10" text-anchor="end">{hi}</text>'
f'</svg>')
def _pcn_centrality_html(structure, measure, state, width=480, height=400, view_mode="raw"):
"""3Dmol.js HTML colouring residues by PCN centrality.
``view_mode='raw'`` colours by the structure's own centrality on a viridis
scale (works for WT and mutants). ``view_mode='delta'`` colours by
(mutant - WT) on a blue-white-red diverging scale (mutants only). A small
inline SVG colorbar is prepended so the mapping is legible.
"""
rd = state.get("results_dir", "") if state else ""
if not rd:
return "<p>No results directory.</p>"
pcn_dir = os.path.join(rd, "pcn_outputs")
if not os.path.isdir(pcn_dir):
return "<p>PCN results not found. Run the PCN step first.</p>"
pdb_dir = os.path.join(rd, "pdbs", "tetramer")
if not os.path.isdir(pdb_dir):
pdb_dir = os.path.join(rd, "pdbs", "monomer")
all_structures = [f.replace(".pdb", "") for f in sorted(os.listdir(pdb_dir))
if f.endswith(".pdb")] if os.path.isdir(pdb_dir) else []
wt_name = _find_wt_structure(all_structures)
label_text = _CENTRALITY_LABELS.get(measure, measure)
struct_pdb_path = _find_structure_pdb(rd, structure)
if not struct_pdb_path:
return f"<p>PDB file not found: {structure}.pdb</p>"
with open(struct_pdb_path) as f:
pdb_text = f.read()
baseline = "viewer.setStyle({}, {cartoon: {color: 'lightgray'}});"
# ---- RAW mode: colour by the structure's own centrality (viridis) --------
if view_mode == "raw":
cent = _parse_pcn_dict(_find_centrality_file(pcn_dir, measure, structure))
if not cent:
return f"<p>Centrality data not found for {measure} ({structure}).</p>"
vals = [float(v) for v in cent.values()]
vmin, vmax = min(vals), max(vals)
span = (vmax - vmin) or 1.0
style_lines = []
for lab, val in cent.items():
parsed = _parse_residue_label(lab)
if not parsed:
continue
_resn, resi, chain = parsed
norm = (float(val) - vmin) / span
color = _viridis_color(norm)
style_lines.append(
f"viewer.setStyle({{chain:'{chain}',resi:{resi}}},"
f"{{cartoon:{{color:'{color}'}}}});")
style_script = "\n ".join([baseline] + style_lines)
viewer = _3dmol_html(pdb_text, style_script, width, height)
cbar = _colorbar_svg(vmin, vmax, f"{label_text} centrality ({structure})",
diverging=False, width=width - 10)
return f'<div style="margin-bottom:4px;">{cbar}</div>' + viewer
# ---- DELTA mode: colour by (mutant - WT) on a diverging scale -----------
if structure == wt_name:
return ("<p>Delta view needs a mutant (WT minus WT = 0). "
"Switch to 'Raw centrality' to view WT.</p>")
wt_cent = _parse_pcn_dict(_find_centrality_file(pcn_dir, measure, wt_name))
mut_cent = _parse_pcn_dict(_find_centrality_file(pcn_dir, measure, structure))
if not wt_cent or not mut_cent:
return f"<p>Centrality data not found for {measure} ({wt_name} or {structure}).</p>"
deltas = {}
for lab, val in mut_cent.items():
if lab in wt_cent:
deltas[lab] = float(val) - float(wt_cent[lab])
if not deltas:
return "<p>No matching residues between WT and mutant.</p>"
max_abs = max(abs(v) for v in deltas.values()) or 1.0
style_lines = []
for lab, delta in deltas.items():
parsed = _parse_residue_label(lab)
if not parsed:
continue
_resn, resi, chain = parsed
color = _diverging_color(delta / max_abs)
style_lines.append(
f"viewer.setStyle({{chain:'{chain}',resi:{resi}}},"
f"{{cartoon:{{color:'{color}'}}}});")
style_script = "\n ".join([baseline] + style_lines)
viewer = _3dmol_html(pdb_text, style_script, width, height)
cbar = _colorbar_svg(-max_abs, max_abs,
f"\u0394 {label_text} vs WT (blue=down, red=up)",
diverging=True, width=width - 10)
return f'<div style="margin-bottom:4px;">{cbar}</div>' + viewer
def _pcn_community_html(structure, algo, state, width=480, height=400):
"""Generate 3Dmol.js HTML showing community colors + changed residues vs WT."""
rd = state.get("results_dir", "") if state else ""
if not rd:
return "<p>No results directory.</p>"
pcn_dir = os.path.join(rd, "pcn_outputs")
if not os.path.isdir(pcn_dir):
return "<p>PCN results not found. Run the PCN step first.</p>"
# Colour the SELECTED structure by its own community assignment (single
# structure; no WT-difference overlay/white sticks — those were confusing and
# are dropped per the community-view request). WT can be viewed like any
# other structure.
comm = _parse_pcn_dict(_find_community_file(pcn_dir, algo, structure))
if not comm:
return f"<p>Community data not found for {algo} ({structure}).</p>"
struct_pdb_path = _find_structure_pdb(rd, structure)
if not struct_pdb_path:
return f"<p>PDB file not found: {structure}.pdb</p>"
with open(struct_pdb_path) as f:
pdb_text = f.read()
style_lines = []
for label, comm_id in comm.items():
parsed = _parse_residue_label(label)
if not parsed:
continue
_resn, resi, chain = parsed
color = _COMMUNITY_COLORS[int(comm_id) % len(_COMMUNITY_COLORS)]
style_lines.append(
f"viewer.setStyle({{chain:'{chain}',resi:{resi}}},"
f"{{cartoon:{{color:'{color}'}}}});")
# Global cartoon baseline FIRST so the whole protein is cartoon (not the
# 3Dmol default lines/licorice); per-community colored cartoon is layered on
# top.
baseline = "viewer.setStyle({}, {cartoon: {color: 'lightgray'}});"
style_script = "\n ".join([baseline] + style_lines)
n_comms = len(set(comm.values()))
fam = "Liberation Sans, Arimo, DejaVu Sans, sans-serif"
caption = (f'<div style="font-family:{fam};font-size:11px;margin-bottom:4px;">'
f'<b>{structure}</b>: {n_comms} communities ({algo}), '
f'coloured by community id</div>')
viewer = _3dmol_html(pdb_text, style_script, width, height)
return caption + viewer
def _centrality_delta_bar_file(rd, structure, measure):
"""Build (and cache) the top-10 |Δ centrality| signed bar plot PNG for a
structure vs WT. Returns the PNG path, or None when data is unavailable."""
if not rd:
return None
pcn_dir = os.path.join(rd, "pcn_outputs")
if not os.path.isdir(pcn_dir):
return None
pdb_dir = os.path.join(rd, "pdbs", "tetramer")
if not os.path.isdir(pdb_dir):
pdb_dir = os.path.join(rd, "pdbs", "monomer")
all_structures = [f.replace(".pdb", "") for f in sorted(os.listdir(pdb_dir))
if f.endswith(".pdb")] if os.path.isdir(pdb_dir) else []
wt_name = _find_wt_structure(all_structures)
if not wt_name or structure == wt_name:
return None
wt_cent = _parse_pcn_dict(_find_centrality_file(pcn_dir, measure, wt_name))
mut_cent = _parse_pcn_dict(_find_centrality_file(pcn_dir, measure, structure))
if not wt_cent or not mut_cent:
return None
plots_dir = os.path.join(pcn_dir, "delta_bars")
os.makedirs(plots_dir, exist_ok=True)
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", f"{structure}_{measure}")
stem = os.path.join(plots_dir, f"delta_{safe}")
try:
out = viz.centrality_delta_bars(wt_cent, mut_cent, measure, stem,
structure=structure, top_n=10)
except Exception as e:
log.warning("centrality_delta_bars failed for %s/%s: %s", structure, measure, e)
return None
return out.get("png") if out and out.get("n") else None
def pcn_visualize(structure, measure, algo, state, view_mode="raw"):
"""Callback for PCN visualize button.
Returns (centrality_html, community_html, legend, delta_bar_png).
"""
if not structure:
return ("<p>Select a structure.</p>", "<p>Select a structure.</p>",
"Select a structure.", None)
rd = state.get("results_dir", "") if state else ""
pcn_dir = os.path.join(rd, "pcn_outputs") if rd else ""
mode = "delta" if (view_mode or "").lower().startswith("d") else "raw"
cent_html = _pcn_centrality_html(structure, measure, state, view_mode=mode)
comm_html = _pcn_community_html(structure, algo, state)
# Top-10 |Δ centrality| bar plot (signed: red=increase, blue=decrease vs WT).
bar_file = _centrality_delta_bar_file(rd, structure, measure)
# Build legend text
measure_label = _CENTRALITY_LABELS.get(measure, measure)
pdb_dir = os.path.join(rd, "pdbs", "tetramer") if rd else ""
if not os.path.isdir(pdb_dir):
pdb_dir = os.path.join(rd, "pdbs", "monomer") if rd else ""
all_structures = [f.replace(".pdb", "") for f in sorted(os.listdir(pdb_dir))
if f.endswith(".pdb")] if os.path.isdir(pdb_dir) else []
wt_name = _find_wt_structure(all_structures)
comm = _parse_pcn_dict(_find_community_file(pcn_dir, algo, structure)) if pcn_dir else {}
n_communities = len(set(comm.values())) if comm else 0
if mode == "raw":
cent_desc = (f"Left: {measure_label} centrality of {structure} "
f"(viridis: purple = low, yellow = high).")
else:
cent_desc = (f"Left: {measure_label} centrality \u0394 vs WT "
f"(red = increased, blue = decreased).")
legend = (f"{cent_desc} Right: {algo} communities of {structure} "
f"({n_communities} communities, coloured by community id). "
f"Bar plot: top-10 residues by |\u0394 centrality| vs WT.")
return cent_html, comm_html, legend, bar_file
# ---------------------------------------------------------------------------
# MD trajectory overlay
# ---------------------------------------------------------------------------
def _clean_md_label(traj_rel):
"""Derive a readable structure label from a trajectory's relative path."""
label = os.path.basename(os.path.dirname(traj_rel))
label = label.replace("Mut_", "").replace("-tetramer", "").replace("_", " ")
if label.lower() in ("1a3n", "1f41", "1aie"):
label = "WT"
return label
def _md_series_from_selection(traj_rels, results_dir, kind):
"""Build viz.md_overlay series for the selected trajectories.
Prefers the per-structure ``rmsd.csv`` / ``rmsf.csv`` written by md.py
(fast, no re-load); falls back to recomputing from ``trajectory.pdb`` with
mdtraj if the CSV is absent (older runs).
"""
import numpy as np
series = []
csv_name = "rmsf.csv" if kind == "rmsf" else "rmsd.csv"
for traj_rel in traj_rels:
traj_dir = os.path.dirname(os.path.join(results_dir, traj_rel))
label = _clean_md_label(traj_rel)
csv_path = os.path.join(traj_dir, csv_name)
if os.path.exists(csv_path):
try:
d = pd.read_csv(csv_path)
if kind == "rmsf":
series.append({"label": label, "x": d["residue"].to_numpy(),
"y": d["rmsf_A"].to_numpy()})
else:
series.append({"label": label, "x": d["time_ps"].to_numpy(),
"y": d["rmsd_A"].to_numpy()})
continue
except Exception as e:
log.warning("Failed to read %s: %s", csv_path, e)
# Fallback: recompute from trajectory.pdb
full = os.path.join(results_dir, traj_rel)
if not os.path.exists(full):
continue
try:
import mdtraj
traj = mdtraj.load(full)
if kind == "rmsf":
y = mdtraj.rmsf(traj, traj, frame=0) * 10.0
x = np.arange(1, len(y) + 1)
else:
y = mdtraj.rmsd(traj, traj, 0) * 10.0
x = np.arange(len(y)) * 0.1
series.append({"label": label, "x": x, "y": y})
except Exception as e:
log.warning("Failed to load trajectory %s: %s", traj_rel, e)
return series
def _all_trajectory_rels(results_dir):
"""Return relative paths of every ``trajectory.pdb`` under the results dir.
The MD overlay now shows ALL structures at once and lets the user toggle
individual traces via the Plotly legend, so we no longer rely on a manual
selection widget.
"""
rels = []
if not results_dir or not os.path.isdir(results_dir):
return rels
for root, _, fnames in os.walk(results_dir):
for f in fnames:
if f == "trajectory.pdb":
rels.append(os.path.relpath(os.path.join(root, f), results_dir))
return sorted(rels)
def overlay_md_callback(kind, state):
"""Build an interactive RMSD or RMSF overlay of ALL MD trajectories.
Every structure with MD output is plotted as its own Plotly trace; the user
shows/hides individual structures through the Plotly legend (no server-side
selection needed).
"""
rd = state.get("results_dir", "") if state else ""
if not rd:
return "<p>No results directory. Run the pipeline first.</p>", "", "No results directory."
kind = "rmsf" if str(kind).lower().startswith("rmsf") else "rmsd"
traj_rels = _all_trajectory_rels(rd)
if not traj_rels:
return ("<p>No MD trajectories found. Run the 'md' step first.</p>", "",
"No MD trajectories found.")
series = _md_series_from_selection(traj_rels, rd, kind)
if not series:
return ("<p>No RMSD/RMSF data found.</p>", "",
"No data (missing CSVs and trajectories).")
plot_dir = os.path.join(rd, "_plots")
os.makedirs(plot_dir, exist_ok=True)
stem = os.path.join(plot_dir, f"md_overlay_{kind}")
out = viz.md_overlay(series, stem, kind=kind)
html_out = _html_iframe(out.get("html"), height=540)
static = out.get("svg") or out.get("png") or ""
n = out.get("n", 0)
return (html_out, static,
f"Interactive {kind.upper()} overlay of {n} structure(s) — "
f"toggle structures via the legend. "
f"Static file: {os.path.basename(static) if static else 'n/a'}")
# ---------------------------------------------------------------------------
# Data-plot builder (docking / pocket / per-subsection summaries)
# ---------------------------------------------------------------------------
def _read_csv_df(path):
if path and os.path.exists(path):
try:
return pd.read_csv(path)
except Exception as e:
log.warning("Failed to read %s: %s", path, e)
return None
def _find_exact_csv(results_dir, filename):
"""Find a CSV by exact basename (avoids substring collisions like
``docking_summary`` matching ``docking_summary_boltz2``)."""
if not results_dir or not os.path.isdir(results_dir):
return None
for root, _, fnames in os.walk(results_dir):
if filename in fnames:
return os.path.join(root, filename)
return None
def _find_exact_csv_glob(results_dir, pattern, exclude=None):
"""Find the first CSV whose basename matches a glob ``pattern`` (e.g.
``tm_scores_*.csv``), optionally skipping names containing ``exclude``
(e.g. ``_all.csv`` to avoid the pairwise matrix)."""
import fnmatch
if not results_dir or not os.path.isdir(results_dir):
return None
for root, _, fnames in os.walk(results_dir):
for f in sorted(fnames):
if fnmatch.fnmatch(f, pattern) and (not exclude or exclude not in f):
return os.path.join(root, f)
return None
# Component columns that make up the composite impact score. Used to decide
# whether an impact row is "all zero" (no measurable impact) so the GUI can
# drop it, per the requested behaviour.
_IMPACT_COMPONENT_COLS = ("structural", "binding", "dynamics", "network", "sequence")
def _impact_scores_df(rd):
"""Load the proteoform impact *scores* table (not the components sidecar).
``_find_csv(rd, "impact")`` would also match ``impact_score_components.csv``;
this prefers the actual scores file by exact basename first.
"""
path = (_find_exact_csv(rd, "proteoform_impact_scores.csv")
or _find_csv(rd, "proteoform_impact_scores")
or _find_csv(rd, "impact_scores")
or _find_csv(rd, "impact"))
# never treat the components sidecar as the scores table
if path and os.path.basename(path) == "impact_score_components.csv":
path = (_find_exact_csv(rd, "proteoform_impact_scores.csv")
or _find_csv(rd, "impact_scores"))
return _read_csv_df(path)
def _drop_zero_impact_rows(df):
"""Return (filtered_df, n_dropped, all_zero).
Drops proteoform rows whose impact is entirely zero across every available
numeric component (and composite). If every row is zero, returns an empty
frame with all_zero=True so the caller can show an honest note instead of a
misleading all-zero table/plot.
"""
if df is None or len(df) == 0:
return df, 0, False
numeric_cols = [c for c in list(_IMPACT_COMPONENT_COLS) + ["composite"]
if c in df.columns]
if not numeric_cols:
return df, 0, False
vals = df[numeric_cols].apply(pd.to_numeric, errors="coerce").fillna(0.0)
nonzero_mask = (vals.abs().sum(axis=1) > 0)
n_dropped = int((~nonzero_mask).sum())
filtered = df[nonzero_mask].reset_index(drop=True)
all_zero = (len(filtered) == 0 and len(df) > 0)
return filtered, n_dropped, all_zero
def _drop_zero_pocket_rows(df):
"""Return (filtered_df, n_dropped, all_zero) for pocket predictions.
A pocket row carries no real measurement when the detector failed or the
reported volume is 0 (the geometric fallback emits volume=0,
detector_failed=True). Those rows are dropped so the plot/table only shows
structures with an actually detected pocket. If none remain, all_zero=True.
"""
if df is None or len(df) == 0:
return df, 0, False
d = df.copy()
vol_col = "volume" if "volume" in d.columns else (
"volume_A3" if "volume_A3" in d.columns else None)
failed = (d["detector_failed"].astype(str).str.lower().isin(["true", "1"])
if "detector_failed" in d.columns else pd.Series(False, index=d.index))
if vol_col is not None:
vol = pd.to_numeric(d[vol_col], errors="coerce").fillna(0.0)
keep = (~failed) & (vol.abs() > 0)
else:
keep = ~failed
n_dropped = int((~keep).sum())
filtered = d[keep].reset_index(drop=True)
all_zero = (len(filtered) == 0 and len(df) > 0)
return filtered, n_dropped, all_zero
def _drop_zero_drift_rows(df):
"""Return (filtered_df, n_dropped, all_zero) for pocket drift.
A drift row is uninformative when both the volume change and the pocket
centre displacement are zero (nothing moved / no pocket to compare). Those
rows are dropped. If none remain, all_zero=True.
"""
if df is None or len(df) == 0:
return df, 0, False
d = df.copy()
cols = [c for c in ("volume_change", "center_displacement_A",
"center_displacement") if c in d.columns]
if not cols:
return d, 0, False
vals = d[cols].apply(pd.to_numeric, errors="coerce").fillna(0.0)
keep = (vals.abs().sum(axis=1) > 0)
n_dropped = int((~keep).sum())
filtered = d[keep].reset_index(drop=True)
all_zero = (len(filtered) == 0 and len(df) > 0)
return filtered, n_dropped, all_zero
def _build_result_plots(rd):
"""Generate all data-driven result plots into ``<rd>/_plots``.
Returns a dict mapping a plot key -> HTML iframe string, so the Results tab
can drop each one straight into a gr.HTML panel. Missing inputs yield a
friendly placeholder rather than an error.
"""
placeholder = "<p>No data yet. Run the pipeline and click Refresh.</p>"
keys = ["dock_bars", "dock_scatter", "pocket_vol", "pocket_drift",
"impact_summary", "tm_summary"]
panels = {k: placeholder for k in keys}
# Track which panels actually received real data. Callers use this to hide
# empty panels (e.g. docking) instead of showing a "No data yet" box.
populated = set()
panels["_populated"] = populated
if not rd:
return panels
plot_dir = os.path.join(rd, "_plots")
os.makedirs(plot_dir, exist_ok=True)
# ---- Docking: Vina affinity bars ----
vina_df = _read_csv_df(_find_exact_csv(rd, "docking_summary.csv"))
if vina_df is not None:
out = viz.docking_affinity_bars(vina_df, os.path.join(plot_dir, "dock_affinity"))
if out.get("n"):
panels["dock_bars"] = _html_iframe(out.get("html"),
height=max(360, 42 * out["n"] + 140))
populated.add("dock_bars")
# ---- Docking: Boltz-2 pTM vs ipTM scatter ----
# Only counts as populated when the CSV carries real confidence values
# (ptm/iptm/confidence). On a local run Boltz-2 needs a GPU, so the file is
# often absent or status-only -> the panel stays empty and gets hidden.
boltz_df = _read_csv_df(_find_exact_csv(rd, "docking_summary_boltz2.csv"))
if boltz_df is not None:
conf_cols = [c for c in ("ptm", "iptm", "confidence_score", "complex_plddt")
if c in boltz_df.columns]
has_conf = False
for c in conf_cols:
if pd.to_numeric(boltz_df[c], errors="coerce").notna().any():
has_conf = True
break
if has_conf:
out = viz.docking_boltz_scatter(boltz_df, os.path.join(plot_dir, "dock_boltz"))
if out.get("n"):
panels["dock_scatter"] = _html_iframe(out.get("html"), height=660)
populated.add("dock_scatter")
# ---- Pocket volume bars ----
pocket_df = _read_csv_df(_find_exact_csv(rd, "pocket_predictions.csv")
or _find_csv(rd, "pocket_pred") or _find_csv(rd, "pocket"))
if pocket_df is not None:
# Drop structures with no real pocket (detector failed / volume 0).
pocket_df, _pn_dropped, _p_all_zero = _drop_zero_pocket_rows(pocket_df)
if _p_all_zero:
panels["pocket_vol"] = (
"<p>No binding pocket was detected for any structure "
"(all detectors failed or returned an empty pocket), so no real "
"volume could be measured. Nothing is plotted rather than showing "
"zeros as data.</p>")
elif len(pocket_df):
out = viz.pocket_volume_bars(pocket_df, os.path.join(plot_dir, "pocket_volume"))
if out.get("n"):
panels["pocket_vol"] = _html_iframe(out.get("html"), height=520)
# ---- Pocket drift scatter ----
drift_df = _read_csv_df(_find_csv(rd, "pocket_drift"))
if drift_df is not None:
# Drop proteoforms with zero drift (no volume change and no centre shift).
drift_df, _dn_dropped, _d_all_zero = _drop_zero_drift_rows(drift_df)
if _d_all_zero:
panels["pocket_drift"] = (
"<p>No pocket drift to show: every proteoform has zero volume "
"change and zero pocket-centre displacement vs wild-type. Locally, "
"mutant structures share the WT backbone, so pockets do not move; "
"run the GPU pipeline for real structural divergence.</p>")
elif len(drift_df):
out = viz.pocket_drift_scatter(drift_df, os.path.join(plot_dir, "pocket_drift"))
if out.get("n"):
panels["pocket_drift"] = _html_iframe(out.get("html"), height=580)
# ---- Impact score summary (composite ranking) ----
impact_df = _impact_scores_df(rd)
if impact_df is not None:
# Drop proteoforms with no measurable impact (all-zero across every
# component). Locally, mutant structures share the WT backbone so the
# structural/binding/dynamics/network terms are all zero; only rows
# that actually differ (e.g. ESM2 sequence distance) are informative.
impact_df, _n_dropped, _all_zero = _drop_zero_impact_rows(impact_df)
if _all_zero:
panels["impact_summary"] = (
"<p>No proteoform shows a measurable impact yet. All impact "
"components are zero &mdash; in local mode mutant structures "
"share the wild-type backbone, so structural/binding/dynamics/"
"network terms are all 0. Run the GPU pipeline (Boltz-2 folding "
"+ docking) for structural divergence.</p>")
elif len(impact_df):
val_col = None
for c in ("composite", "composite_score", "impact_score", "score"):
if c in impact_df.columns:
val_col = c
break
lab_col = "proteoform" if "proteoform" in impact_df.columns else (
impact_df.columns[0] if len(impact_df.columns) else None)
if val_col and lab_col:
out = viz.summary_bar(impact_df, lab_col, val_col,
os.path.join(plot_dir, "impact_summary"),
title="Proteoform impact ranking",
value_title="Composite impact score")
if out.get("n"):
panels["impact_summary"] = _html_iframe(out.get("html"),
height=max(340, 34 * out["n"] + 140))
# ---- TM-score summary ----
# Prefer the WT-vs-mutant file (has a 'Mutant' column); avoid the *_all.csv
# pairwise matrix which has no single value column.
tm_wt_csv = _find_exact_csv_glob(rd, "tm_scores_*.csv", exclude="_all.csv") \
or _find_csv(rd, "tm_scores")
tm_df = _read_csv_df(tm_wt_csv)
if tm_df is not None:
val_col = None
for c in ("TM-score", "tm_score", "tmscore", "TMscore"):
if c in tm_df.columns:
val_col = c
break
lab_col = None
for c in ("Mutant", "mutant", "structure", "proteoform"):
if c in tm_df.columns:
lab_col = c
break
if val_col and lab_col:
# 4-decimal labels: near-identical folds differ only at the 3rd/4th
# decimal; rounding to 2 dp made distinct structures look identical.
out = viz.summary_bar(tm_df, lab_col, val_col,
os.path.join(plot_dir, "tm_summary"),
title="TM-score vs wild-type",
value_title="TM-score (1.0 = identical fold)",
text_format=".4f")
if out.get("n"):
panels["tm_summary"] = _html_iframe(out.get("html"),
height=max(340, 34 * out["n"] + 140))
return panels
# ---------------------------------------------------------------------------
# Refresh + view callbacks
# ---------------------------------------------------------------------------
def refresh_all_results(state):
"""Refresh all result subsections. Returns a tuple of all outputs."""
rd = state.get("results_dir", "") if state else ""
# Warning banners (visible in the Results tab):
# - structure provenance: prominent red banner when structures were built by
# ptm-psi grafting (backbone-identical -> TM=1.0), else a short green note.
# - pocket warning: shown in BOTH the Pocket Prediction and Pocket Drift
# accordions when binding_site_method == 'reference' or all volumes are 0.
prov_banner = _structure_provenance_banner(rd)
pocket_banner = _pocket_warning_banner(rd)
prov_update = gr.update(value=prov_banner, visible=bool(prov_banner))
pocket_pred_update = gr.update(value=pocket_banner, visible=bool(pocket_banner))
pocket_drift_update = gr.update(value=pocket_banner, visible=bool(pocket_banner))
step_csv = os.path.join(rd, "step_status.csv") if rd else None
step_data = _load_csv(step_csv) if step_csv and os.path.exists(step_csv) else []
# Prefer the WT-vs-mutant TM file (Mutant, TM-score) over the pairwise
# matrix; render TM-scores at 4 decimals so near-identical folds are
# distinguishable in the table (the CSV keeps full precision).
tm_csv = (_find_exact_csv_glob(rd, "tm_scores_*.csv", exclude="_all.csv")
or _find_csv(rd, "tm_scores")) if rd else None
tm_data = _load_csv_fmt(tm_csv, {"TM-score": 4, "tm_score": 4}) if tm_csv else []
dock_csv = _find_csv(rd, "docking_summary") if rd else None
dock_data = _load_csv(dock_csv) if dock_csv else []
ddg_csv = _find_csv(rd, "ddg_summary") if rd else None
ddg_data = _load_csv(ddg_csv) if ddg_csv else []
# Impact scores table: drop proteoforms with all-zero impact (see
# _drop_zero_impact_rows). Falls back to an empty table when nothing is
# informative rather than showing a misleading all-zero grid.
impact_df_raw = _impact_scores_df(rd) if rd else None
impact_df_filt, _, _impact_all_zero = _drop_zero_impact_rows(impact_df_raw)
if impact_df_filt is not None and len(impact_df_filt):
impact_data = [list(impact_df_filt.columns)] + impact_df_filt.values.tolist()
else:
impact_data = []
# Pocket predictions table: prefer the exact predictions file (avoid
# matching pocket_drift.csv) and drop structures with no real pocket.
pocket_path = (_find_exact_csv(rd, "pocket_predictions.csv")
or _find_csv(rd, "pocket_pred")) if rd else None
pocket_df_raw = _read_csv_df(pocket_path) if pocket_path else None
pocket_df_filt, _, _ = _drop_zero_pocket_rows(pocket_df_raw)
if pocket_df_filt is not None and len(pocket_df_filt):
# Show volume/score at higher precision (small pockets can be < 1 A^3).
pocket_data = _df_to_table_fmt(
pocket_df_filt, {"volume": 3, "volume_A3": 3, "score": 4,
"center_x": 3, "center_y": 3, "center_z": 3})
else:
pocket_data = []
# Pocket drift table: drop proteoforms with zero drift.
drift_path = _find_csv(rd, "pocket_drift") if rd else None
drift_df_raw = _read_csv_df(drift_path) if drift_path else None
drift_df_filt, _, _ = _drop_zero_drift_rows(drift_df_raw)
if drift_df_filt is not None and len(drift_df_filt):
# Drift volume/displacement were previously rounded to 2 dp at the
# source (erasing small real changes); now full precision on disk and
# shown at 4 dp so sub-0.01 changes are visible instead of "0.00".
drift_data = _df_to_table_fmt(
drift_df_filt, {"volume_change": 4, "center_displacement_A": 4,
"center_displacement": 4, "wt_volume": 3,
"mut_volume": 3})
else:
drift_data = []
# PDB files for structure viewer. Complexes (docked receptor+ligand and
# designed antibody) are surfaced with clear labels and listed first so the
# user can actually see the ligand / antibody (not just the apo receptor).
pdb_files = _find_files(rd, ".pdb") if rd else []
pdb_choices = _pdb_viewer_choices(pdb_files)
# PCN structure dropdown: all non-WT structures (mutants + proteoforms)
pcn_choices = []
if rd:
# Mutants from pdbs/tetramer or pdbs/monomer (exclude WT and reference PDB)
for subdir in ("tetramer", "monomer"):
pd = os.path.join(rd, "pdbs", subdir)
if os.path.isdir(pd):
for f in sorted(os.listdir(pd)):
if not f.endswith(".pdb"):
continue
name = f.replace(".pdb", "")
if not name.lower().startswith("wt") and not re.match(r"^[0-9][a-z0-9]{3}$", name.lower()):
pcn_choices.append(name)
break
# Proteoforms from proteoforms/ directory
pf_dir = os.path.join(rd, "proteoforms")
if os.path.isdir(pf_dir):
for f in sorted(os.listdir(pf_dir)):
if f.endswith(".pdb"):
pcn_choices.append(f.replace(".pdb", ""))
# ESM2/UMAP plots (Fix 2: show plot instead of table)
esm_plots = []
if rd:
emb_dir = os.path.join(rd, "embeddings")
if os.path.isdir(emb_dir):
for f in sorted(os.listdir(emb_dir)):
if "umap" in f.lower() and f.endswith(".png"):
esm_plots.append(os.path.join("embeddings", f))
# ESM2/UMAP per-variant table (Fix 1: the space under the dropdown on the
# left of the ESM2 tab was empty; now it holds the searchable UMAP
# coordinates + variant classification read from embeddings/umap.csv).
esm_table_data = _esm_umap_table(rd) if rd else []
# Build interactive data plots (docking / pocket / summaries)
panels = _build_result_plots(rd)
populated = panels.get("_populated", set())
# Docking panels are hidden when they hold no real data (issue: the right
# panel used to show "No data yet"). Locally Boltz-2 needs a GPU, so the
# confidence scatter is usually empty -> hide it rather than show a stub.
dock_bars_update = gr.update(value=panels["dock_bars"],
visible="dock_bars" in populated)
dock_scatter_update = gr.update(value=panels["dock_scatter"],
visible="dock_scatter" in populated)
return (prov_update, # structure-provenance banner (top of Results)
step_data, tm_data, dock_data,
gr.update(choices=pcn_choices), # PCN structure dropdown
gr.update(choices=esm_plots), # ESM2 plot dropdown
esm_table_data, # ESM2/UMAP per-variant table (left column)
ddg_data, impact_data, pocket_data, drift_data,
gr.update(choices=pdb_choices), # PDB structure viewer dropdown
panels["impact_summary"], # impact plot
panels["pocket_vol"], # pocket volume plot
panels["pocket_drift"], # pocket drift plot
panels["tm_summary"], # tm-score plot
dock_bars_update, # docking affinity bars (hide if empty)
dock_scatter_update, # docking boltz scatter (hide if empty)
pocket_pred_update, # pocket-method warning (Pocket Prediction)
pocket_drift_update, # pocket-method warning (Pocket Drift)
state)
def view_pdb(pdb_rel, state):
rd = state.get("results_dir", "") if state else ""
if not pdb_rel or not rd:
return "<p>Select a PDB file to view.</p>"
full = os.path.join(rd, pdb_rel)
return _pdb_to_html_viewer(full)
def view_esm_plot(plot_rel, state):
"""Fix 2: Show ESM2/UMAP plot instead of table."""
rd = state.get("results_dir", "") if state else ""
if not plot_rel or not rd:
return None
full = os.path.join(rd, plot_rel)
if os.path.exists(full):
return full
return None
def _esm_umap_table(rd):
"""Return the ESM2/UMAP per-variant table (Fix 1: fill the empty space on
the left of the ESM2 tab).
Reads ``embeddings/umap.csv`` (columns x, y, label, subunit,
pathogenicity). Coordinates are rounded to 3 dp for readability; the CSV on
disk keeps full precision. Returns the ``[[headers], …]`` table format.
"""
if not rd:
return []
umap_csv = os.path.join(rd, "embeddings", "umap.csv")
if not os.path.exists(umap_csv):
umap_csv = _find_exact_csv(rd, "umap.csv")
if not umap_csv or not os.path.exists(umap_csv):
return []
try:
df = pd.read_csv(umap_csv)
except Exception:
return []
# friendlier column labels
rename = {"label": "variant", "subunit": "subunit/UniProt",
"pathogenicity": "classification", "x": "UMAP1", "y": "UMAP2"}
df = df.rename(columns={k: v for k, v in rename.items() if k in df.columns})
# Reorder columns to match the ``esm_table`` gr.Dataframe headers exactly
# (a gr.Dataframe with fixed headers is positional, so column order must
# line up or values land under the wrong header). Any extra columns are
# appended after the known ones; missing ones are simply skipped.
preferred = ["variant", "subunit/UniProt", "classification", "UMAP1", "UMAP2"]
ordered = [c for c in preferred if c in df.columns]
ordered += [c for c in df.columns if c not in ordered]
df = df[ordered]
return _df_to_table_fmt(df, {"UMAP1": 3, "UMAP2": 3})
def _structure_metrics_lookup(rd, struct_name):
"""Collect this structure's result metrics (TM-score vs WT, ΔΔG, pocket
volume) from the result CSVs, matched by structure/mutant name. Returns a
list of ``(label, value)`` strings; missing metrics are simply omitted."""
out = []
if not rd or not struct_name:
return out
name = str(struct_name)
base = os.path.basename(name).replace(".pdb", "")
def _match(df, cols):
for col in cols:
if col in df.columns:
s = df[col].astype(str)
hit = df[(s == base) | (s == name)]
if len(hit):
return hit.iloc[0]
return None
# TM-score vs WT
tm_csv = _find_exact_csv_glob(rd, "tm_scores_*.csv", exclude="_all.csv")
tdf = _read_csv_df(tm_csv)
if tdf is not None:
row = _match(tdf, ["Mutant", "mutant", "structure"])
if row is not None:
for c in ("TM-score", "tm_score", "tmscore"):
if c in tdf.columns:
try:
out.append(("TM-score vs WT", f"{float(row[c]):.4f}"))
except Exception:
pass
break
# ΔΔG (stability)
ddf = _read_csv_df(_find_csv(rd, "ddg_summary") or _find_csv(rd, "ddg"))
if ddf is not None:
row = _match(ddf, ["structure", "mutation", "mutant", "proteoform"])
if row is not None:
for c in ("ddg_kcal_mol", "ddG", "ddg"):
if c in ddf.columns:
try:
out.append(("ΔΔG (kcal/mol)", f"{float(row[c]):.3f}"))
except Exception:
pass
break
# Pocket volume
pdf = _read_csv_df(_find_exact_csv(rd, "pocket_predictions.csv"))
if pdf is not None:
row = _match(pdf, ["structure", "proteoform"])
if row is not None:
vcol = "volume" if "volume" in pdf.columns else (
"volume_A3" if "volume_A3" in pdf.columns else None)
if vcol is not None:
try:
vol = float(row[vcol])
failed = str(row.get("detector_failed", "")).lower() in ("true", "1")
out.append(("Pocket volume (ų)",
"n/a (detector failed)" if failed or vol == 0
else f"{vol:.3f}"))
except Exception:
pass
return out
def structure_summary(pdb_rel, state):
"""Build a 'Structure summary' card (Fix 2: fill the empty space to the
right of the 3D viewer).
Shows chains + molecule types, ligands/hetero groups, an antibody-complex
flag, atom/residue counts, and any per-structure result metrics (TM-score,
ΔΔG, pocket volume) joined from the result CSVs.
"""
rd = state.get("results_dir", "") if state else ""
if not pdb_rel or not rd:
return ("<div style='color:#666;padding:8px;'>Select a structure and click "
"<b>View</b> to see its summary.</div>")
full = os.path.join(rd, pdb_rel)
if not os.path.exists(full):
return "<div style='color:#b2182b;'>Structure file not found.</div>"
with open(full) as f:
pdb_text = f.read()
info = _classify_pdb_contents(pdb_text)
# counts
n_atoms = sum(1 for l in pdb_text.splitlines() if l[:6].strip() in ("ATOM", "HETATM"))
resids = set()
for l in pdb_text.splitlines():
if l[:6].strip() in ("ATOM", "HETATM"):
resids.add((l[21:22], l[22:27]))
n_res = len(resids)
prot_chains = sorted(info["protein_chains"])
ligs = sorted(info["ligand_resns"])
# structure type headline
if info["looks_like_antibody"]:
kind = "Antibody complex (H / L" + (
" + antigen" if len(info["protein_chains"] - _AB_CHAINS) else "") + ")"
elif info["has_ligand"] and info["has_protein"]:
kind = "Receptor + ligand complex"
elif info["has_protein"]:
kind = "Protein structure"
elif info["has_ligand"]:
kind = "Ligand only"
else:
kind = "Unknown"
rows = [
("Type", kind),
("Protein chains", f"{len(prot_chains)} ({', '.join(prot_chains) or '—'})"),
("Ligands / hetero", ", ".join(ligs) if ligs else "none"),
("Atoms", str(n_atoms)),
("Residues", str(n_res)),
]
# joined result metrics for this structure
base = os.path.basename(pdb_rel).replace(".pdb", "")
struct_key = base[: -len("_complex")].split("__", 1)[0] if base.endswith("_complex") else base
metrics = _structure_metrics_lookup(rd, struct_key)
def _table(pairs):
trs = "".join(
f"<tr><td style='padding:3px 10px 3px 0;color:#555;white-space:nowrap;'>{k}</td>"
f"<td style='padding:3px 0;font-weight:600;'>{v}</td></tr>" for k, v in pairs)
return f"<table style='border-collapse:collapse;font-size:0.92em;'>{trs}</table>"
html_parts = [
"<div style='border:1px solid #ccc;border-radius:8px;padding:12px 14px;"
"background:#fafafa;'>",
"<div style='font-weight:700;font-size:1.02em;margin-bottom:8px;'>"
"Structure summary</div>",
_table(rows),
]
if metrics:
html_parts.append(
"<div style='margin-top:10px;padding-top:8px;border-top:1px solid #ddd;'>"
"<div style='font-weight:700;font-size:0.95em;margin-bottom:6px;'>"
"Result metrics</div>" + _table(metrics) + "</div>")
else:
html_parts.append(
"<div style='margin-top:10px;padding-top:8px;border-top:1px solid #ddd;"
"color:#888;font-size:0.85em;'>No per-structure result metrics found "
"(run the pipeline steps to populate TM-score / ΔΔG / pocket volume).</div>")
html_parts.append("</div>")
return "".join(html_parts)
# ---------------------------------------------------------------------------
# GUI layout
# ---------------------------------------------------------------------------
def build_app():
"""Build the Gradio Blocks app and return it (without launching).
Split out from :func:`launch` so external entry points (e.g. a Hugging
Face Spaces ``app.py``) can obtain the ``gr.Blocks`` object, attach a
request queue, and call ``.launch(...)`` with their own server / ZeroGPU
settings. ``launch()`` remains the standard local entry point.
"""
step_choices = list(STEP_REGISTRY.keys())
with gr.Blocks(title="Proteoform Analyzer", theme=gr.themes.Soft()) as app:
gr.Markdown("# Proteoform Analyzer\n"
"An AI-powered tool to analyze the effects of mutations, PTMs, and proteoforms in different oligomeric proteins. "
)
state = gr.State({"done": False, "results": [], "results_dir": ""})
# ── Tab 1: Setup ──
with gr.Tab("1. Setup"):
# Preset selector: choose from 9 presets or custom
preset_names = list(PRESETS.keys()) + ["Custom (manual)"]
preset_selector = gr.Dropdown(
choices=preset_names, value="Hemoglobin — Fast",
label="Preset configuration",
info="Select a preset to auto-fill all fields below, or choose Custom for manual entry.")
with gr.Row():
uniprot_ids = gr.Textbox(label="UniProt ID(s)", value="P69905,P68871",
info="Comma-separated. 1 for homo, 2+ for hetero.")
n_subunits = gr.Dropdown([1, 2, 3, 4, 6], value=4, label="Number of subunits")
stoichiometry = gr.Textbox(value="2,2", label="Stoichiometry (comma-sep copy counts)")
with gr.Row():
proteoform_mode = gr.Radio(["single", "pairwise"], value="pairwise",
label="Proteoform mode")
proteoform_cap = gr.Slider(10, 500, value=100, step=10,
label="Max proteoforms")
with gr.Row():
mutations_text = gr.Textbox(
label="Mutations (pipe-separated per subunit)",
value="",
info="e.g. 'D75A H87R D94H'. Blank = fetch from UniProt.")
max_mutations = gr.Slider(1, 50, value=10, step=1, label="Max mutations")
with gr.Row():
structure_source = gr.Radio(["boltz2"], value="boltz2",
label="Structure source",
info="Boltz-2 folds structures from sequence "
"(hosted API → local binary → graft fallback).")
local_pdb_id = gr.Textbox(value="1A3N",
label="Reference PDB ID (binding-site alignment only)",
info="Optional. Used only to align known binding "
"sites; NOT a structure source.")
with gr.Accordion("PTM settings", open=False):
ptm_residues = gr.Textbox(value="CYS10", label="PTM residues (comma-sep)")
ptm_types = gr.Textbox(
value="",
label="PTM types (comma-sep)")
with gr.Accordion("Engine choices", open=True):
with gr.Row():
md_engine = gr.Radio(["openmm", "gromacs"], value="openmm", label="MD engine")
md_steps = gr.Slider(1000, 50000, value=5000, step=1000,
label="MD production steps")
with gr.Row():
docking_engine = gr.Radio(["vina", "boltz2", "diffdock"], value="vina",
label="Docking engine",
info="'boltz2' co-folds receptor+ligand "
"(confidence score, not kcal/mol).")
ligand_design_engine = gr.Radio(
["diffsbdd", "boltzgen"], value="diffsbdd",
label="Ligand / binder design engine",
info="'diffsbdd' = small-molecule design; 'boltzgen' "
"= protein binder design (Boltz API → local install → none).")
with gr.Row():
binding_site_method = gr.Radio(
["auto", "reference", "p2rank", "alphasphere", "user"],
value="auto", label="Binding-site prediction method",
info="'reference' uses a fixed box from a known ligand site "
"(no pocket volume/drift — see warning in Results).")
ensemble_docking = gr.Checkbox(value=False, label="Ensemble docking (MD snapshots)")
gr.Markdown(
"**Boltz-2 backend** — structure folding, co-folding docking "
"(`boltz2`), and binder design (`boltzgen`) all use "
"one backend, chosen in this order: **hosted API key → local "
"install → graft fallback** (folding only). Leave everything "
"blank to use the offline graft fallback. Local binder design "
"runs BoltzGen if installed.")
with gr.Row():
boltz_api_key = gr.Textbox(
value="", label="Boltz API key",
info="Official api.boltz.bio key. Overrides $BOLTZ_API_KEY. "
"Enables folding, docking, and binder design via the "
"hosted API (no GPU needed).")
with gr.Row():
boltz_prefer_local = gr.Checkbox(
value=False, label="Prefer local Boltz",
info="Use a local 'boltz'/'boltzgen' install before the API.")
boltz_allow_graft = gr.Checkbox(
value=True, label="Allow graft fallback",
info="If no folding backend exists, build backbone-identical "
"structures by PTM-Psi grafting (TM-score = 1.0; no "
"structural/pocket/docking signal). Uncheck to hard-skip.")
with gr.Accordion("Antibody design (RFAntibody; opt-in)", open=False):
gr.Markdown(
"De novo antibody/nanobody design against a binding site "
"(RFdiffusion -> ProteinMPNN -> RF2). Enabling this adds the "
"**antibody** step automatically. Requires a **local RFAntibody "
"install** (GPU strongly recommended); set the checkout path via "
"`config.antibody.local_rfantibody_dir` (or the `--antibody-rfantibody-dir` "
"CLI flag). Without it, the step skips cleanly with instructions.")
with gr.Row():
antibody_enabled = gr.Checkbox(value=True,
label="Enable antibody design")
antibody_framework = gr.Radio(["nanobody", "scfv"], value="nanobody",
label="Framework")
antibody_num_designs = gr.Slider(1, 100, value=20, step=1,
label="Number of designs")
with gr.Row():
antibody_hotspot_source = gr.Radio(
["user", "bcell", "mhc_i", "mhc_ii"], value="user",
label="Hotspot source",
info="'user' = residues at right; 'bcell' = predicted B-cell "
"epitopes; 'mhc_i'/'mhc_ii' require an external tool (stubs).")
antibody_hotspots = gr.Textbox(
value="", label="Target hotspot residues (comma-sep)",
info="For source 'user', e.g. '305,456' or 'A305,A456'.")
selected_steps = gr.CheckboxGroup(step_choices,
value=step_choices,
label="Pipeline steps to run")
# Wire preset selector to populate all widgets
preset_selector.change(
_load_preset,
inputs=[preset_selector],
outputs=[uniprot_ids, n_subunits, stoichiometry, proteoform_mode,
proteoform_cap, mutations_text, max_mutations, structure_source,
local_pdb_id, ptm_residues, ptm_types, md_engine, md_steps,
docking_engine, ligand_design_engine, binding_site_method,
ensemble_docking, selected_steps])
# ── Tab 2: Run ──
with gr.Tab("2. Run"):
run_btn = gr.Button("Run pipeline", variant="primary")
log_out = gr.Textbox(label="Live log", lines=20, max_lines=40,
interactive=False)
status_table = gr.Dataframe(
headers=["status", "step", "message", "elapsed_s", "n_outputs"],
label="Step status", wrap=True)
run_btn.click(
run_from_gui,
inputs=[preset_selector,
uniprot_ids, n_subunits, stoichiometry, proteoform_mode, proteoform_cap,
mutations_text, max_mutations, structure_source, local_pdb_id,
ptm_residues, ptm_types, md_engine, md_steps,
docking_engine, ligand_design_engine, binding_site_method,
ensemble_docking, selected_steps,
antibody_enabled, antibody_framework, antibody_hotspot_source,
antibody_hotspots, antibody_num_designs,
boltz_api_key, boltz_prefer_local,
boltz_allow_graft,
state],
outputs=[log_out, status_table, state])
# ── Tab 3: Results ──
with gr.Tab("3. Results"):
gr.Markdown("## Results\nBrowse results by type. Use the search box in each "
"table to filter. Click 'Refresh' after a run to populate.")
# Structure-provenance banner: hidden until Refresh detects how the
# structures were produced. Turns into a prominent red warning when
# the graft fallback was used (backbone-identical -> TM=1.0).
provenance_banner = gr.Markdown(value="", visible=False)
refresh_btn = gr.Button("Refresh all results", variant="secondary")
with gr.Accordion("Step Status Summary", open=True):
step_status_df = gr.Dataframe(
headers=["status", "step", "message", "elapsed_s", "n_outputs"],
label="Pipeline step status", wrap=True,
interactive=False, row_count=(10, "dynamic"), show_search=True)
with gr.Accordion("Proteoform Impact Scores", open=False):
gr.Markdown("Composite impact score ranking across all proteoforms. "
"Higher score = more impactful variant.")
impact_plot = gr.HTML(
value="<p>Run the pipeline and click Refresh.</p>",
label="Impact ranking (interactive)")
impact_df = gr.Dataframe(
headers=["proteoform", "structural", "binding", "dynamics", "network", "sequence", "composite"],
label="Impact scores (searchable)", wrap=True,
interactive=False, row_count=(20, "dynamic"), show_search=True)
with gr.Accordion("Pocket Prediction", open=False):
gr.Markdown("Predicted binding pockets (method, center, volume, score). "
"Structures where every pocket detector failed are omitted "
"from the plot; if any are shown they appear in grey with a "
"volume of 0 (no pocket was detected, so no volume could be "
"measured).")
# Warning banner (hidden until Refresh): shown when the
# binding-site method is 'reference' (fixed box -> volume/drift
# always 0) or when no detector produced a real volume.
pocket_pred_warning = gr.Markdown(value="", visible=False)
pocket_plot = gr.HTML(
value="<p>Run the pipeline and click Refresh.</p>",
label="Pocket volume (interactive)")
pocket_df = gr.Dataframe(
headers=["structure", "method", "center_x", "center_y", "center_z", "volume", "score"],
label="Pocket predictions (searchable)", wrap=True,
interactive=False, row_count=(20, "dynamic"), show_search=True)
with gr.Accordion("Pocket Drift Analysis", open=False):
gr.Markdown("Pocket property changes vs WT across proteoforms. "
"X = volume change (ų), Y = pocket-centre displacement (Å).")
# Same warning as Pocket Prediction (reference method / all-zero
# volumes -> drift is identically 0 and cannot change).
pocket_drift_warning = gr.Markdown(value="", visible=False)
drift_plot = gr.HTML(
value="<p>Run the pipeline and click Refresh.</p>",
label="Pocket drift (interactive)")
drift_df = gr.Dataframe(
headers=["proteoform", "volume_change", "center_displacement", "residue_jaccard"],
label="Pocket drift (searchable)", wrap=True,
interactive=False, row_count=(20, "dynamic"), show_search=True)
with gr.Accordion("TM-score (structural comparison)", open=False):
tm_plot = gr.HTML(
value="<p>Run the pipeline and click Refresh.</p>",
label="TM-score (interactive)")
tm_df = gr.Dataframe(
headers=["Mutant", "TM-score"],
label="TM-scores (searchable)", wrap=True,
interactive=False, row_count=(20, "dynamic"), show_search=True)
with gr.Accordion("Docking (binding affinities)", open=False):
gr.Markdown("**Vina affinity** (bar chart, lower = stronger) and "
"**Boltz-2 confidence** (pTM vs ipTM scatter).")
with gr.Row():
# Hidden until Refresh finds real data (see refresh_all_results).
dock_bars_plot = gr.HTML(
value="<p>Vina docking: run and Refresh.</p>",
label="Vina affinity (interactive)", visible=False)
dock_scatter_plot = gr.HTML(
value="<p>Boltz-2 docking: run and Refresh.</p>",
label="Boltz-2 pTM vs ipTM (interactive)", visible=False)
dock_df = gr.Dataframe(
headers=["structure", "ligand", "affinity_kcal_mol"],
label="Docking results (searchable)", wrap=True,
interactive=False, row_count=(20, "dynamic"), show_search=True)
with gr.Accordion("Molecular Dynamics (RMSD / RMSF)", open=False):
gr.Markdown("**Interactive trajectory overlay (all structures).** "
"Choose RMSD or RMSF and click Overlay. Every structure "
"with MD output is drawn as its own trace — show/hide "
"individual structures directly from the Plotly legend. "
"A static SVG/PNG is also saved.")
with gr.Row():
md_overlay_kind = gr.Radio(choices=["RMSD", "RMSF"], value="RMSD",
label="Metric")
overlay_btn = gr.Button("Overlay", variant="primary")
md_overlay_plot = gr.HTML(
value="<p>Click Overlay to plot RMSD/RMSF for all structures.</p>",
label="Interactive overlay")
with gr.Row():
md_overlay_file = gr.File(label="Static plot (SVG)", interactive=False)
md_overlay_status = gr.Textbox(label="Status", interactive=False)
overlay_btn.click(overlay_md_callback,
inputs=[md_overlay_kind, state],
outputs=[md_overlay_plot, md_overlay_file, md_overlay_status])
# ── Fix 3: PCN interactive viewers (replaces table) ──
with gr.Accordion("Protein Contact Networks (interactive)", open=False):
gr.Markdown(
"**Residue centrality** (left) and **community detection** "
"(right), each mapped onto the 3D structure. Centrality can be "
"shown as a **raw viridis colormap** (works for WT and mutants) "
"or as **\u0394 vs WT** (blue = decreased, red = increased). The "
"**bar plot** highlights the top-10 residues with the largest "
"|\u0394 centrality| (name+id, signed). Communities are coloured "
"by community id for the selected structure.")
with gr.Row():
pcn_struct = gr.Dropdown(label="Structure", choices=[], interactive=True,
info="WT or any mutant/proteoform")
pcn_measure = gr.Dropdown(
choices=[("Betweenness", "betweenness"), ("Closeness", "closeness"),
("Degree", "degree_c"), ("Eigenvector", "eigenvector_c")],
value="betweenness", label="Centrality measure")
pcn_algo = gr.Dropdown(
choices=[("Louvain", "louvain"), ("Leiden", "leiden"), ("Infomap", "infomap")],
value="louvain", label="Community algorithm")
with gr.Row():
pcn_view_mode = gr.Radio(
choices=[("Raw centrality (viridis)", "raw"),
("\u0394 vs WT (diverging)", "delta")],
value="raw", label="Centrality view")
pcn_btn = gr.Button("Visualize", variant="primary")
with gr.Row():
pcn_centrality_view = gr.HTML(
value="<p>Select a structure and click 'Visualize'.</p>",
label="Centrality (colormap on structure)")
pcn_community_view = gr.HTML(
value="<p>Select a structure and click 'Visualize'.</p>",
label="Community detection")
try:
pcn_delta_bars = gr.Image(
label="Top-10 residues by |\u0394 centrality| vs WT "
"(red = increase, blue = decrease)",
interactive=False, show_download_button=True)
pcn_legend = gr.Textbox(label="Legend", interactive=False, lines=2)
except Exception as e:
pcn_delta_bars = gr.Image(
label="Top-10 residues by |\u0394 centrality| vs WT "
"(red = increase, blue = decrease)",
interactive=False, buttons=["download"])
pcn_legend = gr.Textbox(label="Legend", interactive=False, lines=2)
pcn_btn.click(pcn_visualize,
inputs=[pcn_struct, pcn_measure, pcn_algo, state, pcn_view_mode],
outputs=[pcn_centrality_view, pcn_community_view,
pcn_legend, pcn_delta_bars])
# ── Fix 2: ESM2 plot (replaces table) ──
with gr.Accordion("ESM2 + UMAP (variant classification)", open=False):
gr.Markdown("ESM2 embeddings projected to 2D via UMAP. Each point is a "
"variant; points that cluster together have similar sequences.")
with gr.Row():
# Left column: plot selector + the per-variant UMAP table
# (Fix 1: the space under the dropdown was empty; now it
# holds the searchable UMAP coordinates + classification).
with gr.Column(scale=1):
esm_plot_selector = gr.Dropdown(label="Select UMAP plot",
choices=[], interactive=True)
esm_table = gr.Dataframe(
headers=["variant", "subunit/UniProt", "classification",
"UMAP1", "UMAP2"],
label="UMAP coordinates & classification (searchable)",
wrap=True, interactive=False,
row_count=(12, "dynamic"), show_search=True)
# Right column: the UMAP projection image
with gr.Column(scale=1):
esm_plot_view = gr.Image(label="UMAP projection", height=450)
esm_plot_selector.change(view_esm_plot, inputs=[esm_plot_selector, state],
outputs=[esm_plot_view])
with gr.Accordion("DeltaDeltaG (stability prediction)", open=False):
ddg_df = gr.Dataframe(
headers=["uniprot_id", "mutation", "ddg_kcal_mol"],
label="ΔΔG results (searchable)", wrap=True,
interactive=False, row_count=(20, "dynamic"), show_search=True)
with gr.Accordion("3D Structure Viewer (PDB)", open=False):
gr.Markdown(
"Docked receptor+ligand complexes and designed antibody "
"complexes are listed first (labelled `[docked …]` / "
"`[designed antibody]`) so you can see the **ligand (licorice)** "
"or the **antibody (coloured by chain)**, not just the apo "
"receptor.")
with gr.Row():
pdb_selector = gr.Dropdown(label="Select PDB structure", choices=[],
interactive=True, scale=3)
view_pdb_btn = gr.Button("View", variant="secondary", scale=1)
with gr.Row():
# Left: the 3D viewer. Right: a structure-summary card
# (Fix 2: the space to the right of the viewer was empty).
with gr.Column(scale=3):
pdb_viewer = gr.HTML(
value="<p>Select a PDB file and click 'View' to load "
"the 3D viewer.</p>",
label="3D Structure Viewer")
with gr.Column(scale=2):
pdb_summary = gr.HTML(
value="""
<div style='
color:#0b3d91;
background:#eaf2ff;
padding:12px;
border:1px solid #bcd0f7;
border-radius:8px;
font-weight:500;
'>
Structure summary appears here after you click <b>View</b>.
</div>
""",
label="Structure summary"
)
# One click updates both the viewer and the summary card.
view_pdb_btn.click(view_pdb, inputs=[pdb_selector, state],
outputs=[pdb_viewer])
view_pdb_btn.click(structure_summary, inputs=[pdb_selector, state],
outputs=[pdb_summary])
# Fix 4: "All Result Files" section removed
refresh_btn.click(
refresh_all_results,
inputs=[state],
outputs=[provenance_banner, # structure-provenance banner (top)
step_status_df, tm_df, dock_df,
pcn_struct, # PCN structure dropdown
esm_plot_selector, # ESM2 plot dropdown
esm_table, # ESM2/UMAP per-variant table (left column)
ddg_df, impact_df, pocket_df, drift_df,
pdb_selector, # PDB structure viewer dropdown
impact_plot, # impact interactive plot
pocket_plot, # pocket volume interactive plot
drift_plot, # pocket drift interactive plot
tm_plot, # tm-score interactive plot
dock_bars_plot, # docking affinity bars
dock_scatter_plot, # docking boltz scatter
pocket_pred_warning, # pocket-method warning (Pocket Prediction)
pocket_drift_warning, # pocket-method warning (Pocket Drift)
state])
return app
def launch(**launch_kwargs):
"""Build and launch the Gradio Blocks app (standard local entry point).
Any keyword arguments are forwarded to ``Blocks.launch`` so callers can
override the host/port/share/SSR settings. A request queue is always
enabled (needed for the long-running pipeline and for Hugging Face
ZeroGPU). Defaults preserve the original local behaviour.
"""
app = build_app()
app.queue()
app.launch(**launch_kwargs)
if __name__ == "__main__":
launch()