BoHuangLab commited on
Commit
f2dfd29
·
verified ·
1 Parent(s): e8ad02e

Condensate Titration: input-only panel, row/column comparison in the scan, ZeroGPU duration 120 s

Browse files
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
- Also reported: **c_sat**, the first intensity at which the smoothed curve reaches
63
- probability 0.8, and the raw per-image calls as a downloadable CSV.
64
 
65
  ### Layout
66
 
67
  The tab is laid out like the Virtual OpenCell one, in the same OpenCell style:
68
 
69
- - **Navbar** — a picker over the NUP98 panel below. Picking one fills the sequence
70
- box; typing a panel sequence selects it, and anything else is a custom sequence.
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. Its panel — the wild type and
86
- 91 random F→S mutants, ten draws for each of 1 to 9 substitutions and the one way to
87
- replace all ten F — ships in `assets/nup98_mutation_seqs.csv` and fills the navbar
88
- picker; comparing a mutant's AUC against WT is the quickest way to see the model respond
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 300 s), which
163
- covers the defaults with room to spare.
 
 
 
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
- GPU_DECORATOR = spaces.GPU(duration=int(os.environ.get("ZEROGPU_DURATION", "300")))
 
 
 
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
- # The NUP98 panel the navbar picks from, and the name of each sequence in it
56
- PRESETS = page.load_presets(os.path.join(HERE, "assets", "nup98_mutation_seqs.csv"))
57
- PRESET_NAMES = {sequence: name for name, sequence in PRESETS.items()}
 
 
 
 
 
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 with a sequence picker; the sequence, its
107
- numbers and its titration curve on the left; the generated images on the right; then the
108
- paper's substitution scan, how a titration is computed, and a footer."""
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, sequence_name(result.sequence)),
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
- info_panel = gr.HTML(info(page.DEFAULT_PRESET, PRESETS[page.DEFAULT_PRESET]))
167
  sequence = gr.Textbox(
168
- value=PRESETS[page.DEFAULT_PRESET], lines=3, max_lines=6, label="IDP sequence",
169
- info=(f"Exactly {pipeline.SEQUENCE_LENGTH} single-letter amino acids, the length of "
170
- "the whole CondenSeq library; FASTA headers are stripped"),
171
  elem_classes="cs-sequence",
172
  )
173
- run_button = gr.Button("Run titration", elem_classes="cs-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; a click on
243
- any FROM -> TO cell shows the per-IDP changes that cell averages. Precomputed, so CPU only."""
 
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 view(i, j):
251
- maps = [substitution.heatmap(scan, metric, (i, j))[0] for metric in substitution.METRICS]
252
- return (*maps, page.substitution_html(scan, i, j), substitution.distribution(scan, i, j),
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 select_auc(evt: gr.SelectData):
268
- return select("auc", evt)
269
-
270
- def select_aac(evt: gr.SelectData):
271
- return select("aac", evt)
272
 
273
- auc0, aac0, selection0, distribution0, table0 = view(
274
- *(scan.index(residue) for residue in substitution.DEFAULT_SELECTION))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- gr.HTML(page.per_idp_html(substitution.MODEL_REPO, substitution.SCAN_FILE), padding=False)
298
- idp_table = gr.Dataframe(value=table0, wrap=True, max_height=380, show_label=False)
299
-
300
- outputs = [auc_map, aac_map, selection, distribution, idp_table]
301
- auc_map.select(select_auc, outputs=outputs, show_progress="hidden")
302
- aac_map.select(select_aac, outputs=outputs, show_progress="hidden")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 with a picker, an information panel on the left with the plot under it, a black image
5
- viewer with a sample strip and contrast settings on the right -- and the same channel colours, the
6
- nucleus blue and the protein 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 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 &middot; {num_images} images &middot; 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, name: str = "") -> str:
112
- """AUC, AAC, c_sat and the condensed count, as OpenCell metadata; dashes before any run."""
113
- if result is None:
114
- values = ["—"] * 4
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">{measured} AUC is how much of the scanned range the IDP spends
126
  condensed; AAC how much of that it loses to re-dissolving at high expression. Both are normalised
127
- to [0, 1]. c<sub>sat</sub> is the first level at which the curve reaches 0.8.</div>
128
- <div class="vo-metadata">{''.join(_item(l, v) for l, v in zip(labels, values))}</div>
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 titration to draw the curve", transform=ax.transAxes,
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 titration to add the IDP.</div>')
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)} &middot; '
@@ -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.</div>
 
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)} &middot; '
 
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, extent): the matrix drawn as in the paper, as an RGB array, and where the matrix
94
- sits in that image -- (left, top, right, bottom) in pixels -- so a click maps back to a cell.
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 an outline on an edge cell cannot move the matrix between renders
 
 
 
 
 
 
 
 
 
 
 
 
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
- return image, (box.x0, height - box.y1, box.x1, height - box.y0)
 
 
 
 
 
 
 
 
 
131
 
132
 
133
- def cell_at(extent, x: float, y: float, n: int = 20):
134
- """The (from, to) cell under pixel (x, y) of a heatmap image, or None off the matrix."""
135
- left, top, right, bottom = extent
136
- j = int(np.floor((x - left) / (right - left) * n))
137
  i = int(np.floor((y - top) / (bottom - top) * n))
138
- return (i, j) if 0 <= i < n and 0 <= j < n else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: