"""
MyAbs — v0.2 product spine + developability flags.
Paste a heavy (VH) + light (VL) chain -> fold with ABodyBuilder2 -> view the 3D
structure with CDR loops highlighted -> scan for common developability liabilities
(PTM hotspots, glycosylation sequons, free cysteines, long CDR-H3) and paint the
offending residues onto the structure.
Runs locally (WSL 'base'/'myabs' env) and drops onto a free HF CPU Space unchanged.
Run:
pip install gradio # once, into the same env as ImmuneBuilder
python app.py # opens http://127.0.0.1:7900
The liability flags are HEURISTIC screens, not disqualifiers. MyAbs yields in-silico
CANDIDATES, not patent-ready antibodies — wet-lab validation still required.
"""
import io
import itertools
import json
import os
import tempfile
import time
import gradio as gr
from ImmuneBuilder import ABodyBuilder2
try:
from anarci import number as anarci_number
HAVE_ANARCI = True
except Exception:
HAVE_ANARCI = False
try:
from Bio.PDB import PDBParser
from Bio.PDB.SASA import ShrakeRupley
HAVE_SASA = True
except Exception:
HAVE_SASA = False
print("Loading ABodyBuilder2 ensemble...")
PREDICTOR = ABodyBuilder2()
print("Ready.")
# IMGT CDR position ranges (ABodyBuilder2 writes IMGT-numbered PDBs).
CDR_RANGES = {"1": (27, 38), "2": (56, 65), "3": (105, 117)}
# CDR cartoon colors: heavy = warm, light = cool. Framework stays grey.
CDR_COLORS = {
"H": {"1": "#ffca28", "2": "#ff7043", "3": "#e53935"},
"L": {"1": "#4dd0e1", "2": "#29b6f6", "3": "#1e88e5"},
}
# Illustrative demo VH / VL (verify before any real use).
DEMO_H = ("EVQLVESGGGLVQPGGSLRLSCAASGFTFSSYAMSWVRQAPGKGLEWVSAISGSGGST"
"YYADSVKGRFTISRDNSKNTLYLQMNSLRAEDTAVYYCAKDRGYYYGMDVWGQGTTVTVSS")
DEMO_L = ("DIQMTQSPSSLSASVGDRVTITCRASQSISSYLNWYQQKPGKAPKLLIYAASSLQSGVP"
"SRFSGSGSGTDFTLTISSLQPEDFATYYCQQSYSTPLTFGGGTKVEIK")
# Therapeutic library — variable domains only, self-curated from PUBLIC sources.
# Every sequence cross-checked against two independent authoritative sources.
PASTE = "— paste your own —"
LIBRARY = {
PASTE: None,
"Trastuzumab (Herceptin) · anti-HER2": {
"H": "EVQLVESGGGLVQPGGSLRLSCAASGFNIKDTYIHWVRQAPGKGLEWVARIYPTNGYTRYADSVKGRFTISADTSKNTAYLQMNSLRAEDTAVYYCSRWGGDGFYAMDYWGQGTLVTVSS",
"L": "DIQMTQSPSSLSASVGDRVTITCRASQDVNTAVAWYQQKPGKAPKLLIYSASFLYSGVPSRFSGSRSGTDFTLTISSLQPEDFATYYCQQHYTTPPTFGQGTKVEIK",
"target": "HER2 (ERBB2)",
"src": "PDB 1N8Z SEQRES + KEGG DRUG D03257 (identical)",
"url": "https://www.rcsb.org/structure/1N8Z",
},
"Adalimumab (Humira) · anti-TNF-α": {
"H": "EVQLVESGGGLVQPGRSLRLSCAASGFTFDDYAMHWVRQAPGKGLEWVSAITWNSGHIDYADSVEGRFTISRDNAKNSLYLQMNSLRAEDTAVYYCAKVSYLSTASSLDYWGQGTLVTVSS",
"L": "DIQMTQSPSSLSASVGDRVTITCRASQGIRNYLAWYQQKPGKAPKLLIYAASTLQSGVPSRFSGSGSGTDFTLTISSLQPEDVATYYCQRYNRAPYTFGQGTKVEIK",
"target": "TNF-α",
"src": "PDB 6CR1 + DrugBank DB00051 (consensus; lone 3WD5 outlier residue rejected)",
"url": "https://www.rcsb.org/structure/6CR1",
},
"Pembrolizumab (Keytruda) · anti-PD-1": {
"H": "QVQLVQSGVEVKKPGASVKVSCKASGYTFTNYYMYWVRQAPGQGLEWMGGINPSNGGTNFNEKFKNRVTLTTDSSTTTAYMELKSLQFDDTAVYYCARRDYRFDMGFDYWGQGTTVTVSS",
"L": "EIVLTQSPATLSLSPGERATLSCRASKGVSTSGYSYLHWYQQKPGQAPRLLIYLASYLESGVPARFSGSGSGTDFTLTISSLEPEDFAVYYCQHSRDLPLTFGGGTKLEIK",
"target": "PD-1 (PDCD1)",
"src": "PDB 5DK3 + 5GGS (identical)",
"url": "https://www.rcsb.org/structure/5DK3",
},
"Rituximab (Rituxan) · anti-CD20": {
"H": "QVQLQQPGAELVKPGASVKMSCKASGYTFTSYNMHWVKQTPGRGLEWIGAIYPGNGDTSYNQKFKGKATLTADKSSSTAYMQLSSLTSEDSAVYYCARSTYYGGDWYFNVWGAGTTVTVSA",
"L": "QIVLSQSPAILSASPGEKVTMTCRASSSVSYIHWFQQKPGSSPKPWIYATSNLASGVPVRFSGSGSGTSYSLTISRVEAEDAATYYCQQWTSNPPTFGGGTKLEIK",
"target": "CD20 (MS4A1)",
"src": "PDB 2OSL + 6VJA (identical)",
"url": "https://www.rcsb.org/structure/2OSL",
},
"Bevacizumab (Avastin) · anti-VEGF-A": {
"H": "EVQLVESGGGLVQPGGSLRLSCAASGYTFTNYGMNWVRQAPGKGLEWVGWINTYTGEPTYAADFKRRFTFSLDTSKSTAYLQMNSLRAEDTAVYYCAKYPHYYGSSHWYFDVWGQGTLVTVSS",
"L": "DIQMTQSPSSLSASVGDRVTITCSASQDISNYLNWYQQKPGKAPKVLIYFTSSLHSGVPSRFSGSGSGTDFTLTISSLQPEDFATYYCQQYSTVPWTFGQGTKVEIK",
"target": "VEGF-A",
"src": "PDB 1BJ1 + NIH GSRS (confirmed)",
"url": "https://www.rcsb.org/structure/1BJ1",
},
}
# On-screen CDR + liability legend (static HTML).
def _swatch(label, color):
return (f""
f"{label}")
LEGEND_HTML = (
"
"
"Legend:"
+ _swatch("CDR-H1", CDR_COLORS["H"]["1"]) + _swatch("H2", CDR_COLORS["H"]["2"])
+ _swatch("H3", CDR_COLORS["H"]["3"]) + _swatch("CDR-L1", CDR_COLORS["L"]["1"])
+ _swatch("L2", CDR_COLORS["L"]["2"]) + _swatch("L3", CDR_COLORS["L"]["3"])
+ _swatch("liability", "magenta") + _swatch("your pick", "lime")
+ _swatch("framework", "#cfd8dc")
+ "
"
)
SEV_ORDER = {"High": 0, "Medium": 1, "Low": 2, "Minimal": 3}
SEV_ICON = {"High": "🔴", "Medium": "🟠", "Low": "🟡", "Minimal": "⚪"}
SEV_LEVELS = ["High", "Medium", "Low", "Minimal"]
# Tien et al. 2013 theoretical max ASA (Ų), for relative solvent accessibility.
MAXASA = {
"ALA": 129, "ARG": 274, "ASN": 195, "ASP": 193, "CYS": 167, "GLU": 223,
"GLN": 225, "GLY": 104, "HIS": 224, "ILE": 197, "LEU": 201, "LYS": 236,
"MET": 224, "PHE": 240, "PRO": 159, "SER": 155, "THR": 172, "TRP": 285,
"TYR": 263, "VAL": 174,
}
def clean(seq: str) -> str:
"""Strip whitespace/newlines/numbers, uppercase — accept messy pasted input."""
return "".join(c for c in seq.upper() if c.isalpha())
# ------------------------------------------------------------------ numbering
def number_chain(seq: str):
"""ANARCI IMGT-number a chain -> [(imgt_int, insertion, aa)] for present residues.
Returns None if numbering fails / ANARCI unavailable."""
if not HAVE_ANARCI:
return None
try:
numbering, _chain_type = anarci_number(seq, scheme="imgt")
if not numbering:
return None
out = []
for (pos, ins), aa in numbering:
if aa == "-":
continue
out.append((pos, ins, aa))
return out
except Exception:
return None
def cdr_of(imgt: int):
for name, (lo, hi) in CDR_RANGES.items():
if lo <= imgt <= hi:
return name
return None
# -------------------------------------------------------------- accessibility
def rsa_map(pdb: str):
"""Per-residue relative solvent accessibility from the folded Fv.
Returns {(chain_id, imgt_resseq): rsa} or None if SASA is unavailable.
Computed on the whole Fv, so the VH/VL interface counts as buried."""
if not HAVE_SASA or not pdb:
return None
try:
model = PDBParser(QUIET=True).get_structure("ab", io.StringIO(pdb))[0]
ShrakeRupley().compute(model, level="R") # sets .sasa on each residue
out = {}
for chain in model:
for res in chain:
if res.resname not in MAXASA:
continue
rsa = res.sasa / MAXASA[res.resname]
key = (chain.id, res.id[1]) # (chain, resseq); insertion code dropped
out[key] = max(out.get(key, 0.0), rsa) # keep max across insertions
return out
except Exception:
return None
def exposure_tier(rsa):
"""buried (<15%), partial (15-30%), exposed (>=30%), or None if no RSA."""
if rsa is None:
return None
if rsa < 0.15:
return "buried"
if rsa < 0.30:
return "partial"
return "exposed"
def adjust_severity(raw_sev: str, tier, desc: str) -> str:
"""Down-rank a sequence-motif flag by how buried the residue is: buried motifs
can't undergo solvent-driven chemistry (oxidation, deamidation, glycosylation).
A free cysteine is a covalent/structural concern beyond exposure, so it is
never down-ranked more than one level."""
if tier is None:
return raw_sev
steps = {"exposed": 0, "partial": 1, "buried": 2}[tier]
if "cysteine" in desc.lower():
steps = min(steps, 1)
i = min(SEV_LEVELS.index(raw_sev) + steps, len(SEV_LEVELS) - 1)
return SEV_LEVELS[i]
# ---------------------------------------------------------------- liabilities
def scan_chain(chain_label: str, residues):
"""Scan one numbered chain for sequence-liability motifs.
Returns (flags, highlight_imgt_positions). Each flag: (sev, chain, imgt, desc, loc)."""
flags, highlights = [], []
aas = [r[2] for r in residues]
imgts = [r[0] for r in residues]
n = len(residues)
for i in range(n):
imgt, aa = imgts[i], aas[i]
cdr = cdr_of(imgt)
loc = f"CDR-{chain_label}{cdr}" if cdr else "framework"
nxt = aas[i + 1] if i + 1 < n else ""
nxt2 = aas[i + 2] if i + 2 < n else ""
# N-glycosylation sequon N-X-[S/T], X != P
if aa == "N" and nxt and nxt != "P" and nxt2 in ("S", "T"):
sev = "High" if cdr else "Medium"
flags.append((sev, chain_label, imgt, f"N-glycosylation sequon (N{nxt}{nxt2})", loc))
highlights.append(imgt)
# Deamidation NG (fast) ; NS/NT/NH (slow, only flag in CDR)
if aa == "N" and nxt == "G":
flags.append(("High" if cdr else "Medium", chain_label, imgt,
"Deamidation motif (NG)", loc))
highlights.append(imgt)
elif aa == "N" and nxt in ("S", "T", "H") and cdr:
flags.append(("Low", chain_label, imgt, f"Deamidation motif (N{nxt})", loc))
highlights.append(imgt)
# Isomerization DG (fast) ; DS/DT (slow, only flag in CDR)
if aa == "D" and nxt == "G":
flags.append(("High" if cdr else "Medium", chain_label, imgt,
"Isomerization motif (DG)", loc))
highlights.append(imgt)
elif aa == "D" and nxt in ("S", "T") and cdr:
flags.append(("Low", chain_label, imgt, f"Isomerization motif (D{nxt})", loc))
highlights.append(imgt)
# Acid-labile peptide bond DP
if aa == "D" and nxt == "P":
flags.append(("Low", chain_label, imgt, "Acid-labile bond (DP)", loc))
highlights.append(imgt)
# Oxidation-prone Met / Trp — only a liability when exposed (i.e. in a CDR)
if aa in ("M", "W") and cdr:
res = "Met" if aa == "M" else "Trp"
flags.append(("Medium", chain_label, imgt, f"Oxidation-prone {res} in CDR", loc))
highlights.append(imgt)
# Free / non-canonical cysteine (canonical intradomain disulfide = IMGT 23 & 104)
for i in range(n):
if aas[i] == "C" and imgts[i] not in (23, 104):
cdr = cdr_of(imgts[i])
loc = f"CDR-{chain_label}{cdr}" if cdr else "framework"
flags.append(("High", chain_label, imgts[i], "Unpaired / non-canonical cysteine", loc))
highlights.append(imgts[i])
return flags, highlights
def developability(heavy: str, light: str, pdb: str = None):
"""Scan both chains + CDR-H3 length, then gate by solvent exposure using the
folded structure. Returns (flags, highlights_by_chain, h3_len, ok).
Each flag: (sev, chain, imgt, desc, loc, rsa, tier) where sev is the
exposure-adjusted severity and rsa/tier are None when no structure is given."""
raw_flags = []
h3_len = None
ok = True
for label, seq in (("H", heavy), ("L", light)):
residues = number_chain(seq)
if residues is None:
ok = False
continue
flags, _hi = scan_chain(label, residues)
raw_flags += flags
if label == "H":
h3_len = sum(1 for (imgt, _ins, _aa) in residues if 105 <= imgt <= 117)
if h3_len >= 18:
raw_flags.append(("High", "H", 105,
f"Long CDR-H3 ({h3_len} aa) — aggregation risk", "CDR-H3"))
# Exposure-gate each flag against the folded structure (if available).
rmap = rsa_map(pdb)
enriched = []
highlights = {"H": [], "L": []}
for sev, chain, imgt, desc, loc in raw_flags:
# CDR-H3 length is a whole-loop property, not single-residue exposure.
is_h3len = desc.startswith("Long CDR-H3")
rsa = None if is_h3len else (rmap.get((chain, imgt)) if rmap else None)
tier = exposure_tier(rsa)
adj = sev if is_h3len else adjust_severity(sev, tier, desc)
enriched.append((adj, chain, imgt, desc, loc, rsa, tier))
# Paint only residues whose flag survives exposure gating (High/Medium).
if adj in ("High", "Medium") and not is_h3len and chain in highlights:
highlights[chain].append(imgt)
return enriched, highlights, h3_len, ok
def format_flags(flags, h3_len, ok):
"""Render the liability panel as Markdown."""
if not ok:
return ("**Developability:** could not IMGT-number the sequences (ANARCI). "
"Flags unavailable — check that both chains are valid variable domains.")
if not flags:
return ("### ✅ No sequence liabilities flagged\n"
"No glycosylation sequons, PTM hotspots, or free cysteines found in the CDRs "
"or framework"
+ (f", and CDR-H3 length is normal ({h3_len} aa)" if h3_len else "")
+ ".")
flags_sorted = sorted(flags, key=lambda f: (SEV_ORDER[f[0]], f[1], f[2]))
highs = sum(1 for f in flags if f[0] == "High")
meds = sum(1 for f in flags if f[0] == "Medium")
lows = sum(1 for f in flags if f[0] == "Low")
mins = sum(1 for f in flags if f[0] == "Minimal")
have_exposure = any(f[6] is not None for f in flags)
def _exp_cell(rsa, tier):
if rsa is None:
return "—"
return f"{tier} ({rsa * 100:.0f}%)"
tally = f"{highs} high · {meds} medium · {lows} low"
if mins:
tally += f" · {mins} minimal"
lines = [
f"### Developability flags — {tally}",
("Severity is **adjusted by solvent exposure** from the fold: buried motifs are "
"down-ranked because they can't undergo solvent-driven chemistry. Surviving "
"(high/medium) liabilities are drawn as **magenta sticks** on the structure."
if have_exposure else
"Flagged residues are drawn as **magenta sticks** on the structure."),
"",
"| Severity | Exposure | Chain | IMGT | Location | Liability |",
"|---|---|---|---|---|---|",
]
for sev, chain, imgt, desc, loc, rsa, tier in flags_sorted:
lines.append(
f"| {SEV_ICON[sev]} {sev} | {_exp_cell(rsa, tier)} | {chain} | {imgt} | {loc} | {desc} |"
)
footer = ("_Heuristic screens, not disqualifiers. Exposure (relative solvent accessibility) "
"sharpens the ranking but is not the whole story: an exposed motif can still be "
"fine (far from the paratope, slow kinetics, controlled by formulation). Confirm "
"experimentally before acting._")
lines += ["", footer]
return "\n".join(lines)
# -------------------------------------------------------------------- viewer
# Per-residue colors for the clickable sequence tracks (CDRs reuse the 3D colors;
# "liab" is the same magenta as the liability sticks on the structure).
TRACK_COLORS = {
"H1": CDR_COLORS["H"]["1"], "H2": CDR_COLORS["H"]["2"], "H3": CDR_COLORS["H"]["3"],
"L1": CDR_COLORS["L"]["1"], "L2": CDR_COLORS["L"]["2"], "L3": CDR_COLORS["L"]["3"],
"liab": "#ff00ff", "fw": "#eceff1",
}
def build_track(chain_label: str, residues, liab_imgts=()):
"""Numbered residues -> (HighlightedText tokens, index->imgt map).
Each residue is its own token (combine_adjacent=False) so it clicks individually.
Liability residues (same set shown as magenta sticks in 3D) are tagged 'liab'
so they read magenta in the sequence, matching the structure."""
tokens, idxmap = [], []
liab = set(liab_imgts)
for imgt, _ins, aa in residues:
cdr = cdr_of(imgt)
if imgt in liab:
cat = "liab"
elif cdr:
cat = f"{chain_label}{cdr}"
else:
cat = "fw"
tokens.append((aa, cat))
idxmap.append(imgt)
return tokens, idxmap
FOCUS_CHOICES = ["Both chains", "Heavy only (VH)", "Light only (VL)"]
MODE_CHOICES = ["Grey out others", "Hide others"]
_NONCE = itertools.count(1)
def build_payload(pdb: str, highlights) -> str:
"""JSON handed to the client-side viewer: the structure + liability positions.
The nonce guarantees the value changes each fold so the .change bridge fires."""
highlights = highlights or {}
return json.dumps({
"pdb": pdb,
"highlights": {"H": highlights.get("H", []), "L": highlights.get("L", [])},
"n": next(_NONCE),
})
def toggle_payload(chain_label: str, evt, idxmap) -> str:
"""JSON telling the client to toggle a lime stick on one clicked residue."""
idx = getattr(evt, "index", None)
if isinstance(idx, (list, tuple)):
idx = idx[0] if idx else None
imap = (idxmap or {}).get(chain_label, [])
imgt = imap[idx] if isinstance(idx, int) and 0 <= idx < len(imap) else None
return json.dumps({"chain": chain_label, "imgt": imgt, "n": next(_NONCE)})
# Client-side 3Dmol controller. One persistent viewer; every interaction updates
# it IN PLACE (no re-render, no zoomTo) so the camera / orientation is preserved.
# Loaded once via demo.load(js=...); it also injects 3Dmol.js from the CDN.
_CDR_JS = json.dumps(CDR_COLORS)
_RANGES_JS = json.dumps({k: list(v) for k, v in CDR_RANGES.items()})
CONTROLLER_JS = """
() => {
if (window.__myabsReady) return;
window.__myabsReady = true;
const CDR = __CDR__;
const RANGES = __RANGES__;
const rlist = (lo,hi) => { let a=[]; for(let i=lo;i<=hi;i++) a.push(i); return a; };
const S = window.myabsState = {viewer:null, highlights:{H:[],L:[]}, picks:{H:[],L:[]},
focus:["H","L"], mode:"grey", spinning:false, colorMode:"cdr"};
// Per-residue predicted error (PDB B-factor, Å) -> color. 0 = confident (blue),
// >= CONF_MAX = uncertain (red), through yellow. Matches AlphaFold-style intuition.
const CONF_MAX = 1.5;
const confColor = (atom) => {
const t = Math.max(0, Math.min(1, (atom.b || 0) / CONF_MAX));
let r,g,b;
if (t < 0.5){ const u=t/0.5; r=Math.round(43+(240-43)*u); g=Math.round(131+(200-131)*u); b=Math.round(186+(50-186)*u); }
else { const u=(t-0.5)/0.5; r=Math.round(240+(215-240)*u); g=Math.round(200+(48-200)*u); b=Math.round(50+(39-50)*u); }
return "rgb("+r+","+g+","+b+")";
};
window.myabsApply = () => {
const v = S.viewer; if(!v) return;
v.setStyle({}, {});
["H","L"].forEach(ch => {
if (S.focus.includes(ch)) {
if (S.colorMode === "confidence") {
v.addStyle({chain:ch}, {cartoon:{colorfunc: confColor}}); // color by predicted error
} else {
v.addStyle({chain:ch}, {cartoon:{color:"#cfd8dc"}});
for (const c in RANGES){ const r=RANGES[c];
v.addStyle({chain:ch, resi:rlist(r[0],r[1])}, {cartoon:{color:CDR[ch][c]}}); }
}
const hl=S.highlights[ch]||[]; if(hl.length) v.addStyle({chain:ch, resi:hl},{stick:{color:"magenta",radius:0.3}});
const pk=S.picks[ch]||[]; if(pk.length) v.addStyle({chain:ch, resi:pk},{stick:{color:"lime",radius:0.3}});
} else if (S.mode === "grey") {
v.addStyle({chain:ch}, {cartoon:{color:"#c7ccd1", opacity:0.35}});
}
});
v.render();
};
window.myabsInit = () => {
if (S.viewer) return true;
const el = document.getElementById("myabs-viewer");
if (!el || !window.$3Dmol) return false;
S.viewer = $3Dmol.createViewer(el, {backgroundColor:"white"});
return true;
};
window.myabsLoad = (payload) => {
if (!payload) return;
let p; try { p = JSON.parse(payload); } catch(e){ return; }
if (!p.pdb) return;
if (!window.myabsInit()) { setTimeout(()=>window.myabsLoad(payload), 200); return; }
const v = S.viewer;
S.highlights = p.highlights || {H:[],L:[]}; S.picks = {H:[],L:[]}; S.spinning = false;
v.removeAllModels(); v.addModel(p.pdb, "pdb");
window.myabsApply();
v.zoomTo(); v.render(); // new molecule: recentering here is expected
};
window.myabsSetFocus = (choice) => {
const M = {"Both chains":["H","L"], "Heavy only (VH)":["H"], "Light only (VL)":["L"]};
S.focus = M[choice] || ["H","L"]; window.myabsApply(); // no zoomTo -> view kept
};
window.myabsSetMode = (m) => {
S.mode = (m && m.indexOf("Hide")>=0) ? "hide" : "grey"; window.myabsApply();
};
window.myabsSetColorMode = (m) => {
S.colorMode = (m && m.indexOf("onfidence")>=0) ? "confidence" : "cdr"; window.myabsApply();
};
window.myabsToggleResidue = (payload) => {
let p; try { p = JSON.parse(payload); } catch(e){ return; }
if (p.imgt == null || !p.chain) return;
const arr = S.picks[p.chain] || (S.picks[p.chain]=[]);
const i = arr.indexOf(p.imgt); if (i>=0) arr.splice(i,1); else arr.push(p.imgt);
window.myabsApply();
};
window.myabsClear = () => { S.picks = {H:[],L:[]}; window.myabsApply(); };
window.myabsToggleSpin = () => {
const v = S.viewer; if (!v) return "▶ Spin";
S.spinning = !S.spinning; v.spin(S.spinning ? "y" : false);
return S.spinning ? "⏸ Stop" : "▶ Spin";
};
if (!window.$3Dmol) {
const s = document.createElement("script");
// Pinned, immutable version + Subresource Integrity so a compromised CDN
// cannot inject arbitrary JS into users' browsers.
s.src = "https://cdn.jsdelivr.net/npm/3dmol@2.5.5/build/3Dmol-min.js";
s.integrity = "sha384-OsczYbldvrHgslr9fFp/i4GiLSeuw9l+QIlv99ITw8soOwXcoGeflFMLg+CU/X1d";
s.crossOrigin = "anonymous";
s.onload = () => window.myabsInit();
document.head.appendChild(s);
} else { window.myabsInit(); }
}
""".replace("__CDR__", _CDR_JS).replace("__RANGES__", _RANGES_JS)
# ---------------------------------------------------------------------- fold
def fold_confidence(pdb: str) -> str:
"""Readout of ABodyBuilder2's per-residue predicted error (PDB B-factor = RMS
spread across the 4-model ensemble, Å; lower = models agree = more confident)."""
if not HAVE_SASA or not pdb:
return ""
try:
model = PDBParser(QUIET=True).get_structure("ab", io.StringIO(pdb))[0]
except Exception:
return ""
per, h3 = [], []
for chain in model:
for res in chain:
bs = [a.get_bfactor() for a in res]
if not bs:
continue
b = sum(bs) / len(bs)
per.append(b)
if chain.id == "H" and 105 <= res.id[1] <= 117:
h3.append(b)
if not per:
return ""
def band(x):
return "high" if x < 0.5 else ("moderate" if x < 1.5 else "low")
mean = sum(per) / len(per)
out = [f"**Fold confidence:** mean predicted error **{mean:.2f} Å** "
f"({band(mean)} confidence)."]
if h3:
h3m = sum(h3) / len(h3)
note = ""
if h3m >= 1.5:
note = " — the least certain region, so treat its liability flags with extra caution"
elif h3m >= 0.8:
note = " — moderate certainty"
out.append(f"CDR-H3 **{h3m:.2f} Å**{note}.")
out.append("_Predicted error = spread across the 4-model ensemble (lower = models agree). "
"Set the viewer's \"Color by\" to **Fold confidence** to see it on the structure "
"(blue = confident → red = uncertain)._")
return " ".join(out)
MAX_CHAIN_LEN = 250 # antibody variable domains are ~110-130 aa; a generous DoS cap
def fold(heavy: str, light: str):
"""Fold VH+VL, scan liabilities, and hand the structure to the client viewer."""
heavy, light = clean(heavy), clean(light)
reset_btn = gr.update(value="▶ Spin")
# trailing 8 outputs (everything after `status`) for the early-return paths
tail = ("", "", None, "", [], [], {"H": [], "L": []}, reset_btn)
if not heavy or not light:
return ("Enter both a heavy and a light chain.",) + tail
if len(heavy) > MAX_CHAIN_LEN or len(light) > MAX_CHAIN_LEN:
return (f"Sequence too long (VH {len(heavy)}, VL {len(light)} aa; max "
f"{MAX_CHAIN_LEN} per chain). Paste one antibody variable domain per box.",) + tail
try:
t0 = time.time()
antibody = PREDICTOR.predict({"H": heavy, "L": light})
dt = time.time() - t0
# Per-fold unique dir so concurrent public users never share the output
# file (the download basename stays a clean "myabs_fold.pdb").
out_path = os.path.join(tempfile.mkdtemp(prefix="myabs_"), "myabs_fold.pdb")
antibody.save(out_path)
except Exception: # OpenMM refinement / numbering can occasionally fail
return ("Fold failed. Check that both inputs are valid antibody "
"variable-domain sequences.",) + tail
pdb = open(out_path).read()
flags, highlights, h3_len, ok = developability(heavy, light, pdb)
status = (f"Folded in {dt:.1f} s · VH {len(heavy)} aa / VL {len(light)} aa · "
f"CDRs highlighted (H: yellow/orange/red, L: cyan/blue).")
conf_md = fold_confidence(pdb)
flags_md = format_flags(flags, h3_len, ok)
h_tokens, h_idx = build_track("H", number_chain(heavy) or [], highlights.get("H", []))
l_tokens, l_idx = build_track("L", number_chain(light) or [], highlights.get("L", []))
idxmap = {"H": h_idx, "L": l_idx}
payload = build_payload(pdb, highlights) # -> client-side viewer via .change bridge
return (status, conf_md, flags_md, out_path, payload,
h_tokens, l_tokens, idxmap, reset_btn)
def load_library(name: str):
"""Populate VH/VL from the therapeutic library + show provenance."""
entry = LIBRARY.get(name)
if not entry: # "paste your own"
return (DEMO_H, DEMO_L,
"_Built-in demo Fv (illustrative, not a real drug). "
"Pick a therapeutic above, or paste your own sequences._")
prov = (f"**{name.split(' · ')[0]}** · Target: **{entry['target']}** · "
f"variable domains from a public source: {entry['src']} "
f"([reference]({entry['url']})).")
return entry["H"], entry["L"], prov
def click_heavy(idxmap, evt: gr.SelectData):
"""Map a heavy-chain track click to its IMGT position for the client to toggle."""
return toggle_payload("H", evt, idxmap)
def click_light(idxmap, evt: gr.SelectData):
return toggle_payload("L", evt, idxmap)
with gr.Blocks(title="MyAbs") as demo: # theme moved to launch() in Gradio 6
gr.Markdown(
"# MyAbs\n"
"**Look at an antibody candidate, fold it, see its CDR loops, and flag its "
"developability liabilities — in your browser.**\n\n"
"_In-silico candidates only. Not patent-ready antibodies; wet-lab validation required._"
)
with gr.Row():
with gr.Column(scale=2):
lib_dd = gr.Dropdown(
choices=list(LIBRARY.keys()), value=PASTE,
label="Load a known therapeutic (public sequences) — or paste your own",
)
provenance = gr.Markdown()
h_in = gr.Textbox(label="Heavy chain (VH)", value=DEMO_H, lines=4)
l_in = gr.Textbox(label="Light chain (VL)", value=DEMO_L, lines=4)
with gr.Row():
go = gr.Button("Fold", variant="primary")
spin_btn = gr.Button("▶ Spin")
status = gr.Markdown()
conf_md = gr.Markdown()
pdb_file = gr.File(label="Download structure (.pdb)")
with gr.Column(scale=3):
with gr.Row():
focus_dd = gr.Radio(choices=FOCUS_CHOICES, value="Both chains",
label="Focus chain")
mode_dd = gr.Radio(choices=MODE_CHOICES, value="Grey out others",
label="The non-focused chain is…")
color_dd = gr.Radio(choices=["CDR regions", "Fold confidence"],
value="CDR regions", label="Color by")
# Persistent 3Dmol viewer container. Updated in place by the client-side
# controller (CONTROLLER_JS) so interactions never reset the camera.
viewer = gr.HTML(
''
)
gr.HTML(LEGEND_HTML)
gr.HTML(
"Confidence scale "
"(when Color by → Fold confidence): "
"■ confident → "
"■ → "
"■ uncertain "
" (predicted error, Å)
"
)
gr.Markdown(
"**Rotate it yourself** — _Touchscreen:_ one-finger drag = rotate · "
"pinch = zoom · two-finger drag = pan. _Mouse:_ drag = rotate · "
"scroll = zoom · right-drag = pan."
)
gr.Markdown(
"**Click a residue below to highlight it on the structure (lime stick).** "
"Click it again to remove it. CDR residues are pre-colored; "
"**liability residues are magenta** (same as the sticks in 3D)."
)
h_track = gr.HighlightedText(label="Heavy chain (VH)", combine_adjacent=False,
show_legend=False, color_map=TRACK_COLORS)
l_track = gr.HighlightedText(label="Light chain (VL)", combine_adjacent=False,
show_legend=False, color_map=TRACK_COLORS)
clear_btn = gr.Button("Clear clicked highlights", size="sm")
gr.Markdown("---")
flags_md = gr.Markdown()
idxmap_state = gr.State({"H": [], "L": []}) # track index -> imgt, per chain
# Hidden bridges: server writes JSON here, a .change(js=...) hands it to the viewer.
load_box = gr.Textbox(visible=False) # fold -> myabsLoad
toggle_box = gr.Textbox(visible=False) # residue click -> myabsToggleResidue
fold_outputs = [status, conf_md, flags_md, pdb_file, load_box, h_track, l_track,
idxmap_state, spin_btn]
go.click(fold, inputs=[h_in, l_in], outputs=fold_outputs)
# Pick a therapeutic -> load its sequences + provenance -> auto-fold.
lib_dd.change(load_library, inputs=lib_dd, outputs=[h_in, l_in, provenance]).then(
fold, inputs=[h_in, l_in], outputs=fold_outputs
)
# --- client-side viewer controls (no server round-trip, camera preserved) ---
load_box.change(None, inputs=[load_box], js="(p) => window.myabsLoad(p)")
toggle_box.change(None, inputs=[toggle_box], js="(p) => window.myabsToggleResidue(p)")
focus_dd.change(None, inputs=[focus_dd], js="(c) => window.myabsSetFocus(c)")
mode_dd.change(None, inputs=[mode_dd], js="(m) => window.myabsSetMode(m)")
color_dd.change(None, inputs=[color_dd], js="(m) => window.myabsSetColorMode(m)")
spin_btn.click(None, outputs=[spin_btn], js="() => window.myabsToggleSpin()")
clear_btn.click(None, js="() => window.myabsClear()")
# Residue click: server maps track index -> IMGT, client applies the lime stick.
h_track.select(click_heavy, inputs=[idxmap_state], outputs=[toggle_box])
l_track.select(click_light, inputs=[idxmap_state], outputs=[toggle_box])
# Load 3Dmol.js + define the controller once, on page load.
demo.load(None, js=CONTROLLER_JS)
if __name__ == "__main__":
# Local dev defaults to 127.0.0.1:7900. On an HF Docker Space the Dockerfile
# sets GRADIO_SERVER_NAME=0.0.0.0 and GRADIO_SERVER_PORT=7860 (app_port).
host = os.environ.get("GRADIO_SERVER_NAME", "127.0.0.1")
port = int(os.environ.get("GRADIO_SERVER_PORT", "7900"))
demo.launch(server_name=host, server_port=port, theme=gr.themes.Soft())