Spaces:
Running on Zero
Running on Zero
Condensate Titration: input-only panel, row/column comparison in the scan, ZeroGPU duration 120 s
Browse files- README.md +14 -14
- app.py +108 -72
- cell_fm/apps/condenseq/page.py +25 -89
- cell_fm/apps/condenseq/substitution.py +133 -12
README.md
CHANGED
|
@@ -59,18 +59,15 @@ re-dissolves at high concentration.
|
|
| 59 |
Both are normalised by the width of the log-intensity range, so both lie in [0, 1]
|
| 60 |
and are directly comparable between sequences.
|
| 61 |
|
| 62 |
-
|
| 63 |
-
|
| 64 |
|
| 65 |
### Layout
|
| 66 |
|
| 67 |
The tab is laid out like the Virtual OpenCell one, in the same OpenCell style:
|
| 68 |
|
| 69 |
-
- **
|
| 70 |
-
|
| 71 |
-
- **Left** — the sequence's name and composition (aromatic, basic and acidic residue
|
| 72 |
-
counts, net charge), the sequence box and **Run titration**, then the run's AUC, AAC,
|
| 73 |
-
c_sat and condensed-image count, and the titration curve.
|
| 74 |
- **Right** — the generated images in OpenCell's colours, nucleus blue and the IDP grey,
|
| 75 |
with the same channel switch and per-channel intensity range and gamma. The slider
|
| 76 |
steps through all 512 images along the ladder and the strip below holds 64 evenly
|
|
@@ -82,11 +79,11 @@ drawn afterwards on the CPU, so none of that spends the ZeroGPU budget.
|
|
| 82 |
|
| 83 |
### Default sequence
|
| 84 |
|
| 85 |
-
The box is pre-filled with the **NUP98 IDP**, wild type
|
| 86 |
-
91 random F→S mutants, ten draws for each of 1 to 9
|
| 87 |
-
replace all ten F — ships in
|
| 88 |
-
|
| 89 |
-
to sequence grammar, as in the paper's Fig. 5b.
|
| 90 |
|
| 91 |
### Amino acid substitution scan
|
| 92 |
|
|
@@ -159,8 +156,11 @@ The fixed settings — 512 images at 100 ODE steps — take **~85 s on an A40**
|
|
| 159 |
scales linearly in both, so a section that needs to be faster should lower them in
|
| 160 |
`pipeline.py` (`NUM_IMAGES`, `NUM_STEPS`).
|
| 161 |
|
| 162 |
-
On ZeroGPU the per-call budget is set by `ZEROGPU_DURATION` (default
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
---
|
| 166 |
|
|
|
|
| 59 |
Both are normalised by the width of the log-intensity range, so both lie in [0, 1]
|
| 60 |
and are directly comparable between sequences.
|
| 61 |
|
| 62 |
+
The curve also marks **c_sat**, the first intensity at which it reaches probability 0.8,
|
| 63 |
+
and the raw per-image calls download as a CSV.
|
| 64 |
|
| 65 |
### Layout
|
| 66 |
|
| 67 |
The tab is laid out like the Virtual OpenCell one, in the same OpenCell style:
|
| 68 |
|
| 69 |
+
- **Left** — the input alone, any 66-aa IDR sequence and **Run analysis**; then the
|
| 70 |
+
run's AUC and AAC, and the titration curve.
|
|
|
|
|
|
|
|
|
|
| 71 |
- **Right** — the generated images in OpenCell's colours, nucleus blue and the IDP grey,
|
| 72 |
with the same channel switch and per-channel intensity range and gamma. The slider
|
| 73 |
steps through all 512 images along the ladder and the strip below holds 64 evenly
|
|
|
|
| 79 |
|
| 80 |
### Default sequence
|
| 81 |
|
| 82 |
+
The box is pre-filled with the **NUP98 IDP**, wild type, unnamed in the UI. Its F→S
|
| 83 |
+
panel — the wild type and 91 random F→S mutants, ten draws for each of 1 to 9
|
| 84 |
+
substitutions and the one way to replace all ten F — ships in
|
| 85 |
+
`assets/nup98_mutation_seqs.csv`; pasting a mutant in and comparing its AUC against WT is
|
| 86 |
+
the quickest way to see the model respond to sequence grammar, as in the paper's Fig. 5b.
|
| 87 |
|
| 88 |
### Amino acid substitution scan
|
| 89 |
|
|
|
|
| 156 |
scales linearly in both, so a section that needs to be faster should lower them in
|
| 157 |
`pipeline.py` (`NUM_IMAGES`, `NUM_STEPS`).
|
| 158 |
|
| 159 |
+
On ZeroGPU the per-call budget is set by `ZEROGPU_DURATION` (default 120 s). Keep it
|
| 160 |
+
close to what a run needs: `spaces` requests 1.5× it on the default GPU size, and ZeroGPU
|
| 161 |
+
refuses any request above the visitor's allowance before running anything — at 300 s
|
| 162 |
+
(450 s requested) every visitor without PRO was turned away with "The requested GPU
|
| 163 |
+
duration (450s) is larger than the maximum allowed".
|
| 164 |
|
| 165 |
---
|
| 166 |
|
app.py
CHANGED
|
@@ -15,6 +15,7 @@ import gradio as gr
|
|
| 15 |
import matplotlib
|
| 16 |
matplotlib.use("Agg")
|
| 17 |
import numpy as np
|
|
|
|
| 18 |
|
| 19 |
from cell_fm.apps.condenseq import page, pipeline, substitution
|
| 20 |
from cell_fm.apps.virtual_opencell import viewer
|
|
@@ -45,16 +46,24 @@ except Exception as exc: # never let the shim take the app down
|
|
| 45 |
try:
|
| 46 |
import spaces
|
| 47 |
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
| 49 |
except Exception: # running locally or on a dedicated GPU
|
| 50 |
def GPU_DECORATOR(fn):
|
| 51 |
return fn
|
| 52 |
|
| 53 |
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 54 |
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
# The proteins with virtual staining, and OpenCell's annotations of their real cell lines
|
| 60 |
PROTEINS = viewer.load_catalog(os.path.join(HERE, "assets", "virtual_opencell_proteins.csv"))
|
|
@@ -62,11 +71,6 @@ PROTEINS = viewer.load_catalog(os.path.join(HERE, "assets", "virtual_opencell_pr
|
|
| 62 |
LADDER = (pipeline.MIN_INTENSITY, pipeline.MAX_INTENSITY)
|
| 63 |
|
| 64 |
|
| 65 |
-
def sequence_name(sequence: str) -> str:
|
| 66 |
-
"""A sequence's name in the NUP98 panel, or page.CUSTOM."""
|
| 67 |
-
return PRESET_NAMES.get(page.residues(sequence), page.CUSTOM)
|
| 68 |
-
|
| 69 |
-
|
| 70 |
def reference_nucleus():
|
| 71 |
"""The nucleus every image is generated in, (H, W) in [0, 1]; None if it cannot be fetched."""
|
| 72 |
try:
|
|
@@ -103,9 +107,9 @@ def analyse(sequence, progress=gr.Progress()):
|
|
| 103 |
|
| 104 |
|
| 105 |
def build_condensate_titration_section():
|
| 106 |
-
"""Laid out like the Virtual OpenCell tab: a navbar
|
| 107 |
-
|
| 108 |
-
|
| 109 |
nucleus = reference_nucleus()
|
| 110 |
|
| 111 |
def show(result, position, channel, n_min, n_max, n_gamma, t_min, t_max, t_gamma):
|
|
@@ -122,7 +126,7 @@ def build_condensate_titration_section():
|
|
| 122 |
page.results_table(result).to_csv(path, index=False)
|
| 123 |
strip = page.thumbnail_indices(len(result.images))
|
| 124 |
return (
|
| 125 |
-
page.metrics_html(result
|
| 126 |
page.curve_figure(result, LADDER),
|
| 127 |
image,
|
| 128 |
caption,
|
|
@@ -132,19 +136,6 @@ def build_condensate_titration_section():
|
|
| 132 |
gr.DownloadButton(value=path, interactive=True),
|
| 133 |
)
|
| 134 |
|
| 135 |
-
def info(name, sequence):
|
| 136 |
-
return page.info_html(name, sequence, pipeline.NUM_IMAGES, pipeline.DEFAULT_SEED)
|
| 137 |
-
|
| 138 |
-
def pick_preset(name):
|
| 139 |
-
if name not in PRESETS.index:
|
| 140 |
-
return gr.skip(), gr.skip()
|
| 141 |
-
return PRESETS[name], info(name, PRESETS[name])
|
| 142 |
-
|
| 143 |
-
def edit_sequence(text):
|
| 144 |
-
"""Name what is typed; the picker follows when it matches a panel sequence and clears when not."""
|
| 145 |
-
name = sequence_name(text)
|
| 146 |
-
return gr.Dropdown(value=name if name in PRESETS.index else None), info(name, text)
|
| 147 |
-
|
| 148 |
def pick_sample(result, evt: gr.SelectData):
|
| 149 |
if result is None:
|
| 150 |
return gr.skip()
|
|
@@ -156,21 +147,17 @@ def build_condensate_titration_section():
|
|
| 156 |
with gr.Column(elem_id="condensate-titration"):
|
| 157 |
with gr.Row(elem_classes="vo-navbar", equal_height=True):
|
| 158 |
gr.HTML(page.NAVBAR_HTML)
|
| 159 |
-
preset = gr.Dropdown(
|
| 160 |
-
choices=list(PRESETS.index), value=page.DEFAULT_PRESET, filterable=True,
|
| 161 |
-
show_label=False, container=False, scale=0, min_width=260, elem_classes="vo-search",
|
| 162 |
-
)
|
| 163 |
|
| 164 |
with gr.Row():
|
| 165 |
with gr.Column(scale=4):
|
| 166 |
-
|
| 167 |
sequence = gr.Textbox(
|
| 168 |
-
value=
|
| 169 |
-
|
| 170 |
-
|
| 171 |
elem_classes="cs-sequence",
|
| 172 |
)
|
| 173 |
-
run_button = gr.Button("Run
|
| 174 |
metrics_panel = gr.HTML(page.metrics_html())
|
| 175 |
gr.HTML(page.CURVE_HTML, padding=False)
|
| 176 |
curve_plot = gr.Plot(value=page.curve_figure(None, LADDER), show_label=False)
|
|
@@ -221,11 +208,6 @@ def build_condensate_titration_section():
|
|
| 221 |
result_state = gr.State(None)
|
| 222 |
settings = [channel, n_min, n_max, n_gamma, t_min, t_max, t_gamma]
|
| 223 |
|
| 224 |
-
# .input, not .change: only a visitor's own edits, so neither handler retriggers the other
|
| 225 |
-
preset.input(pick_preset, inputs=preset, outputs=[sequence, info_panel], show_progress="hidden")
|
| 226 |
-
sequence.input(edit_sequence, inputs=sequence, outputs=[preset, info_panel],
|
| 227 |
-
show_progress="hidden", trigger_mode="always_last")
|
| 228 |
-
|
| 229 |
run_button.click(analyse, inputs=sequence, outputs=[result_state, curve_plot, image]).success(
|
| 230 |
show_result, inputs=[result_state] + settings,
|
| 231 |
outputs=[metrics_panel, curve_plot, image, caption, position, samples_box, samples, download],
|
|
@@ -239,39 +221,73 @@ def build_condensate_titration_section():
|
|
| 239 |
|
| 240 |
|
| 241 |
def build_substitution_scan_section():
|
| 242 |
-
"""The paper's substitution matrices, mean change in AUC and AAC side by side
|
| 243 |
-
|
|
|
|
| 244 |
try:
|
| 245 |
scan = substitution.load_scan()
|
| 246 |
except Exception as exc: # the scan is an extra; the titration above works without it
|
| 247 |
print(f"[startup] substitution scan unavailable: {exc}")
|
| 248 |
return
|
| 249 |
|
| 250 |
-
def
|
| 251 |
-
|
| 252 |
-
return
|
| 253 |
-
substitution.idp_table(scan, i, j))
|
| 254 |
-
|
| 255 |
-
# where each matrix sits in its image; the layout does not depend on the selection
|
| 256 |
-
extents = {metric: substitution.heatmap(scan, metric)[1] for metric in substitution.METRICS}
|
| 257 |
-
|
| 258 |
-
def select(metric, evt):
|
| 259 |
-
cell = substitution.cell_at(extents[metric], *evt.index)
|
| 260 |
-
if cell is None: # the colour bar, a label, the margin
|
| 261 |
-
return [gr.skip()] * 5
|
| 262 |
-
if cell[0] == cell[1]:
|
| 263 |
-
gr.Info("The diagonal is a residue replaced by itself — pick a cell off it.")
|
| 264 |
-
return [gr.skip()] * 5
|
| 265 |
-
return view(*cell)
|
| 266 |
|
| 267 |
-
def
|
| 268 |
-
return
|
| 269 |
-
|
| 270 |
-
def select_aac(evt: gr.SelectData):
|
| 271 |
-
return select("aac", evt)
|
| 272 |
|
| 273 |
-
|
| 274 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
|
| 276 |
gr.HTML(page.scan_header_html(scan), padding=False)
|
| 277 |
|
|
@@ -293,13 +309,33 @@ def build_substitution_scan_section():
|
|
| 293 |
with gr.Column(scale=7):
|
| 294 |
distribution = gr.Plot(value=distribution0, label="Per-IDP changes behind the selected cell",
|
| 295 |
show_label=False)
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
|
| 304 |
|
| 305 |
def build_virtual_opencell_section():
|
|
|
|
| 15 |
import matplotlib
|
| 16 |
matplotlib.use("Agg")
|
| 17 |
import numpy as np
|
| 18 |
+
import pandas as pd
|
| 19 |
|
| 20 |
from cell_fm.apps.condenseq import page, pipeline, substitution
|
| 21 |
from cell_fm.apps.virtual_opencell import viewer
|
|
|
|
| 46 |
try:
|
| 47 |
import spaces
|
| 48 |
|
| 49 |
+
# What a run needs, not a safe-looking maximum: spaces requests 1.5x this (the 'large' GPU
|
| 50 |
+
# size), and ZeroGPU refuses outright any request above the visitor's allowance -- at 300 s
|
| 51 |
+
# (450 s requested) every visitor without PRO got "larger than the maximum allowed".
|
| 52 |
+
GPU_DECORATOR = spaces.GPU(duration=int(os.environ.get("ZEROGPU_DURATION", "120")))
|
| 53 |
except Exception: # running locally or on a dedicated GPU
|
| 54 |
def GPU_DECORATOR(fn):
|
| 55 |
return fn
|
| 56 |
|
| 57 |
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 58 |
|
| 59 |
+
|
| 60 |
+
def load_default_sequence() -> str:
|
| 61 |
+
"""The NUP98 IDP, wild type -- what the box is pre-filled with."""
|
| 62 |
+
df = pd.read_csv(os.path.join(HERE, "assets", "nup98_mutation_seqs.csv"), dtype=str)
|
| 63 |
+
return df.loc[df.mutation == "WT", "sequence"].iloc[0]
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
DEFAULT_SEQUENCE = load_default_sequence()
|
| 67 |
|
| 68 |
# The proteins with virtual staining, and OpenCell's annotations of their real cell lines
|
| 69 |
PROTEINS = viewer.load_catalog(os.path.join(HERE, "assets", "virtual_opencell_proteins.csv"))
|
|
|
|
| 71 |
LADDER = (pipeline.MIN_INTENSITY, pipeline.MAX_INTENSITY)
|
| 72 |
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
def reference_nucleus():
|
| 75 |
"""The nucleus every image is generated in, (H, W) in [0, 1]; None if it cannot be fetched."""
|
| 76 |
try:
|
|
|
|
| 107 |
|
| 108 |
|
| 109 |
def build_condensate_titration_section():
|
| 110 |
+
"""Laid out like the Virtual OpenCell tab: a navbar; the sequence box, its numbers and its
|
| 111 |
+
titration curve on the left; the generated images on the right; then the paper's substitution
|
| 112 |
+
scan, how a titration is computed, and a footer."""
|
| 113 |
nucleus = reference_nucleus()
|
| 114 |
|
| 115 |
def show(result, position, channel, n_min, n_max, n_gamma, t_min, t_max, t_gamma):
|
|
|
|
| 126 |
page.results_table(result).to_csv(path, index=False)
|
| 127 |
strip = page.thumbnail_indices(len(result.images))
|
| 128 |
return (
|
| 129 |
+
page.metrics_html(result),
|
| 130 |
page.curve_figure(result, LADDER),
|
| 131 |
image,
|
| 132 |
caption,
|
|
|
|
| 136 |
gr.DownloadButton(value=path, interactive=True),
|
| 137 |
)
|
| 138 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
def pick_sample(result, evt: gr.SelectData):
|
| 140 |
if result is None:
|
| 141 |
return gr.skip()
|
|
|
|
| 147 |
with gr.Column(elem_id="condensate-titration"):
|
| 148 |
with gr.Row(elem_classes="vo-navbar", equal_height=True):
|
| 149 |
gr.HTML(page.NAVBAR_HTML)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
with gr.Row():
|
| 152 |
with gr.Column(scale=4):
|
| 153 |
+
# just the input: any IDR of the library's length, checked when the analysis runs
|
| 154 |
sequence = gr.Textbox(
|
| 155 |
+
value=DEFAULT_SEQUENCE, lines=3, max_lines=6, show_label=False,
|
| 156 |
+
label=f"IDR sequence ({pipeline.SEQUENCE_LENGTH} aa)",
|
| 157 |
+
placeholder=f"Any {pipeline.SEQUENCE_LENGTH}-aa IDR sequence",
|
| 158 |
elem_classes="cs-sequence",
|
| 159 |
)
|
| 160 |
+
run_button = gr.Button("Run analysis", elem_classes="cs-run")
|
| 161 |
metrics_panel = gr.HTML(page.metrics_html())
|
| 162 |
gr.HTML(page.CURVE_HTML, padding=False)
|
| 163 |
curve_plot = gr.Plot(value=page.curve_figure(None, LADDER), show_label=False)
|
|
|
|
| 208 |
result_state = gr.State(None)
|
| 209 |
settings = [channel, n_min, n_max, n_gamma, t_min, t_max, t_gamma]
|
| 210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
run_button.click(analyse, inputs=sequence, outputs=[result_state, curve_plot, image]).success(
|
| 212 |
show_result, inputs=[result_state] + settings,
|
| 213 |
outputs=[metrics_panel, curve_plot, image, caption, position, samples_box, samples, download],
|
|
|
|
| 221 |
|
| 222 |
|
| 223 |
def build_substitution_scan_section():
|
| 224 |
+
"""The paper's substitution matrices, mean change in AUC and AAC side by side. A click on a
|
| 225 |
+
FROM -> TO cell shows the per-IDP changes that cell averages; a click on a residue on either
|
| 226 |
+
axis adds its row (Mutate FROM) or column (Mutate TO) to a comparison. Precomputed, CPU only."""
|
| 227 |
try:
|
| 228 |
scan = substitution.load_scan()
|
| 229 |
except Exception as exc: # the scan is an extra; the titration above works without it
|
| 230 |
print(f"[startup] substitution scan unavailable: {exc}")
|
| 231 |
return
|
| 232 |
|
| 233 |
+
def maps(cell, axis, colours):
|
| 234 |
+
compared = (axis, [(scan.index(r), c) for r, c in colours.items()])
|
| 235 |
+
return [substitution.heatmap(scan, metric, cell, compared)[0] for metric in substitution.METRICS]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
|
| 237 |
+
def cell_view(i, j):
|
| 238 |
+
return (page.substitution_html(scan, i, j), substitution.distribution(scan, i, j),
|
| 239 |
+
substitution.idp_table(scan, i, j))
|
|
|
|
|
|
|
| 240 |
|
| 241 |
+
def compare_view(axis, colours):
|
| 242 |
+
return substitution.comparison(scan, axis, colours), substitution.comparison_table(scan, axis, colours)
|
| 243 |
+
|
| 244 |
+
# where each matrix and its axis labels sit in its image; the same in every render
|
| 245 |
+
layouts = {metric: substitution.heatmap(scan, metric)[1] for metric in substitution.METRICS}
|
| 246 |
+
skip5 = [gr.skip()] * 5
|
| 247 |
+
|
| 248 |
+
def select(metric, cell, axis, colours, evt):
|
| 249 |
+
target = substitution.hit(layouts[metric], *evt.index)
|
| 250 |
+
if target is None: # the colour bar, a title, the margin
|
| 251 |
+
return [gr.skip()] * 11
|
| 252 |
+
if target[0] == "cell":
|
| 253 |
+
i, j = target[1:]
|
| 254 |
+
if i == j:
|
| 255 |
+
gr.Info("The diagonal is a residue replaced by itself — pick a cell off it.")
|
| 256 |
+
return [gr.skip()] * 11
|
| 257 |
+
return (*maps((i, j), axis, colours), *cell_view(i, j), (i, j), *skip5)
|
| 258 |
+
|
| 259 |
+
# a residue label: add it to the comparison, or take it out; the other axis starts afresh
|
| 260 |
+
clicked = "row" if target[0] == "row" else "col"
|
| 261 |
+
residue = scan.amino_acids[target[1]]
|
| 262 |
+
residues = list(colours) if clicked == axis else []
|
| 263 |
+
if residue in residues:
|
| 264 |
+
residues.remove(residue)
|
| 265 |
+
elif len(residues) < len(substitution.SERIES):
|
| 266 |
+
residues.append(residue)
|
| 267 |
+
else:
|
| 268 |
+
gr.Info(f"Compare up to {len(substitution.SERIES)} at once — click one to take it out first.")
|
| 269 |
+
return [gr.skip()] * 11
|
| 270 |
+
colours = substitution.assign_colours(residues, colours if clicked == axis else {})
|
| 271 |
+
return (*maps(cell, clicked, colours), *[gr.skip()] * 4, clicked, list(colours),
|
| 272 |
+
*compare_view(clicked, colours), colours)
|
| 273 |
+
|
| 274 |
+
def select_auc(cell, axis, colours, evt: gr.SelectData):
|
| 275 |
+
return select("auc", cell, axis, colours, evt)
|
| 276 |
+
|
| 277 |
+
def select_aac(cell, axis, colours, evt: gr.SelectData):
|
| 278 |
+
return select("aac", cell, axis, colours, evt)
|
| 279 |
+
|
| 280 |
+
def compare(cell, axis, residues, colours):
|
| 281 |
+
"""The switch or the picker changed: same residues keep their colours."""
|
| 282 |
+
colours = substitution.assign_colours(residues or [], colours)
|
| 283 |
+
return (*maps(cell, axis, colours), *compare_view(axis, colours), colours)
|
| 284 |
+
|
| 285 |
+
cell0 = tuple(scan.index(residue) for residue in substitution.DEFAULT_SELECTION)
|
| 286 |
+
axis0, residues0 = substitution.DEFAULT_COMPARISON
|
| 287 |
+
colours0 = substitution.assign_colours(residues0, {})
|
| 288 |
+
auc0, aac0 = maps(cell0, axis0, colours0)
|
| 289 |
+
selection0, distribution0, table0 = cell_view(*cell0)
|
| 290 |
+
comparison0, values0 = compare_view(axis0, colours0)
|
| 291 |
|
| 292 |
gr.HTML(page.scan_header_html(scan), padding=False)
|
| 293 |
|
|
|
|
| 309 |
with gr.Column(scale=7):
|
| 310 |
distribution = gr.Plot(value=distribution0, label="Per-IDP changes behind the selected cell",
|
| 311 |
show_label=False)
|
| 312 |
+
with gr.Accordion("Per-IDP values", open=False):
|
| 313 |
+
gr.HTML(page.per_idp_html(substitution.MODEL_REPO, substitution.SCAN_FILE), padding=False)
|
| 314 |
+
idp_table = gr.Dataframe(value=table0, wrap=True, max_height=380, show_label=False)
|
| 315 |
+
|
| 316 |
+
gr.HTML(page.COMPARE_HTML, padding=False)
|
| 317 |
+
with gr.Row(equal_height=True):
|
| 318 |
+
compare_axis = gr.Radio([("Rows (Mutate FROM)", "row"), ("Columns (Mutate TO)", "col")],
|
| 319 |
+
value=axis0, label="Compare", scale=0, min_width=440, elem_classes="vo-buttons")
|
| 320 |
+
compare_residues = gr.Dropdown(list(scan.amino_acids), value=list(colours0), multiselect=True,
|
| 321 |
+
max_choices=len(substitution.SERIES), label="Residues", scale=1)
|
| 322 |
+
comparison = gr.Plot(value=comparison0, show_label=False, elem_id="cs-comparison")
|
| 323 |
+
with gr.Accordion("Compared values", open=False):
|
| 324 |
+
comparison_values = gr.Dataframe(value=values0, wrap=True, max_height=380, show_label=False)
|
| 325 |
+
|
| 326 |
+
cell_state = gr.State(cell0)
|
| 327 |
+
colours_state = gr.State(colours0)
|
| 328 |
+
|
| 329 |
+
outputs = [auc_map, aac_map, selection, distribution, idp_table, cell_state,
|
| 330 |
+
compare_axis, compare_residues, comparison, comparison_values, colours_state]
|
| 331 |
+
inputs = [cell_state, compare_axis, colours_state]
|
| 332 |
+
auc_map.select(select_auc, inputs=inputs, outputs=outputs, show_progress="hidden")
|
| 333 |
+
aac_map.select(select_aac, inputs=inputs, outputs=outputs, show_progress="hidden")
|
| 334 |
+
# .input: only a visitor's own changes; a label click sets both itself without re-running this
|
| 335 |
+
for control in (compare_axis, compare_residues):
|
| 336 |
+
control.input(compare, inputs=[cell_state, compare_axis, compare_residues, colours_state],
|
| 337 |
+
outputs=[auc_map, aac_map, comparison, comparison_values, colours_state],
|
| 338 |
+
show_progress="hidden", trigger_mode="always_last")
|
| 339 |
|
| 340 |
|
| 341 |
def build_virtual_opencell_section():
|
cell_fm/apps/condenseq/page.py
CHANGED
|
@@ -1,17 +1,14 @@
|
|
| 1 |
"""The Condensate Titration tab's panels, captions and figures, in the Virtual OpenCell tab's look.
|
| 2 |
|
| 3 |
The two tabs read as one site: the same flat OpenCell styling (viewer.scoped_css), the same layout
|
| 4 |
-
-- a navbar
|
| 5 |
-
|
| 6 |
-
|
| 7 |
alike.
|
| 8 |
|
| 9 |
Nothing here touches the models: the app hands in a pipeline.Result and the reference nucleus.
|
| 10 |
"""
|
| 11 |
|
| 12 |
-
import html
|
| 13 |
-
import re
|
| 14 |
-
|
| 15 |
import numpy as np
|
| 16 |
import pandas as pd
|
| 17 |
from matplotlib.figure import Figure
|
|
@@ -33,99 +30,26 @@ NUM_THUMBNAILS = 64 # the sample strip: evenly spaced along the ladder; the sl
|
|
| 33 |
# OpenCell's channel switch. viewer.render calls the protein channel the "target"; here it is the IDP
|
| 34 |
CHANNELS = [("Nucleus", "Nucleus"), ("IDP", "Target"), ("Both", "Both")]
|
| 35 |
|
| 36 |
-
DEFAULT_PRESET = "NUP98 WT"
|
| 37 |
-
CUSTOM = "Custom sequence"
|
| 38 |
-
|
| 39 |
-
|
| 40 |
def _item(label: str, value: str) -> str:
|
| 41 |
return (f'<div class="vo-metadata-item"><div class="vo-metadata-label">{label}</div>'
|
| 42 |
f'<div class="vo-metadata-value">{value}</div></div>')
|
| 43 |
|
| 44 |
|
| 45 |
-
# ---------------------------------------------------------------------------
|
| 46 |
-
# The sequence picker
|
| 47 |
-
# ---------------------------------------------------------------------------
|
| 48 |
-
|
| 49 |
-
def load_presets(path: str) -> pd.Series:
|
| 50 |
-
"""The NUP98 IDP panel shipped with the app, display name -> sequence, wild type first.
|
| 51 |
-
|
| 52 |
-
The wild type and random F -> S mutants of it: ten draws for each of 1 to 9 substitutions and
|
| 53 |
-
the one way to make all ten, the virtual mutagenesis of the paper's Fig. 5b.
|
| 54 |
-
"""
|
| 55 |
-
table = pd.read_csv(path, dtype=str)
|
| 56 |
-
return pd.Series(table["sequence"].to_numpy(), index=table["mutation"].map(_preset_name).to_numpy())
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
def _preset_name(mutation: str) -> str:
|
| 60 |
-
if mutation == "WT":
|
| 61 |
-
return DEFAULT_PRESET
|
| 62 |
-
count, draw = re.fullmatch(r"(\d+)FtoS_(\d+)", mutation).groups()
|
| 63 |
-
return f"NUP98 {count}F→S #{draw}"
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
def residues(text: str) -> str:
|
| 67 |
-
"""The sequence a box holds, the way pipeline.clean_sequence reads it, without its checks."""
|
| 68 |
-
lines = [ln for ln in str(text).splitlines() if not ln.startswith(">")]
|
| 69 |
-
return "".join("".join(lines).split()).upper()
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
def describe(name: str) -> str:
|
| 73 |
-
if name == DEFAULT_PRESET:
|
| 74 |
-
return "The NUP98 FG-repeat IDP, wild type"
|
| 75 |
-
match = re.fullmatch(r"NUP98 (\d+)F→S #(\d+)", name)
|
| 76 |
-
if match:
|
| 77 |
-
count, draw = match.groups()
|
| 78 |
-
return f"NUP98 IDP with {count} of its 10 phenylalanines mutated to serine, draw {draw}"
|
| 79 |
-
return "A sequence entered by hand"
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
def info_html(name: str, sequence: str, num_images: int, seed: int) -> str:
|
| 83 |
-
"""The left-hand panel, OpenCell's target header: which sequence this is and what it is made of.
|
| 84 |
-
|
| 85 |
-
The composition counts are the residues the substitution scan below finds matter most:
|
| 86 |
-
aromatics and arginine drive condensation, acidic residues oppose it.
|
| 87 |
-
"""
|
| 88 |
-
seq = residues(sequence)
|
| 89 |
-
aromatic, basic, acidic = (sum(seq.count(a) for a in letters) for letters in ("FYW", "RK", "DE"))
|
| 90 |
-
items = [
|
| 91 |
-
_item("Length", f"{len(seq)} aa"),
|
| 92 |
-
_item("Aromatic (F Y W)", str(aromatic)),
|
| 93 |
-
_item("Basic (R K)", str(basic)),
|
| 94 |
-
_item("Acidic (D E)", str(acidic)),
|
| 95 |
-
_item("Net charge", f"{basic - acidic:+d}"),
|
| 96 |
-
]
|
| 97 |
-
return f"""
|
| 98 |
-
<div class="vo-target">
|
| 99 |
-
<div class="vo-target-name">{html.escape(name)}</div>
|
| 100 |
-
<div class="vo-protein-description">{html.escape(describe(name))}</div>
|
| 101 |
-
<div class="vo-status">CELL-FM condensate titration · {num_images} images · seed {seed}</div>
|
| 102 |
-
<div class="vo-metadata">{''.join(items)}</div>
|
| 103 |
-
</div>
|
| 104 |
-
"""
|
| 105 |
-
|
| 106 |
-
|
| 107 |
# ---------------------------------------------------------------------------
|
| 108 |
# A run's numbers and curve
|
| 109 |
# ---------------------------------------------------------------------------
|
| 110 |
|
| 111 |
-
def metrics_html(result=None
|
| 112 |
-
"""AUC
|
| 113 |
-
if result is None:
|
| 114 |
-
|
| 115 |
-
measured = "Run the titration to measure them."
|
| 116 |
-
else:
|
| 117 |
-
c_sat = "not reached" if result.c_sat == float("inf") else f"{result.c_sat:.0f} a.u."
|
| 118 |
-
values = [f"{result.auc:.4f}", f"{result.aac:.4f}", c_sat,
|
| 119 |
-
f"{int(result.predictions.sum())} / {len(result.predictions)}"]
|
| 120 |
-
measured = f"Measured for {html.escape(name)}."
|
| 121 |
-
labels = ["AUC", "AAC", "c<sub>sat</sub>", "Condensed images"]
|
| 122 |
return f"""
|
| 123 |
<div class="vo-target cs-metrics">
|
| 124 |
<div class="vo-section-header">Titration</div>
|
| 125 |
-
<div class="vo-section-caption">{
|
| 126 |
condensed; AAC how much of that it loses to re-dissolving at high expression. Both are normalised
|
| 127 |
-
to [0, 1].
|
| 128 |
-
<div class="vo-metadata">{''.join(_item(l, v) for l, v in zip(
|
| 129 |
</div>
|
| 130 |
"""
|
| 131 |
|
|
@@ -163,7 +87,7 @@ def curve_figure(result, ladder) -> Figure:
|
|
| 163 |
_style(ax)
|
| 164 |
|
| 165 |
if result is None:
|
| 166 |
-
ax.text(0.5, 0.5, "Run the
|
| 167 |
ha="center", va="center", fontsize=11, color=MUTED)
|
| 168 |
else:
|
| 169 |
x, y, curve = result.intensities, result.predictions, result.curve
|
|
@@ -229,7 +153,7 @@ def view(result, nucleus, index: int, channel: str = "Both",
|
|
| 229 |
def caption_html(result=None, index: int = 0) -> str:
|
| 230 |
if result is None:
|
| 231 |
return ('<div class="cs-viewer-caption">The reference nucleus every image is generated in. '
|
| 232 |
-
'Run the
|
| 233 |
index = int(np.clip(index, 0, len(result.images) - 1))
|
| 234 |
call = "condensed" if result.predictions[index] else "diffuse"
|
| 235 |
return (f'<div class="cs-viewer-caption">Image {index + 1} of {len(result.images)} · '
|
|
@@ -259,7 +183,8 @@ def scan_header_html(scan) -> str:
|
|
| 259 |
<div class="vo-section-caption">Every residue type in {len(scan.idr_index)} held-out IDPs replaced by
|
| 260 |
each other type, each mutant titrated as above, and its AUC and AAC compared with the wild type's.
|
| 261 |
A cell is the mean change over the IDPs that contain the FROM residue: left the paper's Fig. 5d
|
| 262 |
-
(AUC), right its Supplementary Fig. 5c (AAC). Click any cell for the distribution behind it
|
|
|
|
| 263 |
</div>
|
| 264 |
"""
|
| 265 |
|
|
@@ -285,6 +210,17 @@ def substitution_html(scan, i: int, j: int) -> str:
|
|
| 285 |
"""
|
| 286 |
|
| 287 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
def per_idp_html(repo: str, filename: str) -> str:
|
| 289 |
return f"""
|
| 290 |
<div class="vo-target">
|
|
|
|
| 1 |
"""The Condensate Titration tab's panels, captions and figures, in the Virtual OpenCell tab's look.
|
| 2 |
|
| 3 |
The two tabs read as one site: the same flat OpenCell styling (viewer.scoped_css), the same layout
|
| 4 |
+
-- a navbar, the input and the plot on the left, a black image viewer with a sample strip and
|
| 5 |
+
contrast settings on the right -- and the same channel colours, the nucleus blue and the protein
|
| 6 |
+
grey. Generated images go through viewer.render, so both viewers draw
|
| 7 |
alike.
|
| 8 |
|
| 9 |
Nothing here touches the models: the app hands in a pipeline.Result and the reference nucleus.
|
| 10 |
"""
|
| 11 |
|
|
|
|
|
|
|
|
|
|
| 12 |
import numpy as np
|
| 13 |
import pandas as pd
|
| 14 |
from matplotlib.figure import Figure
|
|
|
|
| 30 |
# OpenCell's channel switch. viewer.render calls the protein channel the "target"; here it is the IDP
|
| 31 |
CHANNELS = [("Nucleus", "Nucleus"), ("IDP", "Target"), ("Both", "Both")]
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
def _item(label: str, value: str) -> str:
|
| 34 |
return (f'<div class="vo-metadata-item"><div class="vo-metadata-label">{label}</div>'
|
| 35 |
f'<div class="vo-metadata-value">{value}</div></div>')
|
| 36 |
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
# ---------------------------------------------------------------------------
|
| 39 |
# A run's numbers and curve
|
| 40 |
# ---------------------------------------------------------------------------
|
| 41 |
|
| 42 |
+
def metrics_html(result=None) -> str:
|
| 43 |
+
"""AUC and AAC as OpenCell metadata; dashes before any run."""
|
| 44 |
+
values = ["—", "—"] if result is None else [f"{result.auc:.4f}", f"{result.aac:.4f}"]
|
| 45 |
+
before = "Run the analysis to measure them. " if result is None else ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
return f"""
|
| 47 |
<div class="vo-target cs-metrics">
|
| 48 |
<div class="vo-section-header">Titration</div>
|
| 49 |
+
<div class="vo-section-caption">{before}AUC is how much of the scanned range the IDP spends
|
| 50 |
condensed; AAC how much of that it loses to re-dissolving at high expression. Both are normalised
|
| 51 |
+
to [0, 1].</div>
|
| 52 |
+
<div class="vo-metadata">{''.join(_item(l, v) for l, v in zip(["AUC", "AAC"], values))}</div>
|
| 53 |
</div>
|
| 54 |
"""
|
| 55 |
|
|
|
|
| 87 |
_style(ax)
|
| 88 |
|
| 89 |
if result is None:
|
| 90 |
+
ax.text(0.5, 0.5, "Run the analysis to draw the curve", transform=ax.transAxes,
|
| 91 |
ha="center", va="center", fontsize=11, color=MUTED)
|
| 92 |
else:
|
| 93 |
x, y, curve = result.intensities, result.predictions, result.curve
|
|
|
|
| 153 |
def caption_html(result=None, index: int = 0) -> str:
|
| 154 |
if result is None:
|
| 155 |
return ('<div class="cs-viewer-caption">The reference nucleus every image is generated in. '
|
| 156 |
+
'Run the analysis to add the IDP.</div>')
|
| 157 |
index = int(np.clip(index, 0, len(result.images) - 1))
|
| 158 |
call = "condensed" if result.predictions[index] else "diffuse"
|
| 159 |
return (f'<div class="cs-viewer-caption">Image {index + 1} of {len(result.images)} · '
|
|
|
|
| 183 |
<div class="vo-section-caption">Every residue type in {len(scan.idr_index)} held-out IDPs replaced by
|
| 184 |
each other type, each mutant titrated as above, and its AUC and AAC compared with the wild type's.
|
| 185 |
A cell is the mean change over the IDPs that contain the FROM residue: left the paper's Fig. 5d
|
| 186 |
+
(AUC), right its Supplementary Fig. 5c (AAC). Click any cell for the distribution behind it, or a
|
| 187 |
+
residue on either axis to compare whole rows (Mutate FROM) or columns (Mutate TO) below.</div>
|
| 188 |
</div>
|
| 189 |
"""
|
| 190 |
|
|
|
|
| 210 |
"""
|
| 211 |
|
| 212 |
|
| 213 |
+
COMPARE_HTML = """
|
| 214 |
+
<div class="vo-target">
|
| 215 |
+
<div class="vo-section-header">Compare rows or columns</div>
|
| 216 |
+
<div class="vo-section-caption">A row is everything one residue becomes (Mutate FROM), a column
|
| 217 |
+
everything that becomes one residue (Mutate TO). Click residues on a matrix's axis to add or remove
|
| 218 |
+
them, up to eight; clicking on the other axis starts a comparison there. Each point is a matrix
|
| 219 |
+
cell, with its 95% confidence interval over the IDPs; hover for the numbers.</div>
|
| 220 |
+
</div>
|
| 221 |
+
"""
|
| 222 |
+
|
| 223 |
+
|
| 224 |
def per_idp_html(repo: str, filename: str) -> str:
|
| 225 |
return f"""
|
| 226 |
<div class="vo-target">
|
cell_fm/apps/condenseq/substitution.py
CHANGED
|
@@ -42,6 +42,13 @@ SURFACE = "#fff" # white, like the tab's other plots and the Virtual OpenCell m
|
|
| 42 |
|
| 43 |
# Shown before any click: F -> S, the substitution the paper's NUP98 validation makes one F at a time
|
| 44 |
DEFAULT_SELECTION = ("F", "S")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
|
| 47 |
def mean_matrix(delta: np.ndarray) -> np.ndarray:
|
|
@@ -89,11 +96,13 @@ def load_scan() -> Scan:
|
|
| 89 |
)
|
| 90 |
|
| 91 |
|
| 92 |
-
def heatmap(scan: Scan, metric: str, selected=None):
|
| 93 |
-
"""(image,
|
| 94 |
-
|
| 95 |
|
| 96 |
-
selected, a (from, to) index pair, is outlined.
|
|
|
|
|
|
|
| 97 |
"""
|
| 98 |
matrix = scan.mean[metric]
|
| 99 |
n = len(scan.amino_acids)
|
|
@@ -115,7 +124,19 @@ def heatmap(scan: Scan, metric: str, selected=None):
|
|
| 115 |
ax.set_title(f"Mean Δ {LABELS[metric]}", fontsize=20)
|
| 116 |
fig.tight_layout()
|
| 117 |
|
| 118 |
-
# after the layout, so
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
if selected is not None:
|
| 120 |
i, j = selected
|
| 121 |
ax.add_patch(Rectangle((j - 0.5, i - 0.5), 1, 1, fill=False, edgecolor=INK,
|
|
@@ -125,17 +146,117 @@ def heatmap(scan: Scan, metric: str, selected=None):
|
|
| 125 |
image = np.asarray(canvas.buffer_rgba())[..., :3].copy()
|
| 126 |
|
| 127 |
# display coordinates count up from the bottom; image rows count down from the top
|
| 128 |
-
box = ax.get_window_extent()
|
| 129 |
height = image.shape[0]
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
|
| 132 |
|
| 133 |
-
def
|
| 134 |
-
"""
|
| 135 |
-
|
| 136 |
-
|
| 137 |
i = int(np.floor((y - top) / (bottom - top) * n))
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
|
| 141 |
def distribution(scan: Scan, i: int, j: int) -> Figure:
|
|
|
|
| 42 |
|
| 43 |
# Shown before any click: F -> S, the substitution the paper's NUP98 validation makes one F at a time
|
| 44 |
DEFAULT_SELECTION = ("F", "S")
|
| 45 |
+
# and compared: the rows of the residues the paper finds drive condensation, aromatics and arginine
|
| 46 |
+
DEFAULT_COMPARISON = ("row", ("F", "Y", "W", "R"))
|
| 47 |
+
|
| 48 |
+
# Series colours for compared rows or columns: the categorical palette's eight slots in fixed order
|
| 49 |
+
# (validated on white with the dataviz checks; three are under 3:1 against it, hence the direct
|
| 50 |
+
# labels and the table view that go with the plot)
|
| 51 |
+
SERIES = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#6250d6", "#e34948"]
|
| 52 |
|
| 53 |
|
| 54 |
def mean_matrix(delta: np.ndarray) -> np.ndarray:
|
|
|
|
| 96 |
)
|
| 97 |
|
| 98 |
|
| 99 |
+
def heatmap(scan: Scan, metric: str, selected=None, compared=None):
|
| 100 |
+
"""(image, layout): the matrix drawn as in the paper, as an RGB array, and where its parts sit
|
| 101 |
+
in that image, so a click maps back to what it landed on (see hit).
|
| 102 |
|
| 103 |
+
selected, a (from, to) index pair, is outlined. compared is ("row" | "col", [(index, colour)]):
|
| 104 |
+
rows or columns being compared, each outlined, with its residue in bold and a mark in its
|
| 105 |
+
series colour beside it -- the colour is the series', the text stays ink.
|
| 106 |
"""
|
| 107 |
matrix = scan.mean[metric]
|
| 108 |
n = len(scan.amino_acids)
|
|
|
|
| 124 |
ax.set_title(f"Mean Δ {LABELS[metric]}", fontsize=20)
|
| 125 |
fig.tight_layout()
|
| 126 |
|
| 127 |
+
# Everything below is drawn after the layout, so the matrix sits at the same pixels in every
|
| 128 |
+
# render and a click always maps the same way. Marks go just outside it: rows beside the
|
| 129 |
+
# right edge, before the colour bar; columns under the bottom edge, where no labels are.
|
| 130 |
+
axis, marks = compared if compared is not None else ("row", [])
|
| 131 |
+
for k, colour in marks:
|
| 132 |
+
if axis == "row":
|
| 133 |
+
ax.add_patch(Rectangle((-0.5, k - 0.5), n, 1, fill=False, edgecolor=INK, linewidth=1.6, clip_on=False))
|
| 134 |
+
ax.add_patch(Rectangle((n - 0.38, k - 0.4), 0.34, 0.8, color=colour, clip_on=False))
|
| 135 |
+
ax.get_yticklabels()[k].set_fontweight("bold")
|
| 136 |
+
else:
|
| 137 |
+
ax.add_patch(Rectangle((k - 0.5, -0.5), 1, n, fill=False, edgecolor=INK, linewidth=1.6, clip_on=False))
|
| 138 |
+
ax.add_patch(Rectangle((k - 0.4, n - 0.38), 0.8, 0.34, color=colour, clip_on=False))
|
| 139 |
+
ax.get_xticklabels()[k].set_fontweight("bold")
|
| 140 |
if selected is not None:
|
| 141 |
i, j = selected
|
| 142 |
ax.add_patch(Rectangle((j - 0.5, i - 0.5), 1, 1, fill=False, edgecolor=INK,
|
|
|
|
| 146 |
image = np.asarray(canvas.buffer_rgba())[..., :3].copy()
|
| 147 |
|
| 148 |
# display coordinates count up from the bottom; image rows count down from the top
|
|
|
|
| 149 |
height = image.shape[0]
|
| 150 |
+
box = ax.get_window_extent()
|
| 151 |
+
renderer = canvas.get_renderer()
|
| 152 |
+
rows = [t.get_window_extent(renderer) for t in ax.get_yticklabels()]
|
| 153 |
+
cols = [t.get_window_extent(renderer) for t in ax.get_xticklabels()]
|
| 154 |
+
layout = {
|
| 155 |
+
"matrix": (box.x0, height - box.y1, box.x1, height - box.y0), # left, top, right, bottom
|
| 156 |
+
"row_labels": min(b.x0 for b in rows) - 6, # left edge of the FROM labels
|
| 157 |
+
"col_labels": height - max(b.y1 for b in cols) - 6, # top edge of the TO labels
|
| 158 |
+
}
|
| 159 |
+
return image, layout
|
| 160 |
|
| 161 |
|
| 162 |
+
def hit(layout, x: float, y: float, n: int = 20):
|
| 163 |
+
"""What pixel (x, y) of a heatmap image is on: ("cell", i, j) in the matrix, ("row", i) on a
|
| 164 |
+
Mutate FROM label, ("col", j) on a Mutate TO label, or None anywhere else."""
|
| 165 |
+
left, top, right, bottom = layout["matrix"]
|
| 166 |
i = int(np.floor((y - top) / (bottom - top) * n))
|
| 167 |
+
j = int(np.floor((x - left) / (right - left) * n))
|
| 168 |
+
on_row, on_col = 0 <= i < n, 0 <= j < n
|
| 169 |
+
if on_row and on_col:
|
| 170 |
+
return ("cell", i, j)
|
| 171 |
+
if on_row and layout["row_labels"] <= x < left:
|
| 172 |
+
return ("row", i)
|
| 173 |
+
if on_col and layout["col_labels"] <= y < top:
|
| 174 |
+
return ("col", j)
|
| 175 |
+
return None
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def assign_colours(residues, previous: dict) -> dict:
|
| 179 |
+
"""residue -> series colour for the compared residues. A residue keeps the colour it had, and a
|
| 180 |
+
new one takes the first free slot, so adding or removing one never repaints the others."""
|
| 181 |
+
colours = {r: c for r, c in previous.items() if r in residues}
|
| 182 |
+
for residue in residues:
|
| 183 |
+
if residue not in colours:
|
| 184 |
+
colours[residue] = next(c for c in SERIES if c not in colours.values())
|
| 185 |
+
return colours
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _profile(scan: Scan, metric: str, axis: str, k: int):
|
| 189 |
+
"""Mean change and its 95% confidence half-width along compared row/column k, NaN on the diagonal."""
|
| 190 |
+
delta = scan.delta[metric][k] if axis == "row" else scan.delta[metric][:, k] # (other, idp)
|
| 191 |
+
scored = np.isfinite(delta)
|
| 192 |
+
n = scored.sum(axis=1)
|
| 193 |
+
mean = (scan.mean[metric][k] if axis == "row" else scan.mean[metric][:, k]).astype(float).copy()
|
| 194 |
+
sd = np.array([np.std(d[s], ddof=1) if c > 1 else np.nan for d, s, c in zip(delta, scored, n)])
|
| 195 |
+
half = 1.96 * sd / np.sqrt(np.maximum(n, 1))
|
| 196 |
+
mean[k] = half[k] = np.nan
|
| 197 |
+
return mean, half, n
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def comparison(scan: Scan, axis: str, colours: dict):
|
| 201 |
+
"""The compared rows ("row": Mutate FROM) or columns ("col": Mutate TO) side by side: each one's
|
| 202 |
+
mean change along the other axis, with 95% confidence intervals over the IDPs, AUC left and
|
| 203 |
+
AAC right. Plotly, for the hover; a figure with a note when nothing is compared."""
|
| 204 |
+
import plotly.graph_objects as go
|
| 205 |
+
from plotly.subplots import make_subplots
|
| 206 |
+
|
| 207 |
+
aa = list(scan.amino_acids)
|
| 208 |
+
other = "Mutate TO" if axis == "row" else "Mutate FROM"
|
| 209 |
+
fig = make_subplots(rows=1, cols=2, horizontal_spacing=0.09,
|
| 210 |
+
subplot_titles=[f"Mean Δ {LABELS[m]}" for m in METRICS])
|
| 211 |
+
|
| 212 |
+
for col, metric in enumerate(METRICS, start=1):
|
| 213 |
+
fig.add_hline(y=0, line=dict(color=MUTED, width=1, dash="dot"), row=1, col=col)
|
| 214 |
+
for residue, colour in colours.items():
|
| 215 |
+
k = scan.index(residue)
|
| 216 |
+
mean, half, n = _profile(scan, metric, axis, k)
|
| 217 |
+
name = f"FROM {residue}" if axis == "row" else f"TO {residue}"
|
| 218 |
+
pairs = [f"{residue} → {a}" if axis == "row" else f"{a} → {residue}" for a in aa]
|
| 219 |
+
fig.add_trace(go.Scatter(
|
| 220 |
+
x=aa, y=mean, mode="lines+markers", name=name, legendgroup=name, showlegend=col == 1,
|
| 221 |
+
line=dict(color=colour, width=2), marker=dict(color=colour, size=8, line=dict(color=SURFACE, width=2)),
|
| 222 |
+
error_y=dict(type="data", array=half, color=colour, thickness=1, width=0),
|
| 223 |
+
customdata=np.stack([pairs, half, n], axis=1),
|
| 224 |
+
hovertemplate=(f"<b>%{{customdata[0]}}</b><br>mean Δ {LABELS[metric]} %{{y:+.4f}}"
|
| 225 |
+
" ± %{customdata[1]:.4f} (95% CI)<br>%{customdata[2]} IDPs<extra></extra>"),
|
| 226 |
+
), row=1, col=col)
|
| 227 |
+
if len(colours) <= 4: # a few series are named where they end, as well as in the legend
|
| 228 |
+
last = int(np.flatnonzero(np.isfinite(mean))[-1])
|
| 229 |
+
fig.add_annotation(x=aa[last], y=mean[last], text=residue, showarrow=False, xshift=12,
|
| 230 |
+
font=dict(size=12, color=INK), row=1, col=col)
|
| 231 |
+
fig.update_xaxes(title_text=other, categoryorder="array", categoryarray=aa, showgrid=False,
|
| 232 |
+
row=1, col=col)
|
| 233 |
+
fig.update_yaxes(title_text=f"Mean Δ {LABELS[metric]} (mutant − wild type)", zeroline=False,
|
| 234 |
+
gridcolor="rgba(82,81,78,0.15)", row=1, col=col)
|
| 235 |
+
|
| 236 |
+
if not colours:
|
| 237 |
+
fig.add_annotation(text="Click a residue on either axis of a matrix, or pick some above",
|
| 238 |
+
xref="paper", yref="paper", x=0.5, y=0.5, showarrow=False,
|
| 239 |
+
font=dict(size=13, color=MUTED))
|
| 240 |
+
fig.update_layout(
|
| 241 |
+
height=430, margin=dict(l=10, r=10, t=40, b=10), paper_bgcolor=SURFACE, plot_bgcolor=SURFACE,
|
| 242 |
+
font=dict(color=INK, size=12), legend=dict(orientation="h", y=1.14, x=0, font=dict(size=12)),
|
| 243 |
+
hovermode="closest",
|
| 244 |
+
)
|
| 245 |
+
return fig
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def comparison_table(scan: Scan, axis: str, colours: dict) -> pd.DataFrame:
|
| 249 |
+
"""The numbers the comparison plots: one row per residue of the other axis, a mean change and
|
| 250 |
+
its confidence half-width per compared residue and metric."""
|
| 251 |
+
table = {("Mutate TO" if axis == "row" else "Mutate FROM"): list(scan.amino_acids)}
|
| 252 |
+
for residue in colours:
|
| 253 |
+
k = scan.index(residue)
|
| 254 |
+
name = f"FROM {residue}" if axis == "row" else f"TO {residue}"
|
| 255 |
+
for metric in METRICS:
|
| 256 |
+
mean, half, _ = _profile(scan, metric, axis, k)
|
| 257 |
+
table[f"{name} Δ {LABELS[metric]}"] = mean
|
| 258 |
+
table[f"{name} ± 95% CI ({LABELS[metric]})"] = half
|
| 259 |
+
return pd.DataFrame(table).round(4)
|
| 260 |
|
| 261 |
|
| 262 |
def distribution(scan: Scan, i: int, j: int) -> Figure:
|