"""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/___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 "

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 ("
" "Chains: " + "".join(chips) + "
") 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 "

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"
{viewer_html}{legend_html}
") 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 = ( "
" "\u26a0\ufe0f GRAFT FALLBACK " "\u2014 backbone-identical structures.
" f"{GRAFT_WARNING}") if detail: msg += f"
{detail}" msg += "
" 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"
\u2705 Structures folded via " f"{label}. TM-score / pocket / docking signals are meaningful." + (f"
{detail}" if detail else "") + "
") 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 ("
" f"\u26a0\ufe0f {heading} \u2014 " "pocket prediction and pocket drift may not change.
" f"{text}
") 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'') 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'' f'{grad}' f'' f'{html.escape(label)}' f'' f'{lo}' f'{mid}' f'{hi}' f'') 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 "

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'
{cbar}
' + viewer # ---- DELTA mode: colour by (mutant - WT) on a diverging scale ----------- if structure == wt_name: return ("

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'
{cbar}
' + 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 "

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'
' f'{structure}: {n_comms} communities ({algo}), ' f'coloured by community id
') 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 ("

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 ``/_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 = "

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 ("
Select a structure and click " "View to see its summary.
") full = os.path.join(rd, pdb_rel) if not os.path.exists(full): return "
Structure file not found.
" 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"{k}" f"{v}" for k, v in pairs) return f"{trs}
" html_parts = [ "
", "
" "Structure summary
", _table(rows), ] if metrics: html_parts.append( "
" "
" "Result metrics
" + _table(metrics) + "
") else: html_parts.append( "
No per-structure result metrics found " "(run the pipeline steps to populate TM-score / ΔΔG / pocket volume).
") html_parts.append("
") 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="

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="""
Structure summary appears here after you click View.
""", 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()