LLPSense / app.py
Minjun Kang
Preload ProtT5 weights on CPU before the GPU call, increase GPU duration
e4ecaa0
Raw
History Blame Contribute Delete
30 kB
"""
LLPSense Gradio Demo
Condition-dependent protein LLPS prediction using ProtT5 + XGBoost
Pipeline:
(1) User inputs amino-acid sequence
(2) Click "Extract Feature" → mean-pool ProtT5-XL embedding (1024-dim)
(3) Tab 1 – Predict LLPS Probability: adjust temp / conc / pH sliders → predict
(4) Tab 2 – Condition Screening: pick one condition to vary, fix the rest → plot
"""
import sys
import warnings
from pathlib import Path
from copy import deepcopy
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import joblib
import gradio as gr
import spaces
from huggingface_hub import snapshot_download
from examples import EXAMPLES, feature_path, find_example_by_seq
# Only surface examples that actually have a pre-computed T5 feature cached
# in assets/ — an entry added via preprocess/add_example.py but not yet run
# through preprocess/extract_example_feat.py would otherwise show up in the
# picker and silently fall back to a slow/GPU extraction on first click.
AVAILABLE_EXAMPLES = [example for example in EXAMPLES if feature_path(example["id"]).exists()]
from t5_utils import (
T5_REPO_ID,
extract_t5_feature as _extract_t5_feature_core,
preload_t5_cpu,
read_feature_h5,
)
warnings.filterwarnings("ignore")
# ── Paths ─────────────────────────────────────────────────────────────────────
BASE_DIR = Path(__file__).parent
# ── ProtT5 prefetch ────────────────────────────────────────────────────────────
# This SDK-gradio Space builds from requirements.txt, not the repo's Dockerfile,
# so there is no build-time prefetch step. Download the weights here instead,
# at module import (app startup, plain CPU context) — before any @spaces.GPU
# call — so extract_t5_feature() never blocks on a network download while
# holding a ZeroGPU allocation (which has a short time budget).
snapshot_download(T5_REPO_ID)
# Also deserialize the weights into CPU memory here, still outside any
# @spaces.GPU context. Without this, T5EncoderModel.from_pretrained() (loading
# ~2.9GB from disk) would run lazily inside the first GPU-decorated call and
# could eat enough of the ZeroGPU time budget to abort the task. With this,
# the first GPU call only needs a fast .to("cuda") transfer.
preload_t5_cpu()
# ── Physical constants (from preprocess/misc.py) ──────────────────────────────
MAX_TEMP = 60.0
MAX_CONC = 1000.0
MAX_PH = 14.0
MAX_MGCL2 = 50.0
MAX_NACL = 2000.0
MAX_KCL = 1000.0
MAX_CAGENT = 50.0
MAX_GLYC = 10.0
# ── Screening ranges ─────────────────────────────────────────────────────────
SCREEN_RANGES = {
"Temperature": np.arange(0.0, 61.0, 1.0), # 0–60 °C
"Concentration": np.arange(0.0, 1010.0, 10.0), # 0–1000 µM
"pH": np.arange(4.0, 12.1, 0.1), # 4.0–12.0
}
SCREEN_KEYS = {
"Temperature": "temp",
"Concentration": "conc",
"pH": "pH",
}
SCREEN_LABELS = {
"Temperature": "Temperature (°C)",
"Concentration": "Concentration (µM)",
"pH": "pH",
}
VALID_AA = set("ACDEFGHIKLMNPQRSTVWY")
ALLOW_AA = VALID_AA | set("XBJOUZ")
# ── Lazy-loaded singletons ────────────────────────────────────────────────────
_llps_model = None
def load_llps_model():
global _llps_model
if _llps_model is None:
model_path = BASE_DIR / "models" / "LLPSense.pkl"
if not model_path.exists():
raise FileNotFoundError(
f"Model file not found: {model_path}\n"
"Please place 'LLPSense.pkl' inside the 'models/' directory."
)
d = joblib.load(model_path)
mdl = d["model"]
# Force XGBoost to run on CPU to avoid device-mismatch warnings
mdl.set_params(device="cpu")
_llps_model = mdl
return _llps_model
# ── Feature extraction ────────────────────────────────────────────────────────
# The actual tokenize/forward/mean-pool logic lives in t5_utils.py, shared
# with preprocess/extract_example_feat.py so the offline-cached example
# features in assets/ always match what this would compute live. Only the
# @spaces.GPU wrapping (ZeroGPU allocation) is app-specific, and cb_extract()
# below skips calling this entirely when a cached feature is available.
@spaces.GPU(duration=180)
def extract_t5_feature(sequence: str) -> np.ndarray:
return _extract_t5_feature_core(sequence)
# ── Condition vector builder ──────────────────────────────────────────────────
def build_cond(temp, conc, pH,
nacl=160.0, mgcl2=0.0, kcl=0.0, glyc=0.0,
peg1=0.0, peg2=0.0, peg3=0.0,
ficoll=0.0, dext40=0.0, dext70=0.0) -> np.ndarray:
"""
Normalise environmental parameters and return a 13-dim condition vector.
Order: [temp, conc, pH, PEG300-1k, PEG3k-6k, PEG8k-20k,
Ficoll, Dextran≤40, Dextran≥70, MgCl2, NaCl, KCl, Glycerol]
"""
return np.array([
temp / MAX_TEMP,
conc / MAX_CONC,
pH / MAX_PH,
peg1 / MAX_CAGENT,
peg2 / MAX_CAGENT,
peg3 / MAX_CAGENT,
ficoll / MAX_CAGENT,
dext40 / MAX_CAGENT,
dext70 / MAX_CAGENT,
mgcl2 / MAX_MGCL2,
nacl / MAX_NACL,
kcl / MAX_KCL,
glyc / MAX_GLYC,
], dtype=np.float32)
def model_predict(feat: np.ndarray, cond: np.ndarray) -> float:
model = load_llps_model()
x = np.concatenate([feat, cond]).reshape(1, -1)
return float(model.predict_proba(x)[0, 1])
# ── Smoothing (same as LLPSXG.py's moving_average) ────────────────────────────
def moving_average(y: np.ndarray, window_size: int) -> np.ndarray:
if window_size % 2 == 0:
raise ValueError("Window size should be odd to ensure symmetry.")
window = np.ones(int(window_size)) / float(window_size)
y_padded = np.pad(y, (window_size // 2, window_size // 2), mode="edge")
return np.convolve(y_padded, window, "valid")
# ── Matplotlib helpers ────────────────────────────────────────────────────────
def prob_gauge_figure(prob: float) -> plt.Figure:
"""Horizontal probability bar gauge."""
LLPS_COLOR = "#e74c3c"
NON_COLOR = "#2980b9"
color = LLPS_COLOR if prob >= 0.5 else NON_COLOR
label = "Phase Separating" if prob >= 0.5 else "Non-Phase Separating"
fig, ax = plt.subplots(figsize=(7, 2.8))
ax.barh([0], [prob], height=0.55, color=color, alpha=0.88, zorder=3)
ax.barh([0], [1 - prob], height=0.55, left=prob,
color="#ecf0f1", alpha=0.9, zorder=2)
ax.axvline(0.5, color="#2c3e50", lw=1.8, ls="--", label="Threshold 0.5", zorder=4)
ax.set_xlim(0, 1)
ax.set_ylim(-0.55, 0.55)
ax.set_yticks([])
ax.set_xlabel("LLPS Probability", fontsize=12)
ax.set_title(f"{label} | Probability: {prob:.4f}",
fontsize=14, fontweight="bold", color=color, pad=10)
ax.legend(fontsize=10, loc="lower right")
ax.spines[["top", "right", "left"]].set_visible(False)
plt.tight_layout()
return fig
def screening_figure(xvals: np.ndarray, probs: np.ndarray,
screen_name: str, probs_smooth: np.ndarray = None) -> plt.Figure:
"""Line graph for condition screening result.
If probs_smooth differs from probs (smoothing window > 1), the raw curve
is drawn as a faint dotted reference line and the smoothed curve becomes
the main plotted/filled line.
"""
xlabel = SCREEN_LABELS[screen_name]
LLPS_COLOR = "#e74c3c"
NON_COLOR = "#2980b9"
smoothed = probs_smooth is not None and not np.array_equal(probs, probs_smooth)
plot_probs = probs_smooth if smoothed else probs
fig, ax = plt.subplots(figsize=(9, 5))
if smoothed:
ax.plot(xvals, probs, lw=1.2, color=NON_COLOR, alpha=0.35, ls=":",
label="Raw", zorder=2)
ax.plot(xvals, plot_probs, lw=2.5, color=NON_COLOR,
label="Smoothed LLPS Probability" if smoothed else "LLPS Probability",
zorder=3)
ax.axhline(0.5, color=LLPS_COLOR, lw=1.8, ls="--",
label="Threshold 0.5", zorder=4)
ax.fill_between(xvals, plot_probs, 0.5,
where=(plot_probs >= 0.5), alpha=0.22,
color=LLPS_COLOR, label="LLPS region", zorder=2)
ax.fill_between(xvals, plot_probs, 0.5,
where=(plot_probs < 0.5), alpha=0.15,
color=NON_COLOR, label="Non-LLPS region", zorder=2)
ax.set_xlim(xvals[0], xvals[-1])
ax.set_ylim(0, 1)
ax.set_xlabel(xlabel, fontsize=13)
ax.set_ylabel("LLPS Probability", fontsize=13)
ax.set_title(f"Condition Screening — {xlabel}", fontsize=14, fontweight="bold")
ax.legend(fontsize=11, loc="upper right")
ax.grid(True, alpha=0.3)
ax.spines[["top", "right"]].set_visible(False)
plt.tight_layout()
return fig
# ── Status HTML templates ─────────────────────────────────────────────────────
_SPINNER_HTML = """
<div style="display:flex;align-items:center;gap:14px;padding:10px 4px;">
<div style="
width:28px;height:28px;flex-shrink:0;
border:3px solid #fecaca;
border-top-color:#ef4444;
border-radius:50%;
animation:llps-spin 0.85s linear infinite;
"></div>
<span style="color:#555;font-size:14px;font-weight:500;line-height:1.5;">
ProtT5-XL feature extraction in progress…<br>
<span style="font-size:12px;color:#999;font-weight:400;">
First run loads the model onto the GPU — this may take a moment.
</span>
</span>
</div>
<style>@keyframes llps-spin{to{transform:rotate(360deg)}}</style>
"""
def _status_ok(seq_len: int, feat_dim: int) -> str:
pill = ("background:#f0fdf4;color:#15803d;border:1px solid #bbf7d0;"
"border-radius:9999px;padding:2px 10px;font-size:12px;font-weight:600;"
"display:inline-block;margin:0 4px 0 0;")
return (f'<div style="color:#16a34a;font-weight:600;padding:6px 0;display:flex;align-items:center;gap:6px;">'
f'<span>✅ Feature extracted</span>'
f'<span style="{pill}">Length: {seq_len} AA</span>'
f'</div>')
def _status_warn(msg: str) -> str:
return f'<div style="color:#d97706;padding:6px 0;">⚠️ {msg}</div>'
def _status_err(msg: str) -> str:
return f'<div style="color:#dc2626;padding:6px 0;">❌ {msg}</div>'
# ── Gradio callback functions ─────────────────────────────────────────────────
def cb_extract(sequence: str):
"""Step 2: Extract ProtT5 feature from sequence (generator → streams status).
Every yield also clears feat_state and the Step-3 result panels so a
stale feature/result from a previously extracted sequence can never
remain visible or be used once a new extraction starts.
"""
seq = sequence.strip().upper()
if not seq:
yield None, _status_warn("Please enter a protein sequence."), None, "", None, ""
return
invalid = set(seq) - ALLOW_AA
if invalid:
yield None, _status_warn(f"Invalid characters: <code>{''.join(sorted(invalid))}</code>"), None, "", None, ""
return
# ── invalidate old feature/results immediately, then show spinner ─────────
yield None, _SPINNER_HTML, None, "", None, ""
try:
# Known example sequence with a pre-computed feature? Skip the T5
# model/GPU call entirely and load it straight from assets/.
example = find_example_by_seq(seq)
cached_path = feature_path(example["id"]) if example else None
if cached_path and cached_path.exists():
feat = read_feature_h5(cached_path)
else:
feat = extract_t5_feature(seq)
yield feat, _status_ok(len(seq), feat.shape[0]), None, "", None, ""
except Exception as e:
yield None, _status_err(str(e)), None, "", None, ""
def cb_predict(feat,
temp, conc, pH,
nacl, mgcl2, kcl, glyc,
peg1, peg2, peg3, ficoll, dext40, dext70):
"""Tab 1: Predict LLPS probability for a single condition point."""
if feat is None:
return None, "⚠️ Please extract the T5 feature first (Step 2)."
try:
cond = build_cond(temp, conc, pH, nacl, mgcl2, kcl, glyc,
peg1, peg2, peg3, ficoll, dext40, dext70)
prob = model_predict(feat, cond)
fig = prob_gauge_figure(prob)
label = "**Phase Separating** 🔴" if prob >= 0.5 else "**Non-Phase Separating** 🔵"
txt = f"{label} \nLLPS Probability: **{prob:.4f}**"
return fig, txt
except Exception as e:
return None, f"❌ Prediction failed: {e}"
def cb_screen(feat, screen_name,
fix_temp, fix_conc, fix_pH,
nacl, mgcl2, kcl, glyc,
peg1, peg2, peg3, ficoll, dext40, dext70,
smooth_window):
"""Tab 2: Screen LLPS across a range of one condition."""
if feat is None:
return None, "⚠️ Please extract the T5 feature first (Step 2)."
if screen_name is None:
return None, "⚠️ Please select a condition to screen."
try:
xvals = SCREEN_RANGES[screen_name]
probs = []
for v in xvals:
t = float(v) if screen_name == "Temperature" else fix_temp
c = float(v) if screen_name == "Concentration" else fix_conc
p = float(v) if screen_name == "pH" else fix_pH
cond = build_cond(t, c, p, nacl, mgcl2, kcl, glyc,
peg1, peg2, peg3, ficoll, dext40, dext70)
probs.append(model_predict(feat, cond))
probs = np.array(probs)
# Slider step keeps this odd (1, 3, 5, ...); window=1 is a no-op average.
window = int(smooth_window)
probs_plot = moving_average(probs, window) if window > 1 else probs
fig = screening_figure(xvals, probs, screen_name, probs_plot)
peak_idx = probs_plot.argmax()
xlabel = SCREEN_LABELS[screen_name]
txt = (f"Screening completed. \n"
f"Peak probability **{probs_plot[peak_idx]:.4f}** "
f"at {xlabel} = **{xvals[peak_idx]:.1f}** \n"
f"LLPS-positive range: "
f"**{(probs_plot >= 0.5).sum()}** / {len(probs_plot)} points ≥ 0.5")
if window > 1:
txt += f" \n*(Smoothed with moving-average window size {window})*"
return fig, txt
except Exception as e:
return None, f"❌ Screening failed: {e}"
# ── Gradio UI ─────────────────────────────────────────────────────────────────
DESCRIPTION = """
# 🧬 LLPSense — Condition-Dependent LLPS Prediction
**LLPSense** predicts whether a protein undergoes liquid-liquid phase separation (LLPS)
under user-defined environmental conditions.
> Bae†, Kang†, et al. *"A machine learning framework for predicting and modulating
> condition-dependent protein phase separation."* bioRxiv 2025.
---
## Workflow
1. **Paste your sequence** in the text box below.
2. Click **Extract Feature** to compute the ProtT5-XL embedding.
3. Use **Predict LLPS Probability** to query a specific condition point, or
**Condition Screening** to sweep one condition across its full range.
---
"""
CUSTOM_CSS = """
/* Import fonts from Google Fonts */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');
/* Sequence textbox: monospace font for clean AA letter display */
#seq-input textarea,
#seq-input input {
font-family: 'JetBrains Mono', 'Source Code Pro', 'Courier New', monospace !important;
font-size: 14px !important;
line-height: 1.7 !important;
letter-spacing: 0.04em !important;
}
/* Slider thumb: orange */
input[type="range"]::-webkit-slider-thumb {
background: #ef4444 !important;
border-color: #ef4444 !important;
}
input[type="range"]::-moz-range-thumb {
background: #ef4444 !important;
border-color: #ef4444 !important;
}
/* Slider numeric value input */
input[type="number"] {
font-size: 15px !important;
font-weight: 600 !important;
}
/* Initial state: hide Temperature slider column (default screen condition) */
#s-temp-col { display: none; }
/* Tab buttons */
button[role="tab"] {
background: #ffffff !important;
border: 1.5px solid #d1d5db !important;
border-radius: 8px 8px 0 0 !important;
color: #4b5563 !important;
font-weight: 500 !important;
transition: background 0.15s, color 0.15s !important;
}
button[role="tab"]:hover {
background: #eff6ff !important;
color: #1d4ed8 !important;
border-color: #93c5fd !important;
}
button[role="tab"][aria-selected="true"] {
background: #2563eb !important;
color: #ffffff !important;
border-color: #2563eb !important;
font-weight: 600 !important;
}
"""
with gr.Blocks(
theme=gr.themes.Soft(
primary_hue="blue",
font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "ui-monospace", "monospace"],
),
css=CUSTOM_CSS,
title="LLPSense Demo",
) as demo:
feat_state = gr.State(None)
gr.Markdown(DESCRIPTION)
# ────────────────────────────────────────────────────────────────
# Step 1 + 2: Sequence input & feature extraction
# ────────────────────────────────────────────────────────────────
with gr.Group():
gr.Markdown("## Step 1 — Enter Protein Sequence")
seq_box = gr.Textbox(
label="Amino Acid Sequence (1-letter code)",
placeholder="Paste your protein sequence here (e.g. MDVFMKGLSK…)",
lines=5,
value=AVAILABLE_EXAMPLES[0]["seq"] if AVAILABLE_EXAMPLES else "",
elem_id="seq-input",
)
# Clicking an example fills seq_box; the matching Step-2 extraction
# is chained onto it further down (once the Step-3 output components
# exist) so selecting an example runs extraction automatically.
# Skipped entirely if no example has a cached feature yet.
example_picker = None
if AVAILABLE_EXAMPLES:
with gr.Accordion("🧪 Examples (We provide preprocessed T5 feature)", open=False):
example_picker = gr.Examples(
examples=[[example["seq"]] for example in AVAILABLE_EXAMPLES],
example_labels=[example["name"] for example in AVAILABLE_EXAMPLES],
inputs=[seq_box],
)
with gr.Group():
gr.Markdown("## Step 2 — Extract ProtT5 Feature")
gr.Markdown(
"Runs the [**ProtT5-XL**](https://github.com/agemagician/ProtTrans) encoder to produce a 1024-dim mean-pool embedding. \n"
"⏳ *First call loads the model onto the GPU and may take a moment.*"
)
extract_btn = gr.Button("🔬 Extract Feature", variant="primary", size="lg")
extract_status = gr.HTML("")
# ────────────────────────────────────────────────────────────────
# Step 3: Prediction & Screening
# ────────────────────────────────────────────────────────────────
with gr.Group():
gr.Markdown("## Step 3 — Run Demo")
gr.Markdown(
"Predict LLPS probability for a specific condition, "
"or sweep one condition across its full range."
)
with gr.Tabs():
# ── Tab 1: Single-point prediction ───────────────────────────
with gr.Tab("🔮 Predict LLPS Probability"):
gr.Markdown(
"Set the environmental conditions with the sliders below, "
"then click **Predict** to obtain the LLPS probability."
)
# Primary conditions
with gr.Row():
p_temp = gr.Slider(0, 60, value=25.0, step=0.5,
label="Temperature (°C)")
p_conc = gr.Slider(0, 1000, value=100.0, step=5.0,
label="Concentration (µM)")
p_pH = gr.Slider(0, 14, value=7.3, step=0.1,
label="pH")
# Advanced conditions
with gr.Accordion("⚙️ Advanced Conditions (Salts & Crowding Agents)", open=False):
gr.Markdown("Default values represent a common physiological buffer (160 mM NaCl).")
with gr.Row():
p_nacl = gr.Slider(0, 2000, value=160.0, step=10.0, label="NaCl (mM)")
p_mgcl2 = gr.Slider(0, 50, value=0.0, step=1.0, label="MgCl₂ (mM)")
p_kcl = gr.Slider(0, 1000, value=0.0, step=10.0, label="KCl (mM)")
p_glyc = gr.Slider(0, 10, value=0.0, step=0.5, label="Glycerol (%)")
with gr.Row():
p_peg1 = gr.Slider(0, 50, value=0, step=1, label="PEG 300–1000 (%)")
p_peg2 = gr.Slider(0, 50, value=0, step=1, label="PEG 3k–6k (%)")
p_peg3 = gr.Slider(0, 50, value=0, step=1, label="PEG 8k–20k (%)")
with gr.Row():
p_ficoll = gr.Slider(0, 50, value=0, step=1, label="Ficoll (%)")
p_dext40 = gr.Slider(0, 50, value=0, step=1, label="Dextran ≤40 kDa (%)")
p_dext70 = gr.Slider(0, 50, value=0, step=1, label="Dextran ≥70 kDa (%)")
pred_btn = gr.Button("⚡ Predict LLPS Probability", variant="primary")
pred_plot = gr.Plot(label="Prediction Result")
pred_text = gr.Markdown("")
pred_btn.click(
fn=cb_predict,
inputs=[
feat_state,
p_temp, p_conc, p_pH,
p_nacl, p_mgcl2, p_kcl, p_glyc,
p_peg1, p_peg2, p_peg3, p_ficoll, p_dext40, p_dext70,
],
outputs=[pred_plot, pred_text],
)
# ── Tab 2: Condition Screening ────────────────────────────────
with gr.Tab("📈 Condition Screening"):
gr.Markdown(
"Select **one condition** to screen across its full physiological range. \n"
"The remaining conditions are held fixed at the values you specify below."
)
screen_radio = gr.Radio(
choices=["Temperature", "Concentration", "pH"],
value="Temperature",
label="Condition to Screen",
info="This condition will be swept across its full range; its slider value below is hidden.",
)
# Fixed-value sliders — JS hides the swept condition's column (no Gradio re-render)
with gr.Row():
with gr.Column(elem_id="s-temp-col"):
s_temp = gr.Slider(0, 60, value=25.0, step=0.5,
label="Temperature (°C) [fixed]")
with gr.Column(elem_id="s-conc-col"):
s_conc = gr.Slider(0, 1000, value=100.0, step=5.0,
label="Concentration (µM) [fixed]")
with gr.Column(elem_id="s-ph-col"):
s_pH = gr.Slider(0, 14, value=7.3, step=0.1,
label="pH [fixed]")
# Pure JS toggle — bypasses Gradio server update, so slider fills are preserved
screen_radio.change(
fn=None,
inputs=[screen_radio],
outputs=[],
js="""(screen_name) => {
const map = {Temperature: 's-temp-col', Concentration: 's-conc-col', pH: 's-ph-col'};
for (const [cond, id] of Object.entries(map)) {
const el = document.getElementById(id);
if (el) el.style.display = (cond === screen_name) ? 'none' : 'flex';
}
}""",
)
# Advanced conditions (always fixed during screening)
with gr.Accordion("⚙️ Advanced Conditions (Salts & Crowding Agents)", open=False):
gr.Markdown("Default values represent a common physiological buffer (160 mM NaCl).")
with gr.Row():
s_nacl = gr.Slider(0, 2000, value=160.0, step=10.0, label="NaCl (mM)")
s_mgcl2 = gr.Slider(0, 50, value=0.0, step=1.0, label="MgCl₂ (mM)")
s_kcl = gr.Slider(0, 1000, value=0.0, step=10.0, label="KCl (mM)")
s_glyc = gr.Slider(0, 10, value=0.0, step=0.5, label="Glycerol (%)")
with gr.Row():
s_peg1 = gr.Slider(0, 50, value=0, step=1, label="PEG 300–1000 (%)")
s_peg2 = gr.Slider(0, 50, value=0, step=1, label="PEG 3k–6k (%)")
s_peg3 = gr.Slider(0, 50, value=0, step=1, label="PEG 8k–20k (%)")
with gr.Row():
s_ficoll = gr.Slider(0, 50, value=0, step=1, label="Ficoll (%)")
s_dext40 = gr.Slider(0, 50, value=0, step=1, label="Dextran ≤40 kDa (%)")
s_dext70 = gr.Slider(0, 50, value=0, step=1, label="Dextran ≥70 kDa (%)")
s_smooth = gr.Slider(
1, 21, value=15, step=2,
label="Smoothing Window Size",
info="Odd window size for moving-average smoothing of the screening curve (1 = no smoothing).",
)
screen_btn = gr.Button("📊 Run Condition Screening", variant="primary")
screen_plot = gr.Plot(label="Screening Result")
screen_text = gr.Markdown("")
screen_btn.click(
fn=cb_screen,
inputs=[
feat_state, screen_radio,
s_temp, s_conc, s_pH,
s_nacl, s_mgcl2, s_kcl, s_glyc,
s_peg1, s_peg2, s_peg3, s_ficoll, s_dext40, s_dext70,
s_smooth,
],
outputs=[screen_plot, screen_text],
)
# Registered here (after Step 3 components exist) since cb_extract also
# clears the Predict/Screening panels so a re-extracted sequence can
# never leave a stale result from the previous sequence on screen.
extract_btn.click(
fn=cb_extract,
inputs=[seq_box],
outputs=[feat_state, extract_status, pred_plot, pred_text, screen_plot, screen_text],
)
# Selecting a built-in example runs extraction automatically (no manual
# Step-2 click needed) — cache-hit examples resolve near-instantly since
# cb_extract reads their pre-computed feature from assets/ instead of
# calling the T5 model. example_picker is None when no example has a
# cached feature yet (see AVAILABLE_EXAMPLES above).
if example_picker is not None:
example_picker.load_input_event.then(
fn=cb_extract,
inputs=[seq_box],
outputs=[feat_state, extract_status, pred_plot, pred_text, screen_plot, screen_text],
)
# ── Footer ────────────────────────────────────────────────────────────────
gr.Markdown("""
---
**Authors:** Jangwon Bae†, Minjun Kang†, Donghyuk Lee, Kuk-Jin Yoon*, Yongwon Jung*
**Paper:** [bioRxiv 2025.12.28.696755](https://doi.org/10.64898/2025.12.28.696755)
**GitHub:** [NearNiah/LLPSense](https://github.com/NearNiah/LLPSense)
""")
if __name__ == "__main__":
demo.launch(server_name="0.0.0.0", server_port=7860)