"""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 "
No plot available yet. Run the pipeline and click Refresh.
" with open(html_path) as f: doc = f.read() escaped = html.escape(doc, quote=True) return (f'') # --------------------------------------------------------------------------- # 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/No PDB data.
" 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""" """ escaped = html.escape(inner_doc, quote=True) return f'' _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"" f"Chain {ch}") for label, col in (extra_items or []): chips.append( f"" f"{label}") if not chips: return "" return ("No PDB file selected.
" 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"No results directory.
" pcn_dir = os.path.join(rd, "pcn_outputs") if not os.path.isdir(pcn_dir): return "PCN results not found. Run the PCN step first.
" 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"PDB file not found: {structure}.pdb
" 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"Centrality data not found for {measure} ({structure}).
" 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'Delta view needs a mutant (WT minus WT = 0). " "Switch to 'Raw centrality' to view WT.
") 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"Centrality data not found for {measure} ({wt_name} or {structure}).
" 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 "No matching residues between WT and mutant.
" 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'No results directory.
" pcn_dir = os.path.join(rd, "pcn_outputs") if not os.path.isdir(pcn_dir): return "PCN results not found. Run the PCN step first.
" # 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"Community data not found for {algo} ({structure}).
" struct_pdb_path = _find_structure_pdb(rd, structure) if not struct_pdb_path: return f"PDB file not found: {structure}.pdb
" 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'Select a structure.
", "Select a structure.
", "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 "No results directory. Run the pipeline first.
", "", "No results directory." kind = "rmsf" if str(kind).lower().startswith("rmsf") else "rmsd" traj_rels = _all_trajectory_rels(rd) if not traj_rels: return ("No MD trajectories found. Run the 'md' step first.
", "", "No MD trajectories found.") series = _md_series_from_selection(traj_rels, rd, kind) if not series: return ("No RMSD/RMSF data found.
", "", "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 ``No data yet. Run the pipeline and click Refresh.
" 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"] = ( "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.
") 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"] = ( "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.
") 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"] = ( "No proteoform shows a measurable impact yet. All impact " "components are zero — 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.
") 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 "Select a PDB file to view.
" 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 ("Run the pipeline and click Refresh.
", 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="Run the pipeline and click Refresh.
", 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="Run the pipeline and click Refresh.
", 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="Run the pipeline and click Refresh.
", 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="Vina docking: run and Refresh.
", label="Vina affinity (interactive)", visible=False) dock_scatter_plot = gr.HTML( value="Boltz-2 docking: run and Refresh.
", 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="Click Overlay to plot RMSD/RMSF for all structures.
", 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="Select a structure and click 'Visualize'.
", label="Centrality (colormap on structure)") pcn_community_view = gr.HTML( value="Select a structure and click 'Visualize'.
", 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="Select a PDB file and click 'View' to load " "the 3D viewer.
", label="3D Structure Viewer") with gr.Column(scale=2): pdb_summary = gr.HTML( value="""