Hamish M. Blair commited on
Commit
9156fae
·
1 Parent(s): cc769d4

Rewrite as FastAPI + Jinja + HTMX, replacing Gradio

Browse files

The Gradio version had recurring brittleness around dynamic group rows
(four prior attempts to make Add/Remove work, each broken in a
different way) and visibility toggling of the results section. Gradio's
component-lifecycle model is the wrong tool for the level of
interactivity this app needs.

The rewrite owns the rendering model end-to-end:

- FastAPI routes for form submission, status polling, plot JSON, and
downloads; Jinja2 templates with HTMX for dynamic group rows; Plotly
JSON rendered client-side via Plotly.js from CDN.
- Pipeline logic moved into pipeline.py (framework-agnostic). Each
group's plots are built and saved on disk under
groups/{name}/{key}.json; a combined reactivity plot is generated
when the input is single-reference with multiple groups.
- Results page navigates groups via a dropdown that re-fetches plot
JSON; per-reference plots (profile, MI, correlation, pairwise
coverage) update via a sequence dropdown that rebuilds them on
demand from the saved HDF5.
- /run-example POSTs the bundled example dataset without requiring a
file upload.
- /results/{job_id}/download/all bundles the HDF5, CSV, log, and PNG
renders of every plot (via kaleido) into a single ZIP.
- pyproject.toml + uv.lock for local dev. The cmuts extra wires the
sibling cmuts checkout so `uv sync --extra cmuts` keeps the binding
in place; without it, the pipeline returns a clean error and the UI
remains testable.
- Dockerfile updated: drops Gradio, adds FastAPI/Jinja/multipart/
kaleido, and copies templates/, static/, and pipeline.py.

Styling: dark theme with light purple accents on the form/header;
plot tiles stay white so cmuts's default Plotly styling is preserved
unchanged. The only layout override is stripping fixed width/height so
each figure fills its tile.

Files changed (14) hide show
  1. .gitignore +4 -0
  2. Dockerfile +12 -5
  3. README.md +57 -0
  4. app.py +460 -1602
  5. pipeline.py +784 -0
  6. pyproject.toml +25 -0
  7. static/app.css +274 -0
  8. static/app.js +1 -0
  9. static/results.js +125 -0
  10. templates/_group_row.html +15 -0
  11. templates/base.html +27 -0
  12. templates/index.html +110 -0
  13. templates/results.html +153 -0
  14. uv.lock +0 -0
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .DS_Store
Dockerfile CHANGED
@@ -44,14 +44,21 @@ RUN sed -i 's/font.family.*=.*"Helvetica"/font.family"] = "Nimbus Sans"/' \
44
  fc-cache -f && \
45
  python3 -c "import matplotlib.font_manager; matplotlib.font_manager._load_fontmanager(try_read_cache=False)"
46
 
47
- # Install Gradio
48
- RUN pip install --no-cache-dir gradio plotly h5py uvicorn
 
 
 
 
 
49
 
50
  # Clean up build artifacts
51
  RUN rm -rf /cmuts/build
52
 
53
- # Copy the app and example data
54
- COPY app.py /app/app.py
 
 
55
  COPY examples /app/examples
56
  WORKDIR /app
57
 
@@ -61,4 +68,4 @@ RUN useradd -m -u 1000 user
61
  USER user
62
  ENV HOME=/home/user PATH="/cmuts/bin:/home/user/.local/bin:$PATH"
63
 
64
- CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
 
44
  fc-cache -f && \
45
  python3 -c "import matplotlib.font_manager; matplotlib.font_manager._load_fontmanager(try_read_cache=False)"
46
 
47
+ # Install Python web deps
48
+ RUN pip install --no-cache-dir \
49
+ "fastapi>=0.110" \
50
+ "uvicorn[standard]>=0.27" \
51
+ "jinja2>=3.1" \
52
+ "python-multipart>=0.0.9" \
53
+ plotly "kaleido==0.2.1" h5py
54
 
55
  # Clean up build artifacts
56
  RUN rm -rf /cmuts/build
57
 
58
+ # Copy the app, templates, static assets, and example data
59
+ COPY app.py pipeline.py /app/
60
+ COPY templates /app/templates
61
+ COPY static /app/static
62
  COPY examples /app/examples
63
  WORKDIR /app
64
 
 
68
  USER user
69
  ENV HOME=/home/user PATH="/cmuts/bin:/home/user/.local/bin:$PATH"
70
 
71
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
README.md CHANGED
@@ -7,3 +7,60 @@ sdk: docker
7
  pinned: false
8
  license: mit
9
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  pinned: false
8
  license: mit
9
  ---
10
+
11
+ # cmuts web UI
12
+
13
+ FastAPI + Jinja + HTMX frontend for the
14
+ [cmuts](https://github.com/hmblair/cmuts) RNA chemical-probing pipeline.
15
+
16
+ ## Local development
17
+
18
+ ### Option A — Docker (full pipeline)
19
+
20
+ The Dockerfile builds `cmuts` from source along with its system
21
+ dependencies (bowtie2, samtools, libhts). This is the only way to run the
22
+ full pipeline locally on macOS.
23
+
24
+ ```bash
25
+ docker build -t cmuts-space .
26
+ docker run --rm -p 7860:7860 cmuts-space
27
+ # Open http://localhost:7860
28
+ ```
29
+
30
+ ### Option B — Python venv (UI only; pipeline returns an error)
31
+
32
+ For working on the UI without rebuilding the cmuts toolchain:
33
+
34
+ ```bash
35
+ uv sync
36
+ uv run python app.py
37
+ # Open http://localhost:7860
38
+ ```
39
+
40
+ The form, group rows, results page, and error states all work.
41
+ Submitting a job will fail cleanly with a "cmuts is not installed"
42
+ message.
43
+
44
+ ### Option C — venv with cmuts linked from a sibling checkout
45
+
46
+ If you have the `cmuts` repo built locally at `../cmuts` (sibling to
47
+ this directory), plus `bowtie2` and `samtools` on `PATH`, the pipeline
48
+ runs without Docker:
49
+
50
+ ```bash
51
+ brew install bowtie2 samtools hdf5 htslib autoconf automake libtool libomp
52
+ # Build cmuts in ../cmuts (see github.com/hmblair/cmuts) — `./configure`
53
+ uv sync --extra cmuts
54
+ uv run python app.py
55
+ ```
56
+
57
+ `uv sync` strips packages not declared in pyproject. The `cmuts` extra
58
+ declares the sibling path so re-running sync keeps it installed.
59
+
60
+ ## Layout
61
+
62
+ - `app.py` — FastAPI routes, request handling
63
+ - `pipeline.py` — subprocess orchestration, HDF5/plot generation
64
+ - `templates/` — Jinja2 HTML
65
+ - `static/` — CSS + JS (Plotly via CDN)
66
+ - `examples/` — bundled FASTA/FASTQ for the "Run with example data" button
app.py CHANGED
@@ -1,1676 +1,534 @@
1
- #!/usr/bin/env python3
2
- """Gradio app for cmuts: chemical mutation profiling for RNA structure analysis."""
 
 
3
 
4
  from __future__ import annotations
5
 
6
- import csv
7
- import glob
8
  import json
9
  import os
10
- import re
11
  import shutil
12
- import subprocess
13
- import tempfile
14
  import time
15
- import traceback
16
  import uuid
17
- from dataclasses import dataclass
18
-
19
- import gradio as gr
20
- import h5py
21
- import numpy as np
22
- import plotly.graph_objects as go
23
- import cmuts
24
- from cmuts.visualize.plotly import (
25
- plot_correlation,
26
- plot_coverage,
27
- plot_cumulative_reads,
28
- plot_examples,
29
- plot_heatmap,
30
- plot_mi,
31
- plot_pairwise_coverage,
32
- plot_profile,
33
- plot_profiles,
34
- plot_read_hist,
35
- plot_snr_scaling,
36
- plot_termination,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  )
38
- from fastapi import FastAPI
39
- from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
40
-
41
-
42
- # --- Constants and paths ---
43
-
44
- EXAMPLES_DIR = os.environ.get("CMUTS_EXAMPLES_DIR", os.path.join(os.path.dirname(__file__), "examples"))
45
- MAX_FASTQ_MB = int(os.environ.get("CMUTS_MAX_FASTQ_MB", "500"))
46
- RESULTS_TTL_HOURS = int(os.environ.get("CMUTS_RESULTS_TTL_HOURS", "48"))
47
- DEFAULT_GROUP_NAME = "profile"
48
- PIPELINE_TIMEOUT_SEC = int(os.environ.get("CMUTS_PIPELINE_TIMEOUT_SEC", "600"))
49
- MAX_GROUPS = 5
50
-
51
- _default_results_dir = "/data/results" if os.path.isdir("/data") else "/tmp/cmuts_results"
52
- RESULTS_DIR = os.environ.get("CMUTS_RESULTS_DIR", _default_results_dir)
53
- os.makedirs(RESULTS_DIR, exist_ok=True)
54
-
55
- _FASTQ_SUFFIXES = (".fastq.gz", ".fq.gz", ".fastq", ".fq")
56
-
57
-
58
- # --- Dataclasses ---
59
-
60
-
61
- @dataclass
62
- class GroupInput:
63
- """One experiment group with modified and optional control FASTQ files."""
64
- name: str
65
- mod_fastq: str
66
- nomod_fastq: str | None = None
67
-
68
-
69
- @dataclass
70
- class ResultUpdate:
71
- """Collected Gradio output updates for the results section."""
72
- results_group: object = None
73
- error_banner: object = None
74
- result_url: object = None
75
- output_file: object = None
76
- csv_file: object = None
77
- profile_plot: object = None
78
- seq_dropdown: object = None
79
- stats: object = None
80
- load_status: str = ""
81
- mod_heatmap: object = None
82
- termination: object = None
83
- coverage: object = None
84
- read_hist: object = None
85
- cumulative_reads: object = None
86
- snr_scaling: object = None
87
- mi: object = None
88
- correlation: object = None
89
- pairwise_coverage: object = None
90
- log: str = ""
91
- structure_files: object = None
92
- structure_commands: object = None
93
-
94
- @classmethod
95
- def hidden(cls) -> ResultUpdate:
96
- h = gr.update(visible=False, value=None)
97
- return cls(
98
- results_group=gr.update(),
99
- error_banner=gr.update(visible=False, value=""),
100
- result_url=h, output_file=h, csv_file=h, profile_plot=h,
101
- seq_dropdown=None, stats=h, load_status="",
102
- mod_heatmap=h, termination=h, coverage=h,
103
- read_hist=h, cumulative_reads=h, snr_scaling=h,
104
- mi=h, correlation=h, pairwise_coverage=h,
105
- log="",
106
- structure_files=h, structure_commands=h,
107
- )
108
 
109
- def to_tuple(self) -> tuple:
110
- return (
111
- self.results_group, self.error_banner,
112
- self.result_url, self.output_file, self.csv_file,
113
- self.profile_plot, self.seq_dropdown,
114
- self.stats, self.load_status,
115
- self.mod_heatmap, self.termination, self.coverage,
116
- self.read_hist, self.cumulative_reads, self.snr_scaling,
117
- self.mi, self.correlation, self.pairwise_coverage,
118
- self.log,
119
- self.structure_files, self.structure_commands,
120
- )
121
 
 
122
 
123
- @dataclass
124
- class AlignConfig:
125
- trim_5: str = ""
126
- trim_3: str = ""
127
- local_align: bool = False
128
-
129
-
130
- @dataclass
131
- class CoreConfig:
132
- min_mapq: int = 10
133
- min_phred: int = 10
134
- min_length: int = 2
135
- max_length: int = 1024
136
- no_insertions: bool = True
137
- no_mismatches: bool = False
138
- strand: str = "both"
139
- compute_pairwise: bool = False
140
-
141
-
142
- @dataclass
143
- class NormConfig:
144
- norm_method: str = "ubr"
145
- no_insertions: bool = True
146
- no_deletions: bool = False
147
- clip_low: bool = False
148
- clip_high: bool = False
149
- blank_5p: int = 0
150
- blank_3p: int = 0
151
- blank_cutoff: int = 10
152
- norm_cutoff: int = 500
153
- norm_percentile: int = 90
154
- sig: float = 0.05
155
-
156
-
157
- # --- FASTA parsing ---
158
-
159
-
160
- def _parse_fasta(fasta_path: str) -> list[tuple[str, str]]:
161
- """Parse FASTA file, returning list of (name, sequence) tuples."""
162
- entries: list[tuple[str, str]] = []
163
- name = ""
164
- seq_parts: list[str] = []
165
- with open(fasta_path) as f:
166
- for line in f:
167
- line = line.strip()
168
- if line.startswith(">"):
169
- if seq_parts:
170
- entries.append((name, "".join(seq_parts)))
171
- seq_parts = []
172
- name = line[1:].split()[0]
173
- elif line:
174
- seq_parts.append(line)
175
- if seq_parts:
176
- entries.append((name, "".join(seq_parts)))
177
- return entries
178
-
179
-
180
- # --- Input validation ---
181
-
182
-
183
- def _sanitize_group_name(raw: str | None) -> str:
184
- """Normalize a user-supplied group name to a safe HDF5 path component."""
185
- name = re.sub(r"[^\w\-]", "_", (raw or "").strip())
186
- return name or DEFAULT_GROUP_NAME
187
-
188
-
189
- def _fastq_stem(path: str) -> str:
190
- """Return the sample name from a FASTQ path by stripping known extensions."""
191
- name = os.path.basename(path)
192
- for suffix in _FASTQ_SUFFIXES:
193
- if name.endswith(suffix):
194
- return name[: -len(suffix)]
195
- return os.path.splitext(name)[0]
196
-
197
-
198
- def _file_size_mb(path: str) -> float:
199
- return os.path.getsize(path) / (1024 * 1024)
200
-
201
-
202
- # --- CLI command builders ---
203
-
204
-
205
- def _build_align_cmd(
206
- fasta_path: str,
207
- output_dir: str,
208
- fastq_files: list[str],
209
- cfg: AlignConfig,
210
- ) -> list[str]:
211
- cmd = ["cmuts", "align", "--fasta", fasta_path, "--output", output_dir]
212
- if cfg.trim_5.strip():
213
- cmd.extend(["--trim-5", cfg.trim_5.strip()])
214
- if cfg.trim_3.strip():
215
- cmd.extend(["--trim-3", cfg.trim_3.strip()])
216
- if cfg.local_align:
217
- cmd.append("--local")
218
- cmd.extend(fastq_files)
219
- return cmd
220
-
221
-
222
- def _build_core_cmd(
223
- fasta_path: str,
224
- output_h5: str,
225
- bam_files: list[str],
226
- cfg: CoreConfig,
227
- ) -> list[str]:
228
- cmd = [
229
- "cmuts", "core",
230
- "-f", fasta_path,
231
- "-o", output_h5,
232
- "--min-mapq", str(cfg.min_mapq),
233
- "--min-phred", str(cfg.min_phred),
234
- "--min-length", str(cfg.min_length),
235
- "--max-length", str(cfg.max_length),
236
- ]
237
- if cfg.no_insertions:
238
- cmd.append("--no-insertions")
239
- if cfg.no_mismatches:
240
- cmd.append("--no-mismatches")
241
- if cfg.strand == "forward":
242
- cmd.append("--no-reverse")
243
- elif cfg.strand == "reverse":
244
- cmd.append("--only-reverse")
245
- if cfg.compute_pairwise:
246
- cmd.append("--pairwise")
247
- cmd.extend(bam_files)
248
- return cmd
249
-
250
-
251
- # --- Intermediate checks ---
252
-
253
-
254
- def _check_bam_files(alignments_dir: str) -> list[str]:
255
- """Return sorted absolute BAM paths, or raise if none found."""
256
- bam_files = sorted(glob.glob(os.path.join(alignments_dir, "*.bam")))
257
- if not bam_files:
258
- raise RuntimeError(
259
- f"Alignment produced no BAM files in {alignments_dir}. "
260
- "Check the log for bowtie2 errors — the reference FASTA may not "
261
- "match the reads, or the FASTQ may be empty."
262
- )
263
- return bam_files
264
 
 
 
 
265
 
266
- def _check_output_h5(path: str, step: str) -> None:
267
- """Raise if an expected HDF5 output file is missing."""
268
- if not os.path.isfile(path):
269
- raise RuntimeError(
270
- f"{step} did not produce output file: {os.path.basename(path)}. "
271
- "Check the log for errors."
272
- )
273
 
 
 
 
 
 
 
274
 
275
- # --- CSV generation ---
276
-
277
-
278
- def _generate_csv(
279
- h5_path: str,
280
- fasta_path: str,
281
- group_names: list[str],
282
- ) -> str:
283
- """Generate a CSV from HDF5 profiles with columns for each group."""
284
- fasta_entries = _parse_fasta(fasta_path)
285
- csv_path = h5_path.rsplit(".", 1)[0] + ".csv"
286
-
287
- with h5py.File(h5_path, "r") as f, open(csv_path, "w", newline="") as csvfile:
288
- writer = csv.writer(csvfile)
289
-
290
- first_grp = f[group_names[0]]
291
- n_refs = first_grp["reactivity"].shape[0]
292
- seq_len = first_grp["reactivity"].shape[1]
293
- multi_ref = n_refs > 1
294
-
295
- header: list[str] = []
296
- if multi_ref:
297
- header.append("Reference")
298
- header.extend(["Position", "Nucleotide"])
299
- for gn in group_names:
300
- header.extend([gn, f"{gn}_error"])
301
- writer.writerow(header)
302
-
303
- for ref_idx in range(n_refs):
304
- ref_name = fasta_entries[ref_idx][0] if ref_idx < len(fasta_entries) else f"ref_{ref_idx + 1}"
305
- ref_seq = fasta_entries[ref_idx][1] if ref_idx < len(fasta_entries) else ""
306
-
307
- group_data = {}
308
- for gn in group_names:
309
- group_data[gn] = {
310
- "reactivity": np.array(f[gn]["reactivity"])[ref_idx],
311
- "error": np.array(f[gn]["error"])[ref_idx],
312
- }
313
-
314
- for pos in range(seq_len):
315
- row: list[str] = []
316
- if multi_ref:
317
- row.append(ref_name)
318
- row.append(str(pos + 1))
319
- row.append(ref_seq[pos] if pos < len(ref_seq) else "")
320
- for gn in group_names:
321
- r = group_data[gn]["reactivity"][pos]
322
- e = group_data[gn]["error"][pos]
323
- row.append(f"{r:.6f}" if np.isfinite(r) else "")
324
- row.append(f"{e:.6f}" if np.isfinite(e) else "")
325
- writer.writerow(row)
326
-
327
- return csv_path
328
-
329
-
330
- # --- Structure visualization ---
331
-
332
-
333
- def _build_defattrs(
334
- cif_path: str,
335
- sequence: str,
336
- results: list,
337
- out_dir: str,
338
- chimerax_bin: str = "ChimeraX",
339
- ) -> tuple[list[str], str]:
340
- """Generate one defattr per group and the ChimeraX commands to render them.
341
-
342
- Returns (defattr_paths, markdown). ``markdown`` is human-readable text
343
- suitable for a gr.Markdown component, with one fenced code block per
344
- group containing the command to run locally.
345
- """
346
- cif_basename = os.path.basename(cif_path)
347
- # cmuts aligns sequences in DNA alphabet (it replaces U with T internally
348
- # when reading the CIF). Match that here so alignment scores are sensible.
349
- aln_seq = sequence.upper().replace("U", "T")
350
-
351
- defattr_paths: list[str] = []
352
- blocks: list[str] = [
353
- "### Visualize the structure with ChimeraX",
354
- "",
355
- f"Download each `.defattr` file below, place it next to your "
356
- f"`{cif_basename}` (a copy of your uploaded structure), and run the "
357
- f"matching command in ChimeraX's command line.",
358
- "",
359
- ]
360
-
361
- for r in results:
362
- name = r.group.name
363
- reactivity = np.asarray(r.combined.reactivity)
364
- if reactivity.shape[0] != 1:
365
- # Not single-reference — skip; defattrs require a 1:1 sequence map.
366
- continue
367
- defattr_path = os.path.join(out_dir, f"{name}.defattr")
368
- try:
369
- max_value = cmuts.visualize.make_defattr(
370
- reactivity[0], aln_seq, cif_path, defattr_path,
371
- )
372
- except Exception as e:
373
- blocks.append(f"**{name}:** could not generate defattr — {e}")
374
- blocks.append("")
375
- continue
376
 
377
- cmd = cmuts.visualize.chimerax_command(
378
- cif_basename,
379
- os.path.basename(defattr_path),
380
- color="indianred",
381
- max_value=max_value,
382
- )
383
- blocks.append(f"**{name}:**")
384
- blocks.append("```")
385
- blocks.append(f"{chimerax_bin} --cmd '{cmd}'")
386
- blocks.append("```")
387
- blocks.append("")
388
- defattr_paths.append(defattr_path)
389
-
390
- if len(defattr_paths) == 0:
391
- return [], ""
392
- return defattr_paths, "\n".join(blocks)
393
-
394
-
395
- # --- HDF5 reading and plotting ---
396
-
397
-
398
- def _build_plots(
399
- mod: cmuts.ProbingData,
400
- nomod: cmuts.ProbingData | None,
401
- combined: cmuts.ProbingData,
402
- name: str,
403
- sequence: str | None = None,
404
- ) -> dict[str, go.Figure | None]:
405
- """Build all diagnostic plots from in-memory ProbingData objects."""
406
- plots: dict[str, go.Figure | None] = {}
407
-
408
- plots["profile"] = plot_examples(
409
- np.asarray(combined.reactivity), np.asarray(combined.error), name,
410
- sequence=sequence,
411
- )
412
- plots["mod_heatmap"] = plot_heatmap(np.asarray(combined.heatmap), name)
413
- plots["termination"] = plot_termination(np.asarray(combined.terminations), name)
414
- plots["coverage"] = plot_coverage(
415
- np.asarray(combined.coverage), np.asarray(combined.reads), name,
416
- )
417
-
418
- is_multi = not combined.single()
419
- reads = np.asarray(combined.reads)
420
- plots["read_hist"] = plot_read_hist(reads, name) if is_multi else None
421
- plots["cumulative_reads"] = plot_cumulative_reads(reads, name) if is_multi else None
422
 
423
- plots["snr_scaling"] = plot_snr_scaling(mod, nomod, combined, name)
424
 
425
- if combined.mi is not None:
426
- plots["mi"] = plot_mi(np.asarray(combined.mi)[0], name)
427
- else:
428
- plots["mi"] = None
429
 
430
- if combined.covariance is not None:
431
- plots["correlation"] = plot_correlation(np.asarray(combined.covariance)[0], name)
432
- else:
433
- plots["correlation"] = None
434
 
435
- if combined.probability is not None:
436
- prob = np.asarray(combined.probability)
437
- plots["pairwise_coverage"] = plot_pairwise_coverage(prob[0, :, :, 1, 1], name)
438
- else:
439
- plots["pairwise_coverage"] = None
440
-
441
- return plots
442
-
443
-
444
- def _build_seq_names(n: int, sequences: list[str] | None) -> list[str]:
445
- """Build display labels for the sequence dropdown, disambiguating
446
- duplicates that result from the 50-char truncation."""
447
- raw: list[str] = []
448
- for i in range(n):
449
- seq = sequences[i] if sequences and i < len(sequences) else None
450
- if seq and len(seq) > 50:
451
- raw.append(seq[:50] + "...")
452
- elif seq:
453
- raw.append(seq)
454
- else:
455
- raw.append(f"Sequence {i + 1}")
456
- counts: dict[str, int] = {}
457
- out: list[str] = []
458
- for label in raw:
459
- if raw.count(label) > 1:
460
- counts[label] = counts.get(label, 0) + 1
461
- out.append(f"{label} (#{counts[label]})")
462
- else:
463
- out.append(label)
464
  return out
465
 
466
 
467
- def _read_profiles(h5_path: str, group_name: str) -> tuple[np.ndarray, list[str]]:
468
- """Read reactivity profiles and sequence names from an HDF5 file."""
469
- with h5py.File(h5_path, "r") as f:
470
- grp = f[group_name] if group_name in f else f
471
- reactivity = np.array(grp["reactivity"])
472
- sequences = None
473
- if "sequence" in f:
474
- sequences = [
475
- s.decode() if isinstance(s, bytes) else s for s in f["sequence"]
476
- ]
477
- return reactivity, _build_seq_names(reactivity.shape[0], sequences)
478
-
479
-
480
- def _build_stats_table(h5_path: str, group_names: list[str]) -> list[list[str]]:
481
- """Build a stats table for one or more groups."""
482
- rows: list[list[str]] = []
483
- multi = len(group_names) > 1
484
- with h5py.File(h5_path, "r") as f:
485
- for gn in group_names:
486
- grp = f[gn] if gn in f else f
487
- reactivity = np.array(grp["reactivity"])
488
- reads = np.array(grp["reads"])
489
- error = np.array(grp["error"])
490
- snr = np.array(grp["SNR"])
491
-
492
- n_refs = reactivity.shape[0]
493
- seq_len = reactivity.shape[1]
494
- total_reads = int(reads.sum())
495
- valid = np.isfinite(reactivity)
496
-
497
- if multi:
498
- rows.append([f"--- {gn} ---", ""])
499
-
500
- rows.extend([
501
- ["References", f"{n_refs:,}"],
502
- ["Reference length", f"{seq_len:,}"],
503
- ["Total reads", f"{total_reads:,}"],
504
- ["Mean reads per reference", f"{np.mean(reads):,.1f}"],
505
- ["Median reads per reference", f"{int(np.median(reads)):,}"],
506
- ])
507
-
508
- if valid.any():
509
- rows.extend([
510
- ["Mean reactivity", f"{np.mean(reactivity[valid]):.3f}"],
511
- ["Mean error", f"{np.mean(error[valid]):.3f}"],
512
- ["Mean SNR", f"{np.mean(snr):.2f}"],
513
- ["SNR > 1", f"{np.mean(snr > 1):.1%}"],
514
- ])
515
-
516
- dropout = float(np.mean(reads == 0))
517
- if dropout > 0:
518
- rows.append(["Dropout fraction", f"{dropout:.1%}"])
519
-
520
- return rows
521
-
522
-
523
- # --- Results persistence ---
524
-
525
-
526
- def cleanup_old_results() -> None:
527
- """Delete result directories older than RESULTS_TTL_HOURS."""
528
- cutoff = time.time() - RESULTS_TTL_HOURS * 3600
529
- if not os.path.isdir(RESULTS_DIR):
530
- return
531
- for entry in os.scandir(RESULTS_DIR):
532
- if entry.is_dir():
533
- meta_path = os.path.join(entry.path, "meta.json")
534
- try:
535
- if os.path.exists(meta_path):
536
- with open(meta_path) as f:
537
- created = json.load(f).get("created_at", 0)
538
- else:
539
- created = entry.stat().st_mtime
540
- if created < cutoff:
541
- shutil.rmtree(entry.path, ignore_errors=True)
542
- except Exception:
543
- pass
544
-
545
-
546
- _PLOT_KEYS = [
547
- "profile", "mod_heatmap", "termination", "coverage",
548
- "read_hist", "cumulative_reads", "snr_scaling", "mi", "correlation",
549
- "pairwise_coverage",
550
- ]
551
-
552
-
553
- def save_results(
554
- h5_path: str,
555
- csv_path: str | None,
556
- group_names: list[str],
557
- plots: dict[str, go.Figure | None],
558
- stats_rows: list[list[str]],
559
- names: list[str],
560
- job_id: str | None = None,
561
- defattr_paths: list[str] | None = None,
562
- chimerax_md: str = "",
563
- ) -> str:
564
- """Save pipeline results to persistent storage. Returns the job ID."""
565
- if job_id is None:
566
- job_id = uuid.uuid4().hex[:12]
567
- job_dir = os.path.join(RESULTS_DIR, job_id)
568
- os.makedirs(job_dir, exist_ok=True)
569
-
570
- shutil.copy(h5_path, os.path.join(job_dir, "profiles.h5"))
571
- if csv_path and os.path.isfile(csv_path):
572
- shutil.copy(csv_path, os.path.join(job_dir, "profiles.csv"))
573
-
574
- saved_defattrs: list[str] = []
575
- if defattr_paths:
576
- defattr_dir = os.path.join(job_dir, "defattr")
577
- os.makedirs(defattr_dir, exist_ok=True)
578
- for p in defattr_paths:
579
- dest = os.path.join(defattr_dir, os.path.basename(p))
580
- shutil.copy(p, dest)
581
- saved_defattrs.append(dest)
582
-
583
- meta = {
584
- "group_names": group_names,
585
- "created_at": time.time(),
586
- "names": names,
587
- "stats_rows": stats_rows,
588
- "defattr_files": [os.path.basename(p) for p in saved_defattrs],
589
- "chimerax_md": chimerax_md,
590
- }
591
- with open(os.path.join(job_dir, "meta.json"), "w") as f:
592
- json.dump(meta, f)
593
-
594
- for key in _PLOT_KEYS:
595
- fig = plots.get(key)
596
- if fig is not None:
597
- with open(os.path.join(job_dir, f"{key}.json"), "w") as f:
598
- f.write(fig.to_json())
599
-
600
- return job_id
601
-
602
-
603
- # --- Pipeline orchestration ---
604
-
605
-
606
- def _plot_update(fig: go.Figure | None):
607
- """Wrap a figure in a gr.update that shows/hides the component."""
608
- if fig is None:
609
- return gr.update(visible=False, value=None)
610
- return gr.update(visible=True, value=fig)
611
-
612
-
613
- def _error_yield(msg: str, log_lines: list[str] | None = None) -> tuple:
614
- """Build a yield tuple that displays an error banner and the log."""
615
- r = ResultUpdate.hidden()
616
- r.results_group = gr.update(visible=True)
617
- r.error_banner = gr.update(visible=True, value=f"**Error:** {msg}")
618
- r.log = "\n".join(log_lines) if log_lines else f"Error: {msg}"
619
- return r.to_tuple()
620
-
621
-
622
- def _progress_yield(log_lines: list[str]) -> tuple:
623
- """Build the in-progress yield tuple. Result URL stays hidden until success."""
624
- r = ResultUpdate.hidden()
625
- r.results_group = gr.update(visible=True)
626
- r.log = "\n".join(log_lines)
627
- return r.to_tuple()
628
-
629
-
630
- def run_pipeline(
631
- fasta_file: str,
632
- groups: list[GroupInput],
633
- align_cfg: AlignConfig,
634
- core_cfg: CoreConfig,
635
- norm_cfg: NormConfig,
636
- cif_file: str | None = None,
637
- ):
638
- """Run the full cmuts pipeline for one or more experiment groups.
639
-
640
- All groups share a single alignment and mutation-counting pass.
641
- Normalization is computed from pooled data across all groups so that
642
- reactivity values are directly comparable.
643
- """
644
- if fasta_file is None:
645
- yield _error_yield("Please upload a FASTA file.")
646
- return
647
- if not groups:
648
- yield _error_yield("Please add at least one group with a modified FASTQ file.")
649
- return
650
-
651
- for g in groups:
652
- for path, label in [(g.mod_fastq, f"{g.name} Modified FASTQ"),
653
- (g.nomod_fastq, f"{g.name} Control FASTQ")]:
654
- if path is not None and _file_size_mb(path) > MAX_FASTQ_MB:
655
- yield _error_yield(
656
- f"{label} is {_file_size_mb(path):.0f} MB. "
657
- f"The free tier has limited RAM (16 GB); files over "
658
- f"{MAX_FASTQ_MB} MB may cause out-of-memory errors. "
659
- f"Consider downsampling first."
660
- )
661
- return
662
-
663
- cleanup_old_results()
664
 
665
- workdir = tempfile.mkdtemp(prefix="cmuts_")
666
- outdir = os.path.join(workdir, "outputs")
667
- os.makedirs(outdir)
668
-
669
- job_id = uuid.uuid4().hex[:12]
670
- space_host = os.environ.get("SPACE_HOST", "")
671
- base = f"https://{space_host}" if space_host else ""
672
- result_url = f"{base}/results/{job_id}"
673
 
 
 
 
 
 
 
 
 
 
 
674
  log_lines: list[str] = []
675
-
676
- def log(msg: str) -> None:
677
- log_lines.append(msg)
678
-
679
- def run(cmd: list[str], cwd: str = outdir) -> bool:
680
- log(f"$ {' '.join(cmd)}")
681
- result = subprocess.run(
682
- cmd,
683
- cwd=cwd,
684
- capture_output=True,
685
- text=True,
686
- timeout=PIPELINE_TIMEOUT_SEC,
687
- )
688
- if result.stdout:
689
- log(result.stdout.rstrip())
690
- if result.stderr:
691
- log(result.stderr.rstrip())
692
- if result.returncode != 0:
693
- log(f"Command failed with exit code {result.returncode}")
694
- return False
695
- return True
696
-
697
- try:
698
- fasta_path = os.path.join(workdir, "ref.fasta")
699
- shutil.copy(fasta_file, fasta_path)
700
-
701
- # Copy all FASTQs, prefixed by group name to avoid collisions
702
- fastq_dir = os.path.join(workdir, "fastq")
703
- os.makedirs(fastq_dir)
704
-
705
- group_fastq_info: list[tuple[str, str, str | None]] = []
706
- for g in groups:
707
- prefix = g.name + "__"
708
- mod_basename = prefix + os.path.basename(g.mod_fastq)
709
- mod_stem = _fastq_stem(mod_basename)
710
- shutil.copy(g.mod_fastq, os.path.join(fastq_dir, mod_basename))
711
-
712
- nomod_stem = None
713
- if g.nomod_fastq is not None:
714
- nomod_basename = prefix + os.path.basename(g.nomod_fastq)
715
- nomod_stem = _fastq_stem(nomod_basename)
716
- shutil.copy(g.nomod_fastq, os.path.join(fastq_dir, nomod_basename))
717
-
718
- group_fastq_info.append((g.name, mod_stem, nomod_stem))
719
-
720
- # Step 1: Align all FASTQs together
721
- log("=== Step 1: Aligning reads ===")
722
- yield _progress_yield(log_lines)
723
-
724
- fastq_files = sorted(glob.glob(os.path.join(fastq_dir, "*")))
725
- alignments_dir = os.path.join(outdir, "alignments")
726
- align_cmd = _build_align_cmd(fasta_path, alignments_dir, fastq_files, align_cfg)
727
- if not run(align_cmd, cwd=outdir):
728
- yield _error_yield("Alignment failed. See log for details.", log_lines)
729
- return
730
- yield _progress_yield(log_lines)
731
-
732
- # Step 2: Count mutations for all BAMs
733
- log("\n=== Step 2: Counting mutations ===")
734
- yield _progress_yield(log_lines)
735
-
736
- bam_abs = _check_bam_files(alignments_dir)
737
- bam_files = sorted(os.path.relpath(p, outdir) for p in bam_abs)
738
- counts_h5 = "counts.h5"
739
- core_cmd = _build_core_cmd(fasta_path, counts_h5, bam_files, core_cfg)
740
- if not run(core_cmd, cwd=outdir):
741
- yield _error_yield("Mutation counting failed. See log for details.", log_lines)
742
- return
743
- _check_output_h5(os.path.join(outdir, counts_h5), "cmuts core")
744
- yield _progress_yield(log_lines)
745
-
746
- # Step 3: Normalize reactivities with shared normalization factor
747
- log("\n=== Step 3: Normalizing reactivities ===")
748
- yield _progress_yield(log_lines)
749
-
750
- counts_path = os.path.join(outdir, counts_h5)
751
-
752
- cmuts_groups = [
753
- cmuts.Group(
754
- name=name,
755
- mod=[f"alignments/{mod_stem}"],
756
- nomod=[f"alignments/{nomod_stem}"] if nomod_stem else None,
757
- )
758
- for name, mod_stem, nomod_stem in group_fastq_info
759
- ]
760
-
761
- norm_opts = cmuts.Opts(
762
- cmuts.DataGroups([]),
763
- cmuts.DataGroups(None),
764
- norm_cfg.blank_cutoff,
765
- not norm_cfg.no_insertions,
766
- not norm_cfg.no_deletions,
767
- norm_cfg.norm_method,
768
- (norm_cfg.blank_5p, norm_cfg.blank_3p),
769
- (norm_cfg.clip_low, norm_cfg.clip_high),
770
- norm_cfg.sig,
771
- )
772
-
773
- with h5py.File(counts_path, "r") as f:
774
- results = cmuts.compute_reactivities(
775
- f, fasta_path, cmuts_groups, norm_opts, shared_norm=True,
776
- )
777
-
778
- if len(results) > 1:
779
- log(f" Pooled {norm_cfg.norm_method} normalization across {len(results)} groups.")
780
- log("Normalization complete.")
781
-
782
- # Save multi-group HDF5
783
- final_path = os.path.join(outdir, "profiles.h5")
784
- cmuts.save_groups(final_path, [(r.group.name, r.combined) for r in results])
785
-
786
- # Generate CSV
787
- all_group_names = [r.group.name for r in results]
788
- csv_path = _generate_csv(final_path, fasta_path, all_group_names)
789
- log("Generated CSV output.")
790
-
791
- # Build plots
792
- first = results[0]
793
- first_name = first.group.name
794
-
795
- # Show the nucleotide sequence on the profile x-axis when there's
796
- # exactly one reference (multi-ref runs use a heatmap instead).
797
- fasta_entries = _parse_fasta(fasta_path)
798
- ref_sequence = (
799
- fasta_entries[0][1]
800
- if first.combined.single() and len(fasta_entries) == 1
801
- else None
802
- )
803
-
804
- # Profile: overlay all groups for single-reference data
805
- if len(results) > 1 and first.combined.single():
806
- reactivities = [np.asarray(r.combined.reactivity)[0] for r in results]
807
- profile_fig = plot_profiles(reactivities, all_group_names, sequence=ref_sequence)
808
- else:
809
- profile_fig = plot_examples(
810
- np.asarray(first.combined.reactivity),
811
- np.asarray(first.combined.error),
812
- first_name,
813
- sequence=ref_sequence,
814
- )
815
-
816
- # Diagnostic plots from first group
817
- diag_plots = _build_plots(
818
- first.mod, first.nomod, first.combined, first_name, sequence=ref_sequence,
819
- )
820
- diag_plots["profile"] = profile_fig
821
-
822
- # Sequence names for dropdown (shared across groups)
823
- reactivity, names = _read_profiles(final_path, first_name)
824
- dropdown_update = gr.Dropdown(
825
- choices=names, value=names[0],
826
- visible=(len(names) > 1),
827
- )
828
-
829
- # Stats for all groups
830
- stats_rows = _build_stats_table(final_path, all_group_names)
831
-
832
- # Optional structure visualization: per-group defattrs + ChimeraX commands
833
- defattr_paths: list[str] = []
834
- chimerax_md = ""
835
- if cif_file is not None and ref_sequence is not None:
836
- cif_workdir_path = os.path.join(workdir, os.path.basename(cif_file))
837
- shutil.copy(cif_file, cif_workdir_path)
838
- defattr_dir = os.path.join(outdir, "defattr")
839
- os.makedirs(defattr_dir, exist_ok=True)
840
- defattr_paths, chimerax_md = _build_defattrs(
841
- cif_workdir_path, ref_sequence, results, defattr_dir,
842
- )
843
- if defattr_paths:
844
- log(f"Wrote {len(defattr_paths)} defattr file(s) for ChimeraX.")
845
- elif cif_file is not None:
846
- log(
847
- "Skipping structure visualization: defattr generation requires "
848
- "a single-reference FASTA."
849
- )
850
-
851
- save_results(
852
- final_path, csv_path, all_group_names,
853
- diag_plots, stats_rows, names, job_id=job_id,
854
- defattr_paths=defattr_paths, chimerax_md=chimerax_md,
855
- )
856
-
857
- log(f"\nDone. Generated profiles for {len(results)} group(s).")
858
- log(f"Results available at: {result_url} (expires in {RESULTS_TTL_HOURS}h)")
859
- yield ResultUpdate(
860
- results_group=gr.update(visible=True),
861
- error_banner=gr.update(visible=False, value=""),
862
- result_url=gr.update(visible=True, value=result_url),
863
- output_file=gr.update(visible=True, value=final_path),
864
- csv_file=gr.update(visible=True, value=csv_path),
865
- profile_plot=_plot_update(diag_plots["profile"]),
866
- seq_dropdown=dropdown_update,
867
- stats=gr.update(visible=True, value=stats_rows),
868
- mod_heatmap=_plot_update(diag_plots["mod_heatmap"]),
869
- termination=_plot_update(diag_plots["termination"]),
870
- coverage=_plot_update(diag_plots["coverage"]),
871
- read_hist=_plot_update(diag_plots["read_hist"]),
872
- cumulative_reads=_plot_update(diag_plots["cumulative_reads"]),
873
- snr_scaling=_plot_update(diag_plots["snr_scaling"]),
874
- mi=_plot_update(diag_plots["mi"]),
875
- correlation=_plot_update(diag_plots["correlation"]),
876
- pairwise_coverage=_plot_update(diag_plots["pairwise_coverage"]),
877
- log="\n".join(log_lines),
878
- structure_files=(
879
- gr.update(visible=True, value=defattr_paths)
880
- if defattr_paths else gr.update(visible=False, value=None)
881
- ),
882
- structure_commands=(
883
- gr.update(visible=True, value=chimerax_md)
884
- if chimerax_md else gr.update(visible=False, value="")
885
- ),
886
- ).to_tuple()
887
-
888
- except subprocess.TimeoutExpired:
889
- log(f"Pipeline timed out ({PIPELINE_TIMEOUT_SEC // 60} minute limit).")
890
- yield _error_yield(
891
- f"Pipeline timed out after {PIPELINE_TIMEOUT_SEC // 60} minutes.",
892
- log_lines,
893
- )
894
- return
895
- except Exception as e:
896
- log(f"Error: {e}")
897
- log(traceback.format_exc())
898
- yield _error_yield(str(e) or "Unexpected error. See log for details.", log_lines)
899
- return
900
-
901
-
902
- # --- Gradio callbacks ---
903
-
904
-
905
- def _run_pipeline_gradio(fasta_file, cif_file, visible_count, *args):
906
- """Gradio-facing wrapper. Receives MAX_GROUPS * 3 group values (one
907
- (name, mod, nomod) triple per row) followed by the option components.
908
- Only the first ``visible_count`` rows are considered."""
909
- group_args = args[: MAX_GROUPS * 3]
910
- cfg = args[MAX_GROUPS * 3 :]
911
-
912
- (norm_method, no_insertions, no_deletions, clip_low, clip_high,
913
- trim_5, trim_3, local_align,
914
- min_mapq, min_phred, min_length, max_length, no_mismatches, strand,
915
- blank_5p, blank_3p, blank_cutoff, norm_cutoff, norm_percentile,
916
- compute_pairwise, sig) = cfg
917
-
918
- groups: list[GroupInput] = []
919
- for i in range(int(visible_count or 0)):
920
- name_val = group_args[3 * i]
921
- mod_val = group_args[3 * i + 1]
922
- nomod_val = group_args[3 * i + 2]
923
- if mod_val is None:
924
- continue
925
- gn = _sanitize_group_name(name_val) or f"group_{i + 1}"
926
- groups.append(GroupInput(gn, mod_val, nomod_val))
927
-
928
- yield from run_pipeline(
929
- fasta_file=fasta_file,
930
- groups=groups,
931
- align_cfg=AlignConfig(
932
- trim_5=trim_5 or "",
933
- trim_3=trim_3 or "",
934
- local_align=local_align,
935
- ),
936
- core_cfg=CoreConfig(
937
- min_mapq=int(min_mapq or 10),
938
- min_phred=int(min_phred or 10),
939
- min_length=int(min_length or 2),
940
- max_length=int(max_length or 1024),
941
- no_insertions=no_insertions,
942
- no_mismatches=no_mismatches,
943
- strand=strand or "both",
944
- compute_pairwise=compute_pairwise,
945
- ),
946
- norm_cfg=NormConfig(
947
- norm_method=norm_method or "ubr",
948
- no_insertions=no_insertions,
949
- no_deletions=no_deletions,
950
- clip_low=clip_low,
951
- clip_high=clip_high,
952
- blank_5p=int(blank_5p or 0),
953
- blank_3p=int(blank_3p or 0),
954
- blank_cutoff=int(blank_cutoff or 10),
955
- norm_cutoff=int(norm_cutoff or 500),
956
- norm_percentile=int(norm_percentile or 90),
957
- sig=float(sig or 0.05),
958
- ),
959
- cif_file=cif_file,
960
  )
961
 
962
 
963
- def select_profile(seq_name: str, output_file: str) -> tuple:
964
- """Switch the displayed profile and pairwise plots for the selected sequence."""
965
- if not output_file or not seq_name:
966
- no_change = gr.update()
967
- return no_change, no_change, no_change, no_change
968
-
969
- with h5py.File(output_file, "r") as f:
970
- group_names = sorted(k for k in f.keys() if k != "sequence")
971
- if not group_names:
972
- no_change = gr.update()
973
- return no_change, no_change, no_change, no_change
974
-
975
- first_grp = f[group_names[0]]
976
- reactivity = np.array(first_grp["reactivity"])
977
-
978
- # The dropdown is hidden for single-reference runs; an auto-fired
979
- # change here would clobber the initial plot (which already has the
980
- # FASTA-derived sequence axis), so leave the existing plot alone.
981
- if reactivity.shape[0] == 1:
982
- no_change = gr.update()
983
- return no_change, no_change, no_change, no_change
984
-
985
- sequences = None
986
- if "sequence" in f:
987
- sequences = [
988
- s.decode() if isinstance(s, bytes) else s for s in f["sequence"]
989
- ]
990
-
991
- names = _build_seq_names(reactivity.shape[0], sequences)
992
-
993
- try:
994
- idx = names.index(seq_name)
995
- except ValueError:
996
- idx = 0
997
-
998
- ref_sequence = sequences[idx] if sequences and idx < len(sequences) else None
999
-
1000
- if len(group_names) > 1:
1001
- reactivities = [np.array(f[gn]["reactivity"])[idx] for gn in group_names]
1002
- profile_fig = plot_profiles(reactivities, group_names, sequence=ref_sequence)
1003
- else:
1004
- error = np.array(first_grp["error"])
1005
- profile_fig = plot_profile(reactivity[idx], error[idx], names[idx], sequence=ref_sequence)
1006
-
1007
- mi_fig = None
1008
- corr_fig = None
1009
- if "mutual-information" in first_grp:
1010
- mi_fig = plot_mi(np.array(first_grp["mutual-information"])[idx], names[idx])
1011
- if "covariance" in first_grp:
1012
- corr_fig = plot_correlation(np.array(first_grp["covariance"])[idx], names[idx])
1013
 
1014
- # Leave the pairwise plot alone — switching sequences shouldn't hide a
1015
- # plot that may already be visible from the initial run.
1016
- return profile_fig, _plot_update(mi_fig), _plot_update(corr_fig), gr.update()
1017
 
 
 
 
1018
 
1019
- def load_example():
1020
- """Load bundled example files into row 0; clear and hide rows 1..MAX_GROUPS-1.
1021
 
1022
- Returns a tuple consumed by the example button's outputs list, see
1023
- ``_example_outputs`` in the UI section.
1024
- """
1025
  fasta = None
1026
  treated = None
1027
  untreated = None
1028
-
1029
- entries = sorted(os.listdir(EXAMPLES_DIR))
1030
- fastq_paths: list[str] = []
1031
- for f in entries:
1032
  path = os.path.join(EXAMPLES_DIR, f)
1033
  lower = f.lower()
1034
  if lower.endswith((".fasta", ".fa")):
1035
  fasta = path
1036
- elif lower.endswith(_FASTQ_SUFFIXES):
1037
- fastq_paths.append(path)
1038
-
1039
- # First pass: pick control by name keywords.
1040
- remaining: list[str] = []
1041
- for p in fastq_paths:
1042
- lower = os.path.basename(p).lower()
1043
- if untreated is None and (
1044
- "untreated" in lower or "nomod" in lower or "control" in lower
1045
- ):
1046
- untreated = p
1047
- else:
1048
- remaining.append(p)
1049
- # Second pass: whatever is left is treated.
1050
- if remaining:
1051
- treated = remaining[0]
1052
-
1053
- group_file = os.path.join(EXAMPLES_DIR, "group.txt")
1054
- if os.path.isfile(group_file):
1055
- with open(group_file) as f:
1056
- group_name = f.read().strip() or "example"
1057
- else:
1058
- group_name = "example"
1059
-
1060
- # Row 0 gets the example values; rows 1..MAX_GROUPS-1 are cleared and hidden.
1061
- row_values: list = [group_name, treated, untreated]
1062
- for _ in range(MAX_GROUPS - 1):
1063
- row_values.extend(["", None, None])
1064
- row_visibilities = [gr.update(visible=(i == 0)) for i in range(MAX_GROUPS)]
1065
- add_btn_update = gr.update(interactive=True)
1066
- remove_btn_update = gr.update(interactive=False)
1067
-
1068
- return (
1069
- fasta,
1070
- *row_values,
1071
- 1, # visible_count
1072
- *row_visibilities,
1073
- add_btn_update,
1074
- remove_btn_update,
1075
- )
1076
 
1077
-
1078
- def load_saved_result(job_id: str):
1079
- """Load a previously saved result by job ID."""
1080
- job_id = (job_id or "").strip()
1081
- if not job_id:
1082
- return ResultUpdate.hidden().to_tuple()
1083
-
1084
- job_dir = os.path.join(RESULTS_DIR, job_id)
1085
- meta_path = os.path.join(job_dir, "meta.json")
1086
-
1087
- if not os.path.isdir(job_dir):
1088
- r = ResultUpdate.hidden()
1089
- r.load_status = (
1090
- f"Result not found. It may have expired "
1091
- f"(results are kept for {RESULTS_TTL_HOURS} hours)."
1092
- )
1093
- return r.to_tuple()
1094
-
1095
- with open(meta_path) as f:
1096
- meta = json.load(f)
1097
-
1098
- loaded: dict[str, go.Figure | None] = {}
1099
- for key in _PLOT_KEYS:
1100
- path = os.path.join(job_dir, f"{key}.json")
1101
- if os.path.isfile(path):
1102
- with open(path) as f:
1103
- loaded[key] = go.Figure(json.load(f))
1104
- else:
1105
- loaded[key] = None
1106
-
1107
- # Handle both old (group_name) and new (group_names) meta formats
1108
- group_names = meta.get("group_names")
1109
- if group_names is None:
1110
- group_names = [meta.get("group_name", DEFAULT_GROUP_NAME)]
1111
- names = meta.get("names", [])
1112
-
1113
- space_host = os.environ.get("SPACE_HOST", "")
1114
- base = f"https://{space_host}" if space_host else ""
1115
-
1116
- h5_path = os.path.join(job_dir, "profiles.h5")
1117
- csv_path = os.path.join(job_dir, "profiles.csv")
1118
- hidden = gr.update(visible=False, value=None)
1119
-
1120
- defattr_files = meta.get("defattr_files", [])
1121
- defattr_paths = [
1122
- os.path.join(job_dir, "defattr", name)
1123
- for name in defattr_files
1124
- if os.path.isfile(os.path.join(job_dir, "defattr", name))
1125
- ]
1126
- chimerax_md = meta.get("chimerax_md", "")
1127
-
1128
- return ResultUpdate(
1129
- results_group=gr.update(visible=True),
1130
- error_banner=gr.update(visible=False, value=""),
1131
- result_url=gr.update(visible=True, value=f"{base}/results/{job_id}"),
1132
- output_file=gr.update(visible=True, value=h5_path) if os.path.isfile(h5_path) else hidden,
1133
- csv_file=gr.update(visible=True, value=csv_path) if os.path.isfile(csv_path) else hidden,
1134
- profile_plot=_plot_update(loaded.get("profile")),
1135
- seq_dropdown=gr.Dropdown(choices=names, value=names[0] if names else None, visible=len(names) > 1),
1136
- stats=gr.update(visible=True, value=meta.get("stats_rows", [])),
1137
- mod_heatmap=_plot_update(loaded.get("mod_heatmap")),
1138
- termination=_plot_update(loaded.get("termination")),
1139
- coverage=_plot_update(loaded.get("coverage")),
1140
- read_hist=_plot_update(loaded.get("read_hist")),
1141
- cumulative_reads=_plot_update(loaded.get("cumulative_reads")),
1142
- snr_scaling=_plot_update(loaded.get("snr_scaling")),
1143
- mi=_plot_update(loaded.get("mi")),
1144
- correlation=_plot_update(loaded.get("correlation")),
1145
- pairwise_coverage=_plot_update(loaded.get("pairwise_coverage")),
1146
- structure_files=(
1147
- gr.update(visible=True, value=defattr_paths) if defattr_paths else hidden
1148
- ),
1149
- structure_commands=(
1150
- gr.update(visible=True, value=chimerax_md) if chimerax_md else hidden
1151
- ),
1152
- ).to_tuple()
1153
-
1154
-
1155
- # --- Gradio UI ---
1156
-
1157
- with gr.Blocks(title="cmuts — RNA Chemical Probing Analysis") as demo:
1158
- gr.Markdown(
1159
- """
1160
- # cmuts — RNA Chemical Probing Analysis
1161
-
1162
- Upload a FASTA reference and FASTQ file(s) from a MaP-seq experiment
1163
- to compute normalized reactivity profiles. Use **+ Add group** to
1164
- compare multiple conditions (e.g. with/without ligand) — normalization
1165
- is applied across all groups so values are directly comparable.
1166
-
1167
- **Pipeline:** `cmuts align` &rarr; `cmuts core` &rarr; `cmuts normalize`
1168
- &ensp;|&ensp;
1169
- [GitHub](https://github.com/hmblair/cmuts)
1170
- &ensp;|&ensp;
1171
- [Documentation](https://hmblair.github.io/cmuts)
1172
- &ensp;|&ensp;
1173
- Free and open-source under the
1174
- [MIT License](https://github.com/hmblair/cmuts/blob/main/LICENSE)
1175
- """
1176
- )
1177
-
1178
- with gr.Tab("Run"):
1179
- with gr.Column() as input_section:
1180
- gr.Markdown("### Input data")
1181
- fasta_input = gr.File(label="Reference FASTA", file_types=[".fasta", ".fa"])
1182
- cif_input = gr.File(
1183
- label=(
1184
- "Reference structure CIF (optional) — produces a "
1185
- ".defattr per group plus a ChimeraX command for local "
1186
- "visualization. Single-reference FASTAs only."
1187
- ),
1188
- file_types=[".cif"],
1189
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1190
 
1191
- # Pre-create MAX_GROUPS static rows. Each row is toggled by
1192
- # visibility on a shared ``visible_count`` state. Components are
1193
- # never recreated, so values persist across Add/Remove and are
1194
- # editable after Load Example without re-render quirks.
1195
- group_rows: list = []
1196
- group_names: list = []
1197
- group_mods: list = []
1198
- group_nomods: list = []
1199
- group_removes: list = []
1200
- for i in range(MAX_GROUPS):
1201
- with gr.Row(visible=(i == 0)) as _row:
1202
- gn = gr.Textbox(
1203
- label=f"Group {i + 1} name",
1204
- placeholder="e.g. 2A3_with_cdiGMP",
1205
- scale=2,
1206
- )
1207
- mod = gr.File(
1208
- label=f"Group {i + 1} Modified FASTQ (required)",
1209
- file_types=[".fastq", ".fq", ".gz"],
1210
- scale=3,
1211
- )
1212
- nomod = gr.File(
1213
- label=f"Group {i + 1} Control FASTQ (optional)",
1214
- file_types=[".fastq", ".fq", ".gz"],
1215
- scale=3,
1216
- )
1217
- rm = gr.Button("Remove", size="sm", scale=1)
1218
- group_rows.append(_row)
1219
- group_names.append(gn)
1220
- group_mods.append(mod)
1221
- group_nomods.append(nomod)
1222
- group_removes.append(rm)
1223
-
1224
- visible_count = gr.State(1)
1225
-
1226
- with gr.Row():
1227
- add_group_btn = gr.Button("+ Add group", variant="secondary", size="sm")
1228
- remove_group_btn = gr.Button(
1229
- "- Remove last group", variant="secondary", size="sm",
1230
- interactive=False,
1231
- )
1232
- example_btn = gr.Button("Load example data", variant="secondary", size="sm")
1233
-
1234
- gr.Markdown("### Options")
1235
- with gr.Accordion("Alignment", open=False):
1236
- gr.Markdown(
1237
- "*If no adapter sequences are provided, cmuts will attempt to "
1238
- "recognize adapters outside the reference sequence and auto-trim.*"
1239
- )
1240
- with gr.Row():
1241
- trim_5 = gr.Textbox(label="5' adapter to trim (optional)", placeholder="auto-detected if blank")
1242
- trim_3 = gr.Textbox(label="3' adapter to trim (optional)", placeholder="auto-detected if blank")
1243
- local_align = gr.Checkbox(label="Local alignment", value=False)
1244
-
1245
- with gr.Accordion("Read filtering", open=False):
1246
- with gr.Row():
1247
- min_mapq = gr.Slider(minimum=0, maximum=60, step=1, value=10, label="Min mapping quality")
1248
- min_phred = gr.Slider(minimum=0, maximum=40, step=1, value=10, label="Min PHRED score")
1249
- with gr.Row():
1250
- min_length = gr.Number(value=2, label="Min read length", precision=0)
1251
- max_length = gr.Number(value=1024, label="Max read length", precision=0)
1252
- with gr.Row():
1253
- no_mismatches = gr.Checkbox(label="Exclude mismatches", value=False)
1254
- strand = gr.Radio(choices=["both", "forward", "reverse"], value="both", label="Strand")
1255
-
1256
- with gr.Accordion("Normalization", open=False):
1257
- norm_method = gr.Radio(
1258
- choices=["ubr", "outlier", "raw"],
1259
- value="ubr",
1260
- label="Normalization method",
1261
- )
1262
- with gr.Row():
1263
- no_insertions = gr.Checkbox(label="Exclude insertions", value=True)
1264
- no_deletions = gr.Checkbox(label="Exclude deletions", value=False)
1265
- with gr.Row():
1266
- clip_low = gr.Checkbox(label="Clip negative reactivities", value=False)
1267
- clip_high = gr.Checkbox(label="Clip reactivities above 1", value=False)
1268
- with gr.Row():
1269
- blank_5p = gr.Number(value=0, label="Blank 5' bases", precision=0)
1270
- blank_3p = gr.Number(value=0, label="Blank 3' bases", precision=0)
1271
- blank_cutoff = gr.Number(value=10, label="Min reads for position", precision=0)
1272
- with gr.Row():
1273
- norm_cutoff = gr.Number(value=500, label="Min reads for normalization", precision=0)
1274
- norm_percentile = gr.Slider(minimum=50, maximum=100, step=1, value=90, label="Normalization percentile")
1275
-
1276
- with gr.Accordion("Pairwise analysis", open=False):
1277
- gr.Markdown(
1278
- "Compute pairwise modification correlations (mutual information "
1279
- "and Pearson correlation). Cost is O(L&sup2;) in sequence length, "
1280
- "so this is slow for long references."
1281
- )
1282
- compute_pairwise = gr.Checkbox(label="Compute pairwise correlations", value=False)
1283
- sig = gr.Slider(
1284
- minimum=0.001, maximum=0.1, step=0.001, value=0.05,
1285
- label="Significance threshold (Bonferroni-corrected)",
1286
- )
1287
-
1288
- run_btn = gr.Button("Run Pipeline", variant="primary")
1289
 
1290
- with gr.Group(visible=False) as results_group:
1291
- gr.Markdown("### Results")
1292
- error_banner = gr.Markdown(value="", visible=False)
1293
- result_url = gr.Textbox(
1294
- label=f"Result link (bookmark this — expires in {RESULTS_TTL_HOURS}h)",
1295
- interactive=False,
1296
- visible=False,
1297
- )
1298
- with gr.Accordion("Log", open=False):
1299
- output_log = gr.Textbox(label="Log", lines=15, max_lines=30, show_label=False)
1300
- output_file = gr.File(label="Output HDF5", visible=False)
1301
- csv_file = gr.File(label="Output CSV", visible=False)
1302
- seq_dropdown = gr.Dropdown(label="Sequence", visible=False, interactive=True)
1303
- output_plot = gr.Plot(label="Reactivity Profile", visible=False)
1304
- mod_heatmap_plot = gr.Plot(label="Modification Heatmap", visible=False)
1305
- with gr.Row():
1306
- termination_plot = gr.Plot(label="Termination by Position", visible=False)
1307
- coverage_plot = gr.Plot(label="Coverage by Position", visible=False)
1308
- with gr.Row():
1309
- read_hist_plot = gr.Plot(label="Read Depth Distribution", visible=False)
1310
- cumulative_reads_plot = gr.Plot(label="Cumulative Reads", visible=False)
1311
- snr_scaling_plot = gr.Plot(label="SNR vs Read Depth", visible=False)
1312
- with gr.Row():
1313
- mi_plot = gr.Plot(label="Mutual Information", visible=False)
1314
- correlation_plot = gr.Plot(label="Correlation", visible=False)
1315
- pairwise_coverage_plot = gr.Plot(label="Pairwise Coverage", visible=False)
1316
- output_stats = gr.Dataframe(
1317
- label="Summary Statistics",
1318
- headers=["Statistic", "Value"],
1319
- interactive=False,
1320
- visible=False,
1321
  )
1322
- structure_files = gr.Files(label="ChimeraX defattr files", visible=False)
1323
- structure_commands = gr.Markdown(visible=False)
1324
-
1325
- with gr.Accordion("Load previous results", open=False):
1326
- with gr.Row():
1327
- prev_job_id = gr.Textbox(label="Job ID", placeholder="e.g. a3f2b1c4d5e6", scale=3)
1328
- load_btn = gr.Button("Load", variant="secondary", scale=1)
1329
- load_status = gr.Textbox(label="Status", interactive=False)
1330
-
1331
- # Flat list of all per-row value components, ordered row by row.
1332
- group_value_components: list = []
1333
- for i in range(MAX_GROUPS):
1334
- group_value_components.extend([group_names[i], group_mods[i], group_nomods[i]])
1335
-
1336
- # Standard outputs tuple for the group state buttons (Add / Remove
1337
- # last / per-row Remove). Order: visible_count, all values, all rows,
1338
- # add button, remove button.
1339
- _groups_state_outputs = (
1340
- [visible_count] + group_value_components
1341
- + group_rows + [add_group_btn, remove_group_btn]
1342
- )
1343
-
1344
- def _groups_state(vc: int, values) -> tuple:
1345
- flat = list(values)
1346
- rows_vis = [gr.update(visible=(k < vc)) for k in range(MAX_GROUPS)]
1347
- add_int = gr.update(interactive=(vc < MAX_GROUPS))
1348
- rem_int = gr.update(interactive=(vc > 1))
1349
- return (vc, *flat, *rows_vis, add_int, rem_int)
1350
-
1351
- def _add_group_handler(vc, *vals):
1352
- new_vc = min(int(vc or 1) + 1, MAX_GROUPS)
1353
- return _groups_state(new_vc, vals)
1354
-
1355
- def _remove_last_handler(vc, *vals):
1356
- cur = int(vc or 1)
1357
- new_vc = max(cur - 1, 1)
1358
- if new_vc == cur:
1359
- return _groups_state(cur, vals)
1360
- triples = [list(vals[3 * k : 3 * k + 3]) for k in range(MAX_GROUPS)]
1361
- triples[new_vc] = ["", None, None]
1362
- flat = [v for t in triples for v in t]
1363
- return _groups_state(new_vc, flat)
1364
-
1365
- def _make_remove_at(idx: int):
1366
- def fn(vc, *vals):
1367
- cur = int(vc or 1)
1368
- triples = [list(vals[3 * k : 3 * k + 3]) for k in range(MAX_GROUPS)]
1369
- if idx >= cur:
1370
- return _groups_state(cur, vals)
1371
- if cur <= 1:
1372
- triples[0] = ["", None, None]
1373
- flat = [v for t in triples for v in t]
1374
- return _groups_state(1, flat)
1375
- for k in range(idx, cur - 1):
1376
- triples[k] = triples[k + 1]
1377
- triples[cur - 1] = ["", None, None]
1378
- flat = [v for t in triples for v in t]
1379
- return _groups_state(cur - 1, flat)
1380
- return fn
1381
-
1382
- add_group_btn.click(
1383
- _add_group_handler,
1384
- inputs=[visible_count] + group_value_components,
1385
- outputs=_groups_state_outputs,
1386
- )
1387
- remove_group_btn.click(
1388
- _remove_last_handler,
1389
- inputs=[visible_count] + group_value_components,
1390
- outputs=_groups_state_outputs,
1391
- )
1392
- for i in range(MAX_GROUPS):
1393
- group_removes[i].click(
1394
- _make_remove_at(i),
1395
- inputs=[visible_count] + group_value_components,
1396
- outputs=_groups_state_outputs,
1397
  )
 
 
 
 
 
 
 
 
1398
 
1399
- _example_outputs = (
1400
- [fasta_input] + group_value_components
1401
- + [visible_count] + group_rows
1402
- + [add_group_btn, remove_group_btn]
1403
- )
1404
- example_btn.click(
1405
- fn=load_example,
1406
- inputs=None,
1407
- outputs=_example_outputs,
1408
- )
1409
-
1410
- # Outputs list matching ResultUpdate.to_tuple() field order.
1411
- _result_outputs = [
1412
- results_group, error_banner,
1413
- result_url, output_file, csv_file,
1414
- output_plot, seq_dropdown,
1415
- output_stats, load_status,
1416
- mod_heatmap_plot, termination_plot, coverage_plot,
1417
- read_hist_plot, cumulative_reads_plot, snr_scaling_plot,
1418
- mi_plot, correlation_plot, pairwise_coverage_plot,
1419
- output_log,
1420
- structure_files, structure_commands,
1421
- ]
1422
-
1423
- run_inputs: list = [fasta_input, cif_input, visible_count]
1424
- run_inputs.extend(group_value_components)
1425
- run_inputs.extend([
1426
- norm_method, no_insertions, no_deletions, clip_low, clip_high,
1427
- trim_5, trim_3, local_align,
1428
- min_mapq, min_phred, min_length, max_length, no_mismatches, strand,
1429
- blank_5p, blank_3p, blank_cutoff, norm_cutoff, norm_percentile,
1430
- compute_pairwise, sig,
1431
- ])
1432
- run_btn.click(
1433
- fn=_run_pipeline_gradio,
1434
- inputs=run_inputs,
1435
- outputs=_result_outputs,
1436
- )
1437
 
1438
- seq_dropdown.change(
1439
- fn=select_profile,
1440
- inputs=[seq_dropdown, output_file],
1441
- outputs=[output_plot, mi_plot, correlation_plot, pairwise_coverage_plot],
1442
- )
1443
 
1444
- load_btn.click(
1445
- fn=load_saved_result,
1446
- inputs=[prev_job_id],
1447
- outputs=_result_outputs,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1448
  )
1449
-
1450
- _load_outputs = [input_section] + _result_outputs
1451
-
1452
- def _load_from_query(request: gr.Request):
1453
- """Auto-load results when ?job_id= is present in the URL."""
1454
- job_id = (request.query_params.get("job_id") or "").strip()
1455
- if not job_id:
1456
- return [gr.update()] * len(_load_outputs)
1457
- result = load_saved_result(job_id)
1458
- return (gr.update(visible=False),) + result
1459
-
1460
- demo.load(
1461
- fn=_load_from_query,
1462
- outputs=_load_outputs,
 
 
 
1463
  )
1464
 
1465
- with gr.Tab("About"):
1466
- gr.Markdown(
1467
- """
1468
- ## Why cmuts?
1469
-
1470
- Existing tools for analyzing MaP-seq data — such as ShapeMapper2 and
1471
- RNAframework — were designed for single-RNA experiments and do not
1472
- scale to modern high-throughput libraries with thousands or millions
1473
- of reference sequences.
1474
-
1475
- **cmuts** is a ground-up rewrite in C/C++ that addresses these
1476
- limitations:
1477
-
1478
- - **100-200x faster** than ShapeMapper2 and RNAframework. A dataset
1479
- of 100 billion aligned reads across 24 million references was
1480
- processed in under 24 hours on 32 cores — a task that would take
1481
- RNAframework approximately 3 months.
1482
- - **Constant memory footprint** regardless of library size, thanks to
1483
- streamed single-pass I/O. Competing tools either grow linearly in
1484
- memory or require processing one reference at a time.
1485
- - **More accurate deletion handling.** cmuts uses a depth-first
1486
- search algorithm to enumerate all possible positions of ambiguous
1487
- deletions and weights them probabilistically using observed mutation
1488
- rates. Prior tools arbitrarily assign deletions to the 3'-most
1489
- position, which can misplace reactivity signals — particularly in
1490
- homopolymer regions and structurally important motifs like
1491
- kink-turns.
1492
- - **HDF5 output** for compact storage, fast random access, and direct
1493
- compatibility with Python and machine-learning pipelines.
1494
- """
1495
- )
1496
 
1497
- with gr.Tab("Help"):
1498
- gr.Markdown(
1499
- f"""
1500
- ## Quick Start
1501
-
1502
- 1. Click **Load example data** on the Run tab to populate the inputs
1503
- with a bundled dataset.
1504
- 2. Leave the default settings and click **Run Pipeline**.
1505
- 3. The pipeline runs three steps — alignment, mutation counting, and
1506
- normalization and streams its progress to the log.
1507
- 4. When finished, download the output HDF5 or CSV file and explore
1508
- the interactive reactivity profile. If multiple reference
1509
- sequences are present, use the dropdown to switch between them.
1510
-
1511
- ## Inputs
1512
-
1513
- | Field | Description |
1514
- |-------|-------------|
1515
- | **Reference FASTA** | One or more RNA sequences in FASTA format. Each sequence is treated as a separate reference for alignment. |
1516
- | **Modified FASTQ** | Reads from the chemically treated condition (e.g., DMS, 2A3, SHAPE). Compressed `.fastq.gz` is accepted. |
1517
- | **Control FASTQ** | *(Optional)* Reads from the untreated/DMSO condition. Providing a control enables background subtraction during normalization. |
1518
- | **Group name** | A label for the experiment group, used in the output files. |
1519
-
1520
- ### Multiple groups
1521
-
1522
- Use **+ Add group** to compare multiple experimental conditions
1523
- (e.g. with/without a ligand). Each group has its own modified and
1524
- control FASTQ files. All groups share the same reference FASTA.
1525
-
1526
- Normalization is applied **across all groups** so that reactivity
1527
- values are directly comparable. The profile plot overlays all
1528
- groups for easy visual comparison.
 
 
 
 
 
 
 
 
1529
 
1530
- ## Adapter trimming
1531
 
1532
- If no adapter sequences are provided in the Alignment settings,
1533
- cmuts will attempt to recognize adapter sequences outside the
1534
- reference and auto-trim them.
1535
 
1536
- ## Settings
1537
 
1538
- | Setting | Description |
1539
- |---------|-------------|
1540
- | **Normalization method** | `ubr` (default): divides by the 90th percentile of reactivities at positions with >100 reads, giving a robust upper-bound reference. `outlier`: from the top 10% of reactivities, discards the top 2% as outliers and divides by the mean of the remaining 8%. `raw`: no normalization — returns raw mutation rates. |
1541
- | **Exclude insertions** | Do not count inserted bases as mutations (recommended for most protocols). |
1542
- | **Exclude deletions** | Do not count deleted bases as mutations. |
1543
- | **Clip negative reactivities** | Set negative normalized reactivities to zero. |
1544
- | **Clip reactivities above 1** | Cap normalized reactivities at 1.0. |
 
 
1545
 
1546
- ## Interpreting the Output
1547
 
1548
- ### Reactivity profile
 
 
 
 
 
 
 
 
1549
 
1550
- The interactive chart shows per-nucleotide reactivity values.
1551
- Hover over any position to see the exact position, nucleotide
1552
- identity, and reactivity value. Peaks correspond to unpaired or
1553
- flexible nucleotides; low/near-zero regions correspond to
1554
- base-paired or otherwise protected positions.
1555
 
1556
- When multiple groups are present, the profile overlays all groups
1557
- for direct comparison.
 
 
 
 
 
1558
 
1559
- ### Output files
1560
 
1561
- **HDF5** (`profiles.h5`) contains normalized per-nucleotide
1562
- reactivity profiles organized by group. Open it in Python with:
 
 
 
 
 
 
 
1563
 
1564
- ```python
1565
- import h5py
1566
 
1567
- with h5py.File("profiles.h5") as f:
1568
- for group in f:
1569
- if group == "sequence":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1570
  continue
1571
- reactivities = f[group]["reactivity"][:]
1572
- print(group, reactivities.shape)
1573
- ```
1574
-
1575
- **CSV** (`profiles.csv`) contains the same data in a flat table
1576
- with columns: Position, Nucleotide, and for each group:
1577
- group_name, group_name_error. This is convenient for spreadsheet
1578
- analysis or cross-checking with other tools.
1579
-
1580
- ### Result link
1581
-
1582
- After the pipeline completes, a **result link** is displayed that
1583
- you can bookmark or share. Opening the link loads the results
1584
- directly into the app with the interactive plots, summary
1585
- statistics, and file downloads. Results are stored for
1586
- **{RESULTS_TTL_HOURS} hours** and automatically deleted after that.
1587
-
1588
- To reload previous results manually, expand
1589
- **Load previous results** on the Run tab and enter the job ID.
1590
-
1591
- ## Limits
1592
-
1593
- This server has limited resources (16 GB RAM, CPU only). Very
1594
- large FASTQ files may cause out-of-memory errors. For larger
1595
- datasets, install cmuts locally:
1596
-
1597
- ```bash
1598
- pip install cmuts
1599
- ```
1600
-
1601
- See the [full documentation](https://hmblair.github.io/cmuts) for
1602
- CLI usage and advanced options.
1603
-
1604
- ## Privacy
1605
-
1606
- All uploaded data is processed in ephemeral temporary directories.
1607
- Pipeline results (reactivity profiles, plots, and summary
1608
- statistics) are stored for **{RESULTS_TTL_HOURS} hours** to provide
1609
- bookmarkable result links, then automatically deleted. No user
1610
- accounts, tracking, or cookies are used.
1611
- """
1612
- )
1613
-
1614
-
1615
- # --- FastAPI routes ---
1616
-
1617
- app = FastAPI()
1618
-
1619
-
1620
- @app.get("/results/{job_id}")
1621
- async def results_page(job_id: str):
1622
- """Redirect to the Gradio app with the job ID as a query parameter."""
1623
- return RedirectResponse(url=f"/?job_id={job_id}")
1624
-
1625
-
1626
- @app.get("/results/{job_id}/download")
1627
- async def results_download(job_id: str):
1628
- h5_path = os.path.join(RESULTS_DIR, job_id, "profiles.h5")
1629
- if not os.path.isfile(h5_path):
1630
- return HTMLResponse(
1631
- "<h1>File not found</h1><p>This result may have expired.</p>",
1632
- status_code=404,
1633
- )
1634
-
1635
- with open(os.path.join(RESULTS_DIR, job_id, "meta.json")) as f:
1636
- meta = json.load(f)
1637
- group_names = meta.get("group_names", [meta.get("group_name", "profiles")])
1638
- filename = "-".join(group_names) + "-profiles.h5"
1639
-
1640
- return FileResponse(
1641
- h5_path,
1642
- media_type="application/x-hdf5",
1643
- filename=filename,
1644
- )
1645
-
1646
-
1647
- @app.get("/results/{job_id}/download/csv")
1648
- async def results_download_csv(job_id: str):
1649
- csv_path = os.path.join(RESULTS_DIR, job_id, "profiles.csv")
1650
- if not os.path.isfile(csv_path):
1651
- return HTMLResponse(
1652
- "<h1>File not found</h1><p>CSV not available for this result.</p>",
1653
- status_code=404,
1654
- )
1655
-
1656
- with open(os.path.join(RESULTS_DIR, job_id, "meta.json")) as f:
1657
- meta = json.load(f)
1658
- group_names = meta.get("group_names", [meta.get("group_name", "profiles")])
1659
- filename = "-".join(group_names) + "-profiles.csv"
1660
 
1661
- return FileResponse(
1662
- csv_path,
1663
- media_type="text/csv",
1664
- filename=filename,
1665
- )
1666
 
1667
 
1668
- # Mount Gradio onto the FastAPI app
1669
- app = gr.mount_gradio_app(app, demo, path="")
1670
 
1671
- # Run cleanup on startup
1672
- cleanup_old_results()
1673
 
1674
  if __name__ == "__main__":
1675
  import uvicorn
1676
- uvicorn.run(app, host="0.0.0.0", port=7860)
 
 
 
 
 
 
 
1
+ """FastAPI app for the cmuts web UI.
2
+
3
+ The HTTP layer; pipeline logic lives in ``pipeline.py``.
4
+ """
5
 
6
  from __future__ import annotations
7
 
8
+ import asyncio
9
+ import io
10
  import json
11
  import os
 
12
  import shutil
 
 
13
  import time
 
14
  import uuid
15
+ import zipfile
16
+ from typing import Annotated
17
+
18
+ from fastapi import BackgroundTasks, FastAPI, Form, HTTPException, Request, UploadFile
19
+ from fastapi.responses import (
20
+ FileResponse,
21
+ HTMLResponse,
22
+ JSONResponse,
23
+ PlainTextResponse,
24
+ RedirectResponse,
25
+ StreamingResponse,
26
+ )
27
+ from fastapi.staticfiles import StaticFiles
28
+ from fastapi.templating import Jinja2Templates
29
+
30
+ from pipeline import (
31
+ AlignConfig,
32
+ CoreConfig,
33
+ GroupInput,
34
+ JobState,
35
+ MAX_FASTQ_MB,
36
+ MAX_GROUPS,
37
+ NormConfig,
38
+ PLOT_KEYS,
39
+ RESULTS_DIR,
40
+ RESULTS_TTL_HOURS,
41
+ build_perref_plot,
42
+ build_profile_plot,
43
+ cleanup_old_results,
44
+ file_size_mb,
45
+ job_dir_for,
46
+ read_meta,
47
+ run_pipeline,
48
+ sanitize_group_name,
49
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
+ # --- App setup ---
53
 
54
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
55
+ TEMPLATES_DIR = os.path.join(BASE_DIR, "templates")
56
+ STATIC_DIR = os.path.join(BASE_DIR, "static")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
+ app = FastAPI(title="cmuts")
59
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
60
+ templates = Jinja2Templates(directory=TEMPLATES_DIR)
61
 
 
 
 
 
 
 
 
62
 
63
+ # In-memory state for jobs currently running in this process. Once a job
64
+ # completes, its results live on disk under RESULTS_DIR; this dict is
65
+ # pruned. A job that's missing from this dict but present on disk is
66
+ # treated as complete (status read from meta.json).
67
+ JOB_STATES: dict[str, JobState] = {}
68
+ _STATES_LOCK = asyncio.Lock()
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
+ @app.on_event("startup")
72
+ def _startup() -> None:
73
+ cleanup_old_results()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
 
75
 
76
+ # --- Helpers ---
 
 
 
77
 
 
 
 
 
78
 
79
+ def _save_upload(upload: UploadFile, dest_dir: str, filename: str) -> str:
80
+ """Save an uploaded file under ``dest_dir`` with the given filename and
81
+ return its absolute path."""
82
+ os.makedirs(dest_dir, exist_ok=True)
83
+ out = os.path.join(dest_dir, filename)
84
+ with open(out, "wb") as f:
85
+ shutil.copyfileobj(upload.file, f)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  return out
87
 
88
 
89
+ def _is_real_upload(upload: UploadFile | None) -> bool:
90
+ if upload is None:
91
+ return False
92
+ name = (upload.filename or "").strip()
93
+ return bool(name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
 
 
 
 
 
 
 
 
95
 
96
+ def _job_status(job_id: str) -> tuple[str, list[str], str | None]:
97
+ """Return (status, log_lines, error) for a job. Reads from in-memory
98
+ state if the job is running here; otherwise from disk."""
99
+ state = JOB_STATES.get(job_id)
100
+ if state is not None:
101
+ return state.status, list(state.log_lines), state.error
102
+ meta = read_meta(job_dir_for(job_id))
103
+ if meta is None:
104
+ return "missing", [], None
105
+ log_path = os.path.join(job_dir_for(job_id), "log.txt")
106
  log_lines: list[str] = []
107
+ if os.path.isfile(log_path):
108
+ with open(log_path) as f:
109
+ log_lines = f.read().splitlines()
110
+ if meta.get("status") == "error":
111
+ return "error", log_lines, meta.get("error")
112
+ return "done", log_lines, None
113
+
114
+
115
+ # --- Routes: form + dynamic rows ---
116
+
117
+
118
+ @app.get("/", response_class=HTMLResponse)
119
+ def index(request: Request) -> HTMLResponse:
120
+ return templates.TemplateResponse(
121
+ request,
122
+ "index.html",
123
+ {
124
+ "max_groups": MAX_GROUPS,
125
+ "ttl_hours": RESULTS_TTL_HOURS,
126
+ "max_fastq_mb": MAX_FASTQ_MB,
127
+ },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  )
129
 
130
 
131
+ @app.get("/group-row", response_class=HTMLResponse)
132
+ def group_row(request: Request) -> HTMLResponse:
133
+ """HTMX partial: returns one new empty group row."""
134
+ return templates.TemplateResponse(
135
+ request, "_group_row.html", {"initial": None},
136
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
 
 
 
 
138
 
139
+ EXAMPLES_DIR = os.environ.get(
140
+ "CMUTS_EXAMPLES_DIR", os.path.join(BASE_DIR, "examples"),
141
+ )
142
 
 
 
143
 
144
+ @app.post("/run-example")
145
+ def run_example(background_tasks: BackgroundTasks):
146
+ """Submit the bundled example dataset as a job, skipping the upload form."""
147
  fasta = None
148
  treated = None
149
  untreated = None
150
+ for f in sorted(os.listdir(EXAMPLES_DIR)):
 
 
 
151
  path = os.path.join(EXAMPLES_DIR, f)
152
  lower = f.lower()
153
  if lower.endswith((".fasta", ".fa")):
154
  fasta = path
155
+ elif "untreated" in lower or "nomod" in lower or "control" in lower:
156
+ untreated = path
157
+ elif lower.endswith((".fastq", ".fq", ".fastq.gz", ".fq.gz")):
158
+ treated = path
159
+ if fasta is None or treated is None:
160
+ raise HTTPException(500, "Example dataset is missing required files.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
 
162
+ job_id = uuid.uuid4().hex[:12]
163
+ job_dir = job_dir_for(job_id)
164
+ uploads_dir = os.path.join(job_dir, "uploads")
165
+ os.makedirs(uploads_dir, exist_ok=True)
166
+ fasta_path = os.path.join(uploads_dir, "ref.fasta")
167
+ shutil.copy(fasta, fasta_path)
168
+ mod_path = os.path.join(uploads_dir, "example__" + os.path.basename(treated))
169
+ shutil.copy(treated, mod_path)
170
+ nomod_path: str | None = None
171
+ if untreated is not None:
172
+ nomod_path = os.path.join(uploads_dir, "example__" + os.path.basename(untreated))
173
+ shutil.copy(untreated, nomod_path)
174
+
175
+ state = JobState(job_id=job_id)
176
+ state.log(f"Submitted example job {job_id}.")
177
+ JOB_STATES[job_id] = state
178
+
179
+ groups = [GroupInput(name="example", mod_fastq=mod_path, nomod_fastq=nomod_path)]
180
+
181
+ def _runner() -> None:
182
+ try:
183
+ run_pipeline(
184
+ job_id=job_id, job_dir=job_dir,
185
+ fasta_path=fasta_path, groups=groups,
186
+ align_cfg=AlignConfig(),
187
+ core_cfg=CoreConfig(),
188
+ norm_cfg=NormConfig(),
189
+ cif_path=None, state=state,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  )
191
+ finally:
192
+ asyncio.get_event_loop().call_later(60, JOB_STATES.pop, job_id, None)
193
+
194
+ background_tasks.add_task(asyncio.to_thread, _runner)
195
+ return RedirectResponse(f"/results/{job_id}", status_code=303)
196
+
197
+
198
+ # --- Routes: submit + status ---
199
+
200
+
201
+ @app.post("/run")
202
+ async def run(
203
+ request: Request,
204
+ background_tasks: BackgroundTasks,
205
+ fasta: UploadFile,
206
+ cif: UploadFile | None = None,
207
+ group_name: list[str] = Form(default=[]),
208
+ mod_fastq: list[UploadFile] = Form(default=[]),
209
+ nomod_fastq: list[UploadFile] = Form(default=[]),
210
+ # Alignment
211
+ trim_5: str = Form(default=""),
212
+ trim_3: str = Form(default=""),
213
+ local_align: bool = Form(default=False),
214
+ # Core
215
+ min_mapq: int = Form(default=10),
216
+ min_phred: int = Form(default=10),
217
+ min_length: int = Form(default=2),
218
+ max_length: int = Form(default=1024),
219
+ no_mismatches: bool = Form(default=False),
220
+ strand: str = Form(default="both"),
221
+ compute_pairwise: bool = Form(default=False),
222
+ sig: float = Form(default=0.05),
223
+ # Norm
224
+ norm_method: str = Form(default="ubr"),
225
+ no_insertions: bool = Form(default=True),
226
+ no_deletions: bool = Form(default=False),
227
+ clip_low: bool = Form(default=False),
228
+ clip_high: bool = Form(default=False),
229
+ blank_5p: int = Form(default=0),
230
+ blank_3p: int = Form(default=0),
231
+ blank_cutoff: int = Form(default=10),
232
+ norm_cutoff: int = Form(default=500),
233
+ norm_percentile: int = Form(default=90),
234
+ ):
235
+ if not _is_real_upload(fasta):
236
+ raise HTTPException(400, "A reference FASTA is required.")
237
 
238
+ # Validate group inputs and stage files to the job dir.
239
+ job_id = uuid.uuid4().hex[:12]
240
+ job_dir = job_dir_for(job_id)
241
+ os.makedirs(job_dir, exist_ok=True)
242
+ uploads_dir = os.path.join(job_dir, "uploads")
243
+ os.makedirs(uploads_dir, exist_ok=True)
244
+
245
+ fasta_path = _save_upload(fasta, uploads_dir, "ref.fasta")
246
+ cif_path: str | None = None
247
+ if _is_real_upload(cif):
248
+ cif_path = _save_upload(cif, uploads_dir, cif.filename or "ref.cif")
249
+
250
+ # Pair the form list values. Browsers always send equal-length lists,
251
+ # but be defensive.
252
+ n_rows = max(len(group_name), len(mod_fastq), len(nomod_fastq))
253
+ while len(group_name) < n_rows:
254
+ group_name.append("")
255
+ while len(mod_fastq) < n_rows:
256
+ mod_fastq.append(UploadFile(filename="", file=io.BytesIO())) # type: ignore[call-arg]
257
+ while len(nomod_fastq) < n_rows:
258
+ nomod_fastq.append(UploadFile(filename="", file=io.BytesIO())) # type: ignore[call-arg]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
 
260
+ groups: list[GroupInput] = []
261
+ seen_names: set[str] = set()
262
+ for i in range(n_rows):
263
+ mod = mod_fastq[i]
264
+ if not _is_real_upload(mod):
265
+ continue
266
+ gn = sanitize_group_name(group_name[i]) or f"group_{i + 1}"
267
+ # Disambiguate clashing names.
268
+ base = gn
269
+ k = 2
270
+ while gn in seen_names:
271
+ gn = f"{base}_{k}"
272
+ k += 1
273
+ seen_names.add(gn)
274
+
275
+ mod_path = _save_upload(mod, uploads_dir, f"{gn}__{mod.filename}")
276
+ if file_size_mb(mod_path) > MAX_FASTQ_MB:
277
+ raise HTTPException(
278
+ 400,
279
+ f"Modified FASTQ for group '{gn}' is "
280
+ f"{file_size_mb(mod_path):.0f} MB; the limit is "
281
+ f"{MAX_FASTQ_MB} MB.",
 
 
 
 
 
 
 
 
 
282
  )
283
+ nomod_path: str | None = None
284
+ if _is_real_upload(nomod_fastq[i]):
285
+ nomod_path = _save_upload(
286
+ nomod_fastq[i], uploads_dir,
287
+ f"{gn}__{nomod_fastq[i].filename}",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  )
289
+ if file_size_mb(nomod_path) > MAX_FASTQ_MB:
290
+ raise HTTPException(
291
+ 400,
292
+ f"Control FASTQ for group '{gn}' is "
293
+ f"{file_size_mb(nomod_path):.0f} MB; the limit is "
294
+ f"{MAX_FASTQ_MB} MB.",
295
+ )
296
+ groups.append(GroupInput(name=gn, mod_fastq=mod_path, nomod_fastq=nomod_path))
297
 
298
+ if not groups:
299
+ raise HTTPException(400, "At least one group with a Modified FASTQ is required.")
300
+
301
+ align_cfg = AlignConfig(trim_5=trim_5, trim_3=trim_3, local_align=local_align)
302
+ core_cfg = CoreConfig(
303
+ min_mapq=min_mapq, min_phred=min_phred,
304
+ min_length=min_length, max_length=max_length,
305
+ no_insertions=no_insertions, no_mismatches=no_mismatches,
306
+ strand=strand, compute_pairwise=compute_pairwise,
307
+ )
308
+ norm_cfg = NormConfig(
309
+ norm_method=norm_method,
310
+ no_insertions=no_insertions, no_deletions=no_deletions,
311
+ clip_low=clip_low, clip_high=clip_high,
312
+ blank_5p=blank_5p, blank_3p=blank_3p,
313
+ blank_cutoff=blank_cutoff, norm_cutoff=norm_cutoff,
314
+ norm_percentile=norm_percentile, sig=sig,
315
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
 
317
+ state = JobState(job_id=job_id)
318
+ state.log(f"Submitted job {job_id} with {len(groups)} group(s).")
319
+ JOB_STATES[job_id] = state
 
 
320
 
321
+ def _runner() -> None:
322
+ try:
323
+ run_pipeline(
324
+ job_id=job_id,
325
+ job_dir=job_dir,
326
+ fasta_path=fasta_path,
327
+ groups=groups,
328
+ align_cfg=align_cfg,
329
+ core_cfg=core_cfg,
330
+ norm_cfg=norm_cfg,
331
+ cif_path=cif_path,
332
+ state=state,
333
+ )
334
+ finally:
335
+ # Keep the state briefly so the UI can read the final transition,
336
+ # then drop it. The on-disk meta + log are authoritative after.
337
+ import threading
338
+ threading.Timer(60.0, JOB_STATES.pop, args=(job_id, None)).start()
339
+
340
+ background_tasks.add_task(asyncio.to_thread, _runner)
341
+ return RedirectResponse(f"/results/{job_id}", status_code=303)
342
+
343
+
344
+ @app.get("/results/{job_id}/status", response_class=JSONResponse)
345
+ def status(job_id: str) -> JSONResponse:
346
+ status_, log_lines, error = _job_status(job_id)
347
+ return JSONResponse({
348
+ "status": status_,
349
+ "log": "\n".join(log_lines),
350
+ "error": error,
351
+ })
352
+
353
+
354
+ # --- Routes: results page + plot data ---
355
+
356
+
357
+ @app.get("/results/{job_id}", response_class=HTMLResponse)
358
+ def results(request: Request, job_id: str) -> HTMLResponse:
359
+ status_, log_lines, error = _job_status(job_id)
360
+ if status_ == "missing":
361
+ return templates.TemplateResponse(
362
+ request,
363
+ "results.html",
364
+ {
365
+ "job_id": job_id,
366
+ "status": "missing",
367
+ "ttl_hours": RESULTS_TTL_HOURS,
368
+ "log": "",
369
+ "error": None,
370
+ "meta": None,
371
+ "first_group": None,
372
+ },
373
+ status_code=404,
374
  )
375
+ meta = read_meta(job_dir_for(job_id))
376
+ first_group = None
377
+ if meta and meta.get("group_names"):
378
+ first_group = meta["group_names"][0]
379
+ return templates.TemplateResponse(
380
+ request,
381
+ "results.html",
382
+ {
383
+ "job_id": job_id,
384
+ "status": status_,
385
+ "ttl_hours": RESULTS_TTL_HOURS,
386
+ "log": "\n".join(log_lines),
387
+ "error": error,
388
+ "meta": meta,
389
+ "first_group": first_group,
390
+ "plot_keys": PLOT_KEYS,
391
+ },
392
  )
393
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
 
395
+ def _read_plot_json(path: str) -> str | None:
396
+ if not os.path.isfile(path):
397
+ return None
398
+ with open(path) as f:
399
+ return f.read()
400
+
401
+
402
+ @app.get("/results/{job_id}/plot/combined", response_class=PlainTextResponse)
403
+ def plot_combined(job_id: str) -> PlainTextResponse:
404
+ path = os.path.join(job_dir_for(job_id), "combined_profile.json")
405
+ body = _read_plot_json(path)
406
+ if body is None:
407
+ raise HTTPException(404, "Combined plot not available.")
408
+ return PlainTextResponse(body, media_type="application/json")
409
+
410
+
411
+ @app.get("/results/{job_id}/plot/{group}/{key}", response_class=PlainTextResponse)
412
+ def plot_group_key(
413
+ job_id: str, group: str, key: str, seq: int = 0,
414
+ ) -> PlainTextResponse:
415
+ if key not in PLOT_KEYS:
416
+ raise HTTPException(404, "Unknown plot key.")
417
+ job_dir = job_dir_for(job_id)
418
+ # For seq=0 use the pre-computed JSON. For seq>0 on per-ref plots,
419
+ # build on demand from the HDF5.
420
+ if seq == 0:
421
+ path = os.path.join(job_dir, "groups", group, f"{key}.json")
422
+ body = _read_plot_json(path)
423
+ if body is not None:
424
+ return PlainTextResponse(body, media_type="application/json")
425
+ # Falls through to on-demand below for missing plots.
426
+ if key == "profile":
427
+ body = build_profile_plot(job_dir, group, seq)
428
+ elif key in {"mi", "correlation", "pairwise_coverage"}:
429
+ body = build_perref_plot(job_dir, group, key, seq)
430
+ else:
431
+ body = None
432
+ if body is None:
433
+ raise HTTPException(404, "Plot not available for this group/sequence.")
434
+ return PlainTextResponse(body, media_type="application/json")
435
 
 
436
 
437
+ # --- Routes: downloads ---
 
 
438
 
 
439
 
440
+ @app.get("/results/{job_id}/download/h5")
441
+ def download_h5(job_id: str) -> FileResponse:
442
+ path = os.path.join(job_dir_for(job_id), "profiles.h5")
443
+ if not os.path.isfile(path):
444
+ raise HTTPException(404, "HDF5 not available.")
445
+ meta = read_meta(job_dir_for(job_id)) or {}
446
+ group_names = meta.get("group_names") or [job_id]
447
+ filename = "-".join(group_names) + "-profiles.h5"
448
+ return FileResponse(path, media_type="application/x-hdf5", filename=filename)
449
 
 
450
 
451
+ @app.get("/results/{job_id}/download/csv")
452
+ def download_csv(job_id: str) -> FileResponse:
453
+ path = os.path.join(job_dir_for(job_id), "profiles.csv")
454
+ if not os.path.isfile(path):
455
+ raise HTTPException(404, "CSV not available.")
456
+ meta = read_meta(job_dir_for(job_id)) or {}
457
+ group_names = meta.get("group_names") or [job_id]
458
+ filename = "-".join(group_names) + "-profiles.csv"
459
+ return FileResponse(path, media_type="text/csv", filename=filename)
460
 
 
 
 
 
 
461
 
462
+ @app.get("/results/{job_id}/download/defattr/{name}")
463
+ def download_defattr(job_id: str, name: str) -> FileResponse:
464
+ safe = os.path.basename(name) # prevent traversal
465
+ path = os.path.join(job_dir_for(job_id), "defattr", safe)
466
+ if not os.path.isfile(path):
467
+ raise HTTPException(404, "Defattr not available.")
468
+ return FileResponse(path, media_type="text/plain", filename=safe)
469
 
 
470
 
471
+ def _plot_json_to_png(json_path: str) -> bytes | None:
472
+ """Render a saved Plotly JSON to PNG bytes. Returns None on failure."""
473
+ try:
474
+ import plotly.graph_objects as go
475
+ with open(json_path) as f:
476
+ fig = go.Figure(json.load(f))
477
+ return fig.to_image(format="png", width=1000, height=600, scale=2)
478
+ except Exception:
479
+ return None
480
 
 
 
481
 
482
+ @app.get("/results/{job_id}/download/all")
483
+ def download_all(job_id: str) -> StreamingResponse:
484
+ job_dir = job_dir_for(job_id)
485
+ if not os.path.isdir(job_dir):
486
+ raise HTTPException(404, "Job not found.")
487
+
488
+ def _gen():
489
+ buf = io.BytesIO()
490
+ with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
491
+ for root, _dirs, files in os.walk(job_dir):
492
+ rel = os.path.relpath(root, job_dir)
493
+ # Skip raw uploads to keep the bundle small.
494
+ if rel.startswith("uploads"):
495
+ continue
496
+ for name in files:
497
+ full = os.path.join(root, name)
498
+ arc_rel = os.path.relpath(full, job_dir)
499
+ # Convert plot JSONs to PNG; include the underlying h5,
500
+ # csv, log, defattr, meta as-is.
501
+ if name.endswith(".json") and (
502
+ rel.startswith("groups")
503
+ or name == "combined_profile.json"
504
+ ):
505
+ png = _plot_json_to_png(full)
506
+ if png is not None:
507
+ zf.writestr(
508
+ os.path.join(job_id, arc_rel[:-5] + ".png"),
509
+ png,
510
+ )
511
  continue
512
+ if name == "meta.json":
513
+ # Skip — internal app state, not useful to the user.
514
+ continue
515
+ zf.write(full, os.path.join(job_id, arc_rel))
516
+ buf.seek(0)
517
+ yield buf.read()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
518
 
519
+ headers = {"Content-Disposition": f'attachment; filename="{job_id}-results.zip"'}
520
+ return StreamingResponse(_gen(), media_type="application/zip", headers=headers)
 
 
 
521
 
522
 
523
+ # --- Local dev entry point ---
 
524
 
 
 
525
 
526
  if __name__ == "__main__":
527
  import uvicorn
528
+
529
+ uvicorn.run(
530
+ "app:app",
531
+ host=os.environ.get("HOST", "127.0.0.1"),
532
+ port=int(os.environ.get("PORT", "7860")),
533
+ reload=bool(os.environ.get("RELOAD", "1") == "1"),
534
+ )
pipeline.py ADDED
@@ -0,0 +1,784 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pipeline orchestration for the cmuts web app.
2
+
3
+ Framework-agnostic. Takes input paths + configs, runs ``cmuts align`` /
4
+ ``core`` / ``compute_reactivities``, and writes everything for one job
5
+ into a single directory under ``RESULTS_DIR``.
6
+
7
+ On-disk job layout::
8
+
9
+ {job_dir}/
10
+ profiles.h5 final per-group HDF5
11
+ profiles.csv flat per-position table
12
+ meta.json status, group/sequence names, stats, log
13
+ log.txt raw streaming log
14
+ defattr/{group}.defattr (if a CIF was supplied)
15
+ groups/{group}/{key}.json plotly figure JSON, first reference
16
+ combined_profile.json overlay across groups (single-ref + multi-group)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import csv
22
+ import glob
23
+ import json
24
+ import os
25
+ import re
26
+ import shutil
27
+ import subprocess
28
+ import tempfile
29
+ import time
30
+ import traceback
31
+ from dataclasses import dataclass, field
32
+ from typing import Callable
33
+
34
+ import h5py
35
+ import numpy as np
36
+ import plotly.graph_objects as go
37
+
38
+
39
+ # --- Constants ---
40
+
41
+ MAX_GROUPS = int(os.environ.get("CMUTS_MAX_GROUPS", "5"))
42
+ MAX_FASTQ_MB = int(os.environ.get("CMUTS_MAX_FASTQ_MB", "500"))
43
+ RESULTS_TTL_HOURS = int(os.environ.get("CMUTS_RESULTS_TTL_HOURS", "48"))
44
+ PIPELINE_TIMEOUT_SEC = int(os.environ.get("CMUTS_PIPELINE_TIMEOUT_SEC", "600"))
45
+ DEFAULT_GROUP_NAME = "profile"
46
+
47
+ _default_results_dir = "/data/results" if os.path.isdir("/data") else "/tmp/cmuts_results"
48
+ RESULTS_DIR = os.environ.get("CMUTS_RESULTS_DIR", _default_results_dir)
49
+ os.makedirs(RESULTS_DIR, exist_ok=True)
50
+
51
+ _FASTQ_SUFFIXES = (".fastq.gz", ".fq.gz", ".fastq", ".fq")
52
+
53
+ PLOT_KEYS = [
54
+ "profile", "mod_heatmap", "termination", "coverage",
55
+ "read_hist", "cumulative_reads", "snr_scaling",
56
+ "mi", "correlation", "pairwise_coverage",
57
+ ]
58
+
59
+
60
+ # --- Dataclasses ---
61
+
62
+
63
+ @dataclass
64
+ class GroupInput:
65
+ name: str
66
+ mod_fastq: str
67
+ nomod_fastq: str | None = None
68
+
69
+
70
+ @dataclass
71
+ class AlignConfig:
72
+ trim_5: str = ""
73
+ trim_3: str = ""
74
+ local_align: bool = False
75
+
76
+
77
+ @dataclass
78
+ class CoreConfig:
79
+ min_mapq: int = 10
80
+ min_phred: int = 10
81
+ min_length: int = 2
82
+ max_length: int = 1024
83
+ no_insertions: bool = True
84
+ no_mismatches: bool = False
85
+ strand: str = "both"
86
+ compute_pairwise: bool = False
87
+
88
+
89
+ @dataclass
90
+ class NormConfig:
91
+ norm_method: str = "ubr"
92
+ no_insertions: bool = True
93
+ no_deletions: bool = False
94
+ clip_low: bool = False
95
+ clip_high: bool = False
96
+ blank_5p: int = 0
97
+ blank_3p: int = 0
98
+ blank_cutoff: int = 10
99
+ norm_cutoff: int = 500
100
+ norm_percentile: int = 90
101
+ sig: float = 0.05
102
+
103
+
104
+ @dataclass
105
+ class JobState:
106
+ """Mutable in-memory state for an in-flight job."""
107
+ job_id: str
108
+ status: str = "running" # "running" | "done" | "error"
109
+ log_lines: list[str] = field(default_factory=list)
110
+ error: str | None = None
111
+
112
+ def log(self, msg: str) -> None:
113
+ self.log_lines.append(msg)
114
+
115
+
116
+ # --- FASTA / FASTQ helpers ---
117
+
118
+
119
+ def parse_fasta(fasta_path: str) -> list[tuple[str, str]]:
120
+ entries: list[tuple[str, str]] = []
121
+ name = ""
122
+ seq_parts: list[str] = []
123
+ with open(fasta_path) as f:
124
+ for line in f:
125
+ line = line.strip()
126
+ if line.startswith(">"):
127
+ if seq_parts:
128
+ entries.append((name, "".join(seq_parts)))
129
+ seq_parts = []
130
+ name = line[1:].split()[0]
131
+ elif line:
132
+ seq_parts.append(line)
133
+ if seq_parts:
134
+ entries.append((name, "".join(seq_parts)))
135
+ return entries
136
+
137
+
138
+ def sanitize_group_name(raw: str | None) -> str:
139
+ name = re.sub(r"[^\w\-]", "_", (raw or "").strip())
140
+ return name or DEFAULT_GROUP_NAME
141
+
142
+
143
+ def fastq_stem(path: str) -> str:
144
+ name = os.path.basename(path)
145
+ for suffix in _FASTQ_SUFFIXES:
146
+ if name.endswith(suffix):
147
+ return name[: -len(suffix)]
148
+ return os.path.splitext(name)[0]
149
+
150
+
151
+ def file_size_mb(path: str) -> float:
152
+ return os.path.getsize(path) / (1024 * 1024)
153
+
154
+
155
+ def build_seq_names(n: int, sequences: list[str] | None) -> list[str]:
156
+ """Build display labels for the sequence selector, disambiguating
157
+ truncated duplicates."""
158
+ raw: list[str] = []
159
+ for i in range(n):
160
+ seq = sequences[i] if sequences and i < len(sequences) else None
161
+ if seq and len(seq) > 50:
162
+ raw.append(seq[:50] + "...")
163
+ elif seq:
164
+ raw.append(seq)
165
+ else:
166
+ raw.append(f"Sequence {i + 1}")
167
+ counts: dict[str, int] = {}
168
+ out: list[str] = []
169
+ for label in raw:
170
+ if raw.count(label) > 1:
171
+ counts[label] = counts.get(label, 0) + 1
172
+ out.append(f"{label} (#{counts[label]})")
173
+ else:
174
+ out.append(label)
175
+ return out
176
+
177
+
178
+ # --- CLI command builders ---
179
+
180
+
181
+ def _build_align_cmd(
182
+ fasta_path: str,
183
+ output_dir: str,
184
+ fastq_files: list[str],
185
+ cfg: AlignConfig,
186
+ ) -> list[str]:
187
+ cmd = ["cmuts", "align", "--fasta", fasta_path, "--output", output_dir]
188
+ if cfg.trim_5.strip():
189
+ cmd.extend(["--trim-5", cfg.trim_5.strip()])
190
+ if cfg.trim_3.strip():
191
+ cmd.extend(["--trim-3", cfg.trim_3.strip()])
192
+ if cfg.local_align:
193
+ cmd.append("--local")
194
+ cmd.extend(fastq_files)
195
+ return cmd
196
+
197
+
198
+ def _build_core_cmd(
199
+ fasta_path: str,
200
+ output_h5: str,
201
+ bam_files: list[str],
202
+ cfg: CoreConfig,
203
+ ) -> list[str]:
204
+ cmd = [
205
+ "cmuts", "core",
206
+ "-f", fasta_path,
207
+ "-o", output_h5,
208
+ "--min-mapq", str(cfg.min_mapq),
209
+ "--min-phred", str(cfg.min_phred),
210
+ "--min-length", str(cfg.min_length),
211
+ "--max-length", str(cfg.max_length),
212
+ ]
213
+ if cfg.no_insertions:
214
+ cmd.append("--no-insertions")
215
+ if cfg.no_mismatches:
216
+ cmd.append("--no-mismatches")
217
+ if cfg.strand == "forward":
218
+ cmd.append("--no-reverse")
219
+ elif cfg.strand == "reverse":
220
+ cmd.append("--only-reverse")
221
+ if cfg.compute_pairwise:
222
+ cmd.append("--pairwise")
223
+ cmd.extend(bam_files)
224
+ return cmd
225
+
226
+
227
+ # --- CSV generation ---
228
+
229
+
230
+ def _generate_csv(
231
+ h5_path: str,
232
+ fasta_path: str,
233
+ group_names: list[str],
234
+ ) -> str:
235
+ """Generate a CSV from HDF5 profiles with columns for each group."""
236
+ fasta_entries = parse_fasta(fasta_path)
237
+ csv_path = h5_path.rsplit(".", 1)[0] + ".csv"
238
+
239
+ with h5py.File(h5_path, "r") as f, open(csv_path, "w", newline="") as csvfile:
240
+ writer = csv.writer(csvfile)
241
+
242
+ first_grp = f[group_names[0]]
243
+ n_refs = first_grp["reactivity"].shape[0]
244
+ seq_len = first_grp["reactivity"].shape[1]
245
+ multi_ref = n_refs > 1
246
+
247
+ header: list[str] = []
248
+ if multi_ref:
249
+ header.append("Reference")
250
+ header.extend(["Position", "Nucleotide"])
251
+ for gn in group_names:
252
+ header.extend([gn, f"{gn}_error"])
253
+ writer.writerow(header)
254
+
255
+ for ref_idx in range(n_refs):
256
+ ref_name = fasta_entries[ref_idx][0] if ref_idx < len(fasta_entries) else f"ref_{ref_idx + 1}"
257
+ ref_seq = fasta_entries[ref_idx][1] if ref_idx < len(fasta_entries) else ""
258
+
259
+ group_data: dict[str, dict[str, np.ndarray]] = {}
260
+ for gn in group_names:
261
+ group_data[gn] = {
262
+ "reactivity": np.array(f[gn]["reactivity"])[ref_idx],
263
+ "error": np.array(f[gn]["error"])[ref_idx],
264
+ }
265
+
266
+ for pos in range(seq_len):
267
+ row: list[str] = []
268
+ if multi_ref:
269
+ row.append(ref_name)
270
+ row.append(str(pos + 1))
271
+ row.append(ref_seq[pos] if pos < len(ref_seq) else "")
272
+ for gn in group_names:
273
+ r = group_data[gn]["reactivity"][pos]
274
+ e = group_data[gn]["error"][pos]
275
+ row.append(f"{r:.6f}" if np.isfinite(r) else "")
276
+ row.append(f"{e:.6f}" if np.isfinite(e) else "")
277
+ writer.writerow(row)
278
+
279
+ return csv_path
280
+
281
+
282
+ # --- Structure visualization (ChimeraX defattr) ---
283
+
284
+
285
+ def _build_defattrs(
286
+ cif_path: str,
287
+ sequence: str,
288
+ results: list,
289
+ out_dir: str,
290
+ chimerax_bin: str = "ChimeraX",
291
+ ):
292
+ """Generate one defattr per group plus a markdown command snippet.
293
+
294
+ Returns (defattr_paths, markdown).
295
+ """
296
+ import cmuts as _cmuts # imported lazily so app starts without cmuts
297
+
298
+ cif_basename = os.path.basename(cif_path)
299
+ aln_seq = sequence.upper().replace("U", "T")
300
+
301
+ defattr_paths: list[str] = []
302
+ blocks: list[str] = [
303
+ "### Visualize the structure with ChimeraX",
304
+ "",
305
+ f"Download each `.defattr` file below, place it next to your "
306
+ f"`{cif_basename}` (a copy of your uploaded structure), and run "
307
+ f"the matching command in ChimeraX's command line.",
308
+ "",
309
+ ]
310
+
311
+ for r in results:
312
+ name = r.group.name
313
+ reactivity = np.asarray(r.combined.reactivity)
314
+ if reactivity.shape[0] != 1:
315
+ continue
316
+ defattr_path = os.path.join(out_dir, f"{name}.defattr")
317
+ try:
318
+ max_value = _cmuts.visualize.make_defattr(
319
+ reactivity[0], aln_seq, cif_path, defattr_path,
320
+ )
321
+ except Exception as e: # noqa: BLE001
322
+ blocks.append(f"**{name}:** could not generate defattr — {e}")
323
+ blocks.append("")
324
+ continue
325
+ cmd = _cmuts.visualize.chimerax_command(
326
+ cif_basename, os.path.basename(defattr_path),
327
+ color="indianred", max_value=max_value,
328
+ )
329
+ blocks.append(f"**{name}:**")
330
+ blocks.append("```")
331
+ blocks.append(f"{chimerax_bin} --cmd '{cmd}'")
332
+ blocks.append("```")
333
+ blocks.append("")
334
+ defattr_paths.append(defattr_path)
335
+
336
+ if not defattr_paths:
337
+ return [], ""
338
+ return defattr_paths, "\n".join(blocks)
339
+
340
+
341
+ # --- Plot building ---
342
+
343
+
344
+ def _build_plots_for_group(
345
+ mod, nomod, combined,
346
+ group_name: str,
347
+ sequence: str | None,
348
+ ) -> dict[str, go.Figure | None]:
349
+ """Build every diagnostic plot for one group. Returns key -> Figure or
350
+ None when the underlying data is absent."""
351
+ from cmuts.visualize.plotly import (
352
+ plot_correlation, plot_coverage, plot_cumulative_reads, plot_examples,
353
+ plot_heatmap, plot_mi, plot_pairwise_coverage,
354
+ plot_read_hist, plot_snr_scaling, plot_termination,
355
+ )
356
+
357
+ plots: dict[str, go.Figure | None] = {}
358
+ plots["profile"] = plot_examples(
359
+ np.asarray(combined.reactivity), np.asarray(combined.error),
360
+ group_name, sequence=sequence,
361
+ )
362
+ plots["mod_heatmap"] = plot_heatmap(np.asarray(combined.heatmap), group_name)
363
+ plots["termination"] = plot_termination(np.asarray(combined.terminations), group_name)
364
+ plots["coverage"] = plot_coverage(
365
+ np.asarray(combined.coverage), np.asarray(combined.reads), group_name,
366
+ )
367
+
368
+ is_multi = not combined.single()
369
+ reads = np.asarray(combined.reads)
370
+ plots["read_hist"] = plot_read_hist(reads, group_name) if is_multi else None
371
+ plots["cumulative_reads"] = plot_cumulative_reads(reads, group_name) if is_multi else None
372
+
373
+ plots["snr_scaling"] = plot_snr_scaling(mod, nomod, combined, group_name)
374
+
375
+ plots["mi"] = (
376
+ plot_mi(np.asarray(combined.mi)[0], group_name)
377
+ if combined.mi is not None else None
378
+ )
379
+ plots["correlation"] = (
380
+ plot_correlation(np.asarray(combined.covariance)[0], group_name)
381
+ if combined.covariance is not None else None
382
+ )
383
+ if combined.probability is not None:
384
+ prob = np.asarray(combined.probability)
385
+ plots["pairwise_coverage"] = plot_pairwise_coverage(prob[0, :, :, 1, 1], group_name)
386
+ else:
387
+ plots["pairwise_coverage"] = None
388
+ return plots
389
+
390
+
391
+ def _save_plot_json(fig: go.Figure | None, path: str) -> bool:
392
+ if fig is None:
393
+ return False
394
+ os.makedirs(os.path.dirname(path), exist_ok=True)
395
+ with open(path, "w") as f:
396
+ f.write(fig.to_json())
397
+ return True
398
+
399
+
400
+ def _build_stats_for_group(grp, ref_count: int) -> list[list[str]]:
401
+ reactivity = np.array(grp["reactivity"])
402
+ reads = np.array(grp["reads"])
403
+ error = np.array(grp["error"])
404
+ snr = np.array(grp["SNR"])
405
+
406
+ n_refs = reactivity.shape[0]
407
+ seq_len = reactivity.shape[1]
408
+ total_reads = int(reads.sum())
409
+ valid = np.isfinite(reactivity)
410
+
411
+ rows: list[list[str]] = [
412
+ ["References", f"{n_refs:,}"],
413
+ ["Reference length", f"{seq_len:,}"],
414
+ ["Total reads", f"{total_reads:,}"],
415
+ ["Mean reads per reference", f"{np.mean(reads):,.1f}"],
416
+ ["Median reads per reference", f"{int(np.median(reads)):,}"],
417
+ ]
418
+ if valid.any():
419
+ rows.extend([
420
+ ["Mean reactivity", f"{np.mean(reactivity[valid]):.3f}"],
421
+ ["Mean error", f"{np.mean(error[valid]):.3f}"],
422
+ ["Mean SNR", f"{np.mean(snr):.2f}"],
423
+ ["SNR > 1", f"{np.mean(snr > 1):.1%}"],
424
+ ])
425
+ dropout = float(np.mean(reads == 0))
426
+ if dropout > 0:
427
+ rows.append(["Dropout fraction", f"{dropout:.1%}"])
428
+ return rows
429
+
430
+
431
+ # --- Results persistence + cleanup ---
432
+
433
+
434
+ def job_dir_for(job_id: str) -> str:
435
+ return os.path.join(RESULTS_DIR, job_id)
436
+
437
+
438
+ def cleanup_old_results() -> None:
439
+ """Delete result directories older than RESULTS_TTL_HOURS."""
440
+ cutoff = time.time() - RESULTS_TTL_HOURS * 3600
441
+ if not os.path.isdir(RESULTS_DIR):
442
+ return
443
+ for entry in os.scandir(RESULTS_DIR):
444
+ if not entry.is_dir():
445
+ continue
446
+ meta_path = os.path.join(entry.path, "meta.json")
447
+ try:
448
+ if os.path.exists(meta_path):
449
+ with open(meta_path) as f:
450
+ created = json.load(f).get("created_at", 0)
451
+ else:
452
+ created = entry.stat().st_mtime
453
+ if created < cutoff:
454
+ shutil.rmtree(entry.path, ignore_errors=True)
455
+ except Exception:
456
+ pass
457
+
458
+
459
+ def write_meta(job_dir: str, meta: dict) -> None:
460
+ with open(os.path.join(job_dir, "meta.json"), "w") as f:
461
+ json.dump(meta, f)
462
+
463
+
464
+ def read_meta(job_dir: str) -> dict | None:
465
+ path = os.path.join(job_dir, "meta.json")
466
+ if not os.path.isfile(path):
467
+ return None
468
+ with open(path) as f:
469
+ return json.load(f)
470
+
471
+
472
+ def write_log(job_dir: str, log_lines: list[str]) -> None:
473
+ with open(os.path.join(job_dir, "log.txt"), "w") as f:
474
+ f.write("\n".join(log_lines))
475
+
476
+
477
+ # --- On-demand plot generation (sequence switching) ---
478
+
479
+
480
+ def build_profile_plot(job_dir: str, group_name: str, seq_idx: int) -> str | None:
481
+ """Build a profile plot JSON on demand for a given group/sequence."""
482
+ from cmuts.visualize.plotly import plot_profile
483
+
484
+ h5_path = os.path.join(job_dir, "profiles.h5")
485
+ if not os.path.isfile(h5_path):
486
+ return None
487
+ with h5py.File(h5_path, "r") as f:
488
+ if group_name not in f:
489
+ return None
490
+ grp = f[group_name]
491
+ reactivity = np.array(grp["reactivity"])
492
+ error = np.array(grp["error"])
493
+ sequences = None
494
+ if "sequence" in f:
495
+ sequences = [
496
+ s.decode() if isinstance(s, bytes) else s for s in f["sequence"]
497
+ ]
498
+ if seq_idx < 0 or seq_idx >= reactivity.shape[0]:
499
+ return None
500
+ seq = sequences[seq_idx] if sequences and seq_idx < len(sequences) else None
501
+ names = build_seq_names(reactivity.shape[0], sequences)
502
+ fig = plot_profile(reactivity[seq_idx], error[seq_idx], names[seq_idx], sequence=seq)
503
+ return fig.to_json()
504
+
505
+
506
+ def build_perref_plot(
507
+ job_dir: str, group_name: str, key: str, seq_idx: int,
508
+ ) -> str | None:
509
+ """Rebuild a per-reference plot (mi / correlation / pairwise_coverage)
510
+ for a different reference. Returns None if not available."""
511
+ from cmuts.visualize.plotly import (
512
+ plot_correlation, plot_mi, plot_pairwise_coverage,
513
+ )
514
+
515
+ h5_path = os.path.join(job_dir, "profiles.h5")
516
+ if not os.path.isfile(h5_path):
517
+ return None
518
+ with h5py.File(h5_path, "r") as f:
519
+ if group_name not in f:
520
+ return None
521
+ grp = f[group_name]
522
+ if key == "mi":
523
+ if "mutual-information" not in grp:
524
+ return None
525
+ arr = np.array(grp["mutual-information"])
526
+ elif key == "correlation":
527
+ if "covariance" not in grp:
528
+ return None
529
+ arr = np.array(grp["covariance"])
530
+ elif key == "pairwise_coverage":
531
+ if "probability" not in grp:
532
+ return None
533
+ arr = np.array(grp["probability"])
534
+ else:
535
+ return None
536
+ if seq_idx < 0 or seq_idx >= arr.shape[0]:
537
+ return None
538
+ if key == "mi":
539
+ fig = plot_mi(arr[seq_idx], group_name)
540
+ elif key == "correlation":
541
+ fig = plot_correlation(arr[seq_idx], group_name)
542
+ else: # pairwise_coverage
543
+ fig = plot_pairwise_coverage(arr[seq_idx, :, :, 1, 1], group_name)
544
+ return fig.to_json()
545
+
546
+
547
+ # --- Main pipeline ---
548
+
549
+
550
+ def run_pipeline(
551
+ job_id: str,
552
+ job_dir: str,
553
+ fasta_path: str,
554
+ groups: list[GroupInput],
555
+ align_cfg: AlignConfig,
556
+ core_cfg: CoreConfig,
557
+ norm_cfg: NormConfig,
558
+ cif_path: str | None,
559
+ state: JobState,
560
+ ) -> None:
561
+ """Run the full pipeline. All status/log goes through ``state``.
562
+
563
+ Writes results into ``job_dir``. On failure, ``state.status`` becomes
564
+ ``"error"`` and ``state.error`` holds a short message. On success,
565
+ ``state.status`` becomes ``"done"``.
566
+ """
567
+ workdir = tempfile.mkdtemp(prefix="cmuts_")
568
+ outdir = os.path.join(workdir, "outputs")
569
+ os.makedirs(outdir)
570
+
571
+ def log(msg: str) -> None:
572
+ state.log(msg)
573
+
574
+ def run_subprocess(cmd: list[str], cwd: str = outdir) -> bool:
575
+ log(f"$ {' '.join(cmd)}")
576
+ try:
577
+ result = subprocess.run(
578
+ cmd, cwd=cwd, capture_output=True, text=True,
579
+ timeout=PIPELINE_TIMEOUT_SEC,
580
+ )
581
+ except subprocess.TimeoutExpired:
582
+ log(f"Command timed out after {PIPELINE_TIMEOUT_SEC}s")
583
+ return False
584
+ if result.stdout:
585
+ log(result.stdout.rstrip())
586
+ if result.stderr:
587
+ log(result.stderr.rstrip())
588
+ if result.returncode != 0:
589
+ log(f"Command failed with exit code {result.returncode}")
590
+ return False
591
+ return True
592
+
593
+ try:
594
+ try:
595
+ import cmuts as _cmuts
596
+ except ImportError as e:
597
+ raise RuntimeError(
598
+ "cmuts is not installed in this environment. "
599
+ "Run the app under Docker (see Dockerfile) or install cmuts."
600
+ ) from e
601
+
602
+ # Stage FASTQ files with a group-name prefix to avoid collisions.
603
+ fastq_dir = os.path.join(workdir, "fastq")
604
+ os.makedirs(fastq_dir)
605
+
606
+ group_fastq_info: list[tuple[str, str, str | None]] = []
607
+ for g in groups:
608
+ prefix = g.name + "__"
609
+ mod_basename = prefix + os.path.basename(g.mod_fastq)
610
+ mod_stem = fastq_stem(mod_basename)
611
+ shutil.copy(g.mod_fastq, os.path.join(fastq_dir, mod_basename))
612
+
613
+ nomod_stem = None
614
+ if g.nomod_fastq is not None:
615
+ nomod_basename = prefix + os.path.basename(g.nomod_fastq)
616
+ nomod_stem = fastq_stem(nomod_basename)
617
+ shutil.copy(g.nomod_fastq, os.path.join(fastq_dir, nomod_basename))
618
+
619
+ group_fastq_info.append((g.name, mod_stem, nomod_stem))
620
+
621
+ # Step 1: align
622
+ log("=== Step 1: Aligning reads ===")
623
+ fastq_files = sorted(glob.glob(os.path.join(fastq_dir, "*")))
624
+ alignments_dir = os.path.join(outdir, "alignments")
625
+ if not run_subprocess(_build_align_cmd(fasta_path, alignments_dir, fastq_files, align_cfg)):
626
+ raise RuntimeError("Alignment failed. See log for details.")
627
+
628
+ bam_files_abs = sorted(glob.glob(os.path.join(alignments_dir, "*.bam")))
629
+ if not bam_files_abs:
630
+ raise RuntimeError(
631
+ "Alignment produced no BAM files. Check log — the reference "
632
+ "may not match the reads, or the FASTQ may be empty."
633
+ )
634
+
635
+ # Step 2: count mutations
636
+ log("\n=== Step 2: Counting mutations ===")
637
+ bam_files = sorted(os.path.relpath(p, outdir) for p in bam_files_abs)
638
+ counts_h5 = "counts.h5"
639
+ if not run_subprocess(_build_core_cmd(fasta_path, counts_h5, bam_files, core_cfg)):
640
+ raise RuntimeError("Mutation counting failed. See log for details.")
641
+
642
+ counts_path = os.path.join(outdir, counts_h5)
643
+ if not os.path.isfile(counts_path):
644
+ raise RuntimeError("cmuts core did not produce counts.h5.")
645
+
646
+ # Step 3: normalize
647
+ log("\n=== Step 3: Normalizing reactivities ===")
648
+ cmuts_groups = [
649
+ _cmuts.Group(
650
+ name=name,
651
+ mod=[f"alignments/{mod_stem}"],
652
+ nomod=[f"alignments/{nomod_stem}"] if nomod_stem else None,
653
+ )
654
+ for name, mod_stem, nomod_stem in group_fastq_info
655
+ ]
656
+
657
+ norm_opts = _cmuts.Opts(
658
+ _cmuts.DataGroups([]),
659
+ _cmuts.DataGroups(None),
660
+ norm_cfg.blank_cutoff,
661
+ not norm_cfg.no_insertions,
662
+ not norm_cfg.no_deletions,
663
+ norm_cfg.norm_method,
664
+ (norm_cfg.blank_5p, norm_cfg.blank_3p),
665
+ (norm_cfg.clip_low, norm_cfg.clip_high),
666
+ norm_cfg.sig,
667
+ )
668
+
669
+ with h5py.File(counts_path, "r") as f:
670
+ results = _cmuts.compute_reactivities(
671
+ f, fasta_path, cmuts_groups, norm_opts, shared_norm=True,
672
+ )
673
+
674
+ if len(results) > 1:
675
+ log(f" Pooled {norm_cfg.norm_method} normalization across {len(results)} groups.")
676
+ log("Normalization complete.")
677
+
678
+ # Save the combined HDF5 + CSV.
679
+ final_h5 = os.path.join(job_dir, "profiles.h5")
680
+ _cmuts.save_groups(final_h5, [(r.group.name, r.combined) for r in results])
681
+
682
+ group_names = [r.group.name for r in results]
683
+ csv_path = _generate_csv(final_h5, fasta_path, group_names)
684
+ # Move CSV next to HDF5.
685
+ final_csv = os.path.join(job_dir, "profiles.csv")
686
+ if csv_path != final_csv:
687
+ shutil.move(csv_path, final_csv)
688
+ log("Wrote profiles.h5 and profiles.csv.")
689
+
690
+ # Sequence names + single-ref check.
691
+ fasta_entries = parse_fasta(fasta_path)
692
+ first_combined = results[0].combined
693
+ single_ref = bool(first_combined.single()) and len(fasta_entries) == 1
694
+ ref_sequence = fasta_entries[0][1] if single_ref else None
695
+ sequence_names_per_group: dict[str, list[str]] = {}
696
+
697
+ # Per-group plots: profile, heatmap, termination, coverage, ... (first ref).
698
+ for r in results:
699
+ gname = r.group.name
700
+ group_plot_dir = os.path.join(job_dir, "groups", gname)
701
+ os.makedirs(group_plot_dir, exist_ok=True)
702
+ plots = _build_plots_for_group(
703
+ r.mod, r.nomod, r.combined, gname, sequence=ref_sequence,
704
+ )
705
+ for key, fig in plots.items():
706
+ _save_plot_json(fig, os.path.join(group_plot_dir, f"{key}.json"))
707
+
708
+ # Combined profile across groups (only when single-ref + >1 group).
709
+ has_combined = False
710
+ if single_ref and len(results) > 1:
711
+ from cmuts.visualize.plotly import plot_profiles
712
+ reactivities = [np.asarray(r.combined.reactivity)[0] for r in results]
713
+ combined_fig = plot_profiles(reactivities, group_names, sequence=ref_sequence)
714
+ _save_plot_json(combined_fig, os.path.join(job_dir, "combined_profile.json"))
715
+ has_combined = True
716
+
717
+ # Per-group sequence names + stats.
718
+ stats: dict[str, list[list[str]]] = {}
719
+ with h5py.File(final_h5, "r") as f:
720
+ sequences = None
721
+ if "sequence" in f:
722
+ sequences = [
723
+ s.decode() if isinstance(s, bytes) else s for s in f["sequence"]
724
+ ]
725
+ for gname in group_names:
726
+ reactivity = np.array(f[gname]["reactivity"])
727
+ seq_names = build_seq_names(reactivity.shape[0], sequences)
728
+ sequence_names_per_group[gname] = seq_names
729
+ stats[gname] = _build_stats_for_group(f[gname], reactivity.shape[0])
730
+
731
+ # Optional CIF visualization (per-group defattrs).
732
+ defattr_files: list[str] = []
733
+ chimerax_md = ""
734
+ if cif_path is not None and ref_sequence is not None:
735
+ defattr_dir = os.path.join(job_dir, "defattr")
736
+ os.makedirs(defattr_dir, exist_ok=True)
737
+ cif_workdir_path = os.path.join(workdir, os.path.basename(cif_path))
738
+ shutil.copy(cif_path, cif_workdir_path)
739
+ paths, md = _build_defattrs(cif_workdir_path, ref_sequence, results, defattr_dir)
740
+ defattr_files = [os.path.basename(p) for p in paths]
741
+ chimerax_md = md
742
+ if defattr_files:
743
+ log(f"Wrote {len(defattr_files)} defattr file(s).")
744
+ elif cif_path is not None:
745
+ log(
746
+ "Skipping structure visualization: defattr generation requires "
747
+ "a single-reference FASTA."
748
+ )
749
+
750
+ meta = {
751
+ "job_id": job_id,
752
+ "group_names": group_names,
753
+ "sequence_names": sequence_names_per_group,
754
+ "single_ref": single_ref,
755
+ "has_combined_profile": has_combined,
756
+ "ref_count": int(np.array(results[0].combined.reactivity).shape[0]),
757
+ "is_pairwise": core_cfg.compute_pairwise,
758
+ "stats": stats,
759
+ "defattr_files": defattr_files,
760
+ "chimerax_md": chimerax_md,
761
+ "created_at": time.time(),
762
+ }
763
+ write_meta(job_dir, meta)
764
+ write_log(job_dir, state.log_lines)
765
+
766
+ log(f"\nDone. Generated profiles for {len(results)} group(s).")
767
+ state.status = "done"
768
+
769
+ except Exception as e: # noqa: BLE001
770
+ msg = str(e) or "Unexpected error. See log for details."
771
+ log(f"Error: {msg}")
772
+ log(traceback.format_exc())
773
+ state.error = msg
774
+ state.status = "error"
775
+ # Still persist meta + log so the results page can show what happened.
776
+ write_meta(job_dir, {
777
+ "job_id": job_id,
778
+ "status": "error",
779
+ "error": msg,
780
+ "created_at": time.time(),
781
+ })
782
+ write_log(job_dir, state.log_lines)
783
+ finally:
784
+ shutil.rmtree(workdir, ignore_errors=True)
pyproject.toml ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "cmuts-space"
3
+ version = "0.1.0"
4
+ description = "Web UI for the cmuts RNA chemical-probing pipeline."
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "fastapi>=0.110",
8
+ "uvicorn[standard]>=0.27",
9
+ "jinja2>=3.1",
10
+ "python-multipart>=0.0.9",
11
+ "plotly>=5.20",
12
+ "kaleido==0.2.1",
13
+ "h5py>=3.10",
14
+ "numpy>=1.26",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ # Install with `uv sync --extra cmuts` once the cmuts repo is built locally.
19
+ # Skip this group if you only want to work on the UI; the pipeline will
20
+ # return a clean error.
21
+ cmuts = ["cmuts"]
22
+
23
+ [tool.uv.sources]
24
+ cmuts = { path = "../cmuts" }
25
+
static/app.css ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ color-scheme: dark;
3
+ --fg: #e6e9ef;
4
+ --muted: #8b94a5;
5
+ --bg: #14161a;
6
+ --bg-soft: #1d2027;
7
+ --bg-elev: #232732;
8
+ --border: #2c313c;
9
+ --primary: #c4a7ff;
10
+ --primary-strong: #b794f6;
11
+ --primary-fg: #14161a;
12
+ --error-bg: #2a1418;
13
+ --error-fg: #f5808d;
14
+ --radius: 6px;
15
+ }
16
+
17
+ * { box-sizing: border-box; }
18
+
19
+ html, body {
20
+ margin: 0;
21
+ padding: 0;
22
+ font: 14px/1.45 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
23
+ color: var(--fg);
24
+ background: var(--bg);
25
+ }
26
+
27
+ main {
28
+ max-width: 1100px;
29
+ margin: 0 auto;
30
+ padding: 1.5rem 1rem 3rem;
31
+ }
32
+
33
+ .site-header {
34
+ display: flex;
35
+ align-items: center;
36
+ justify-content: space-between;
37
+ padding: 0.75rem 1.25rem;
38
+ border-bottom: 1px solid var(--border);
39
+ }
40
+ .site-header .brand {
41
+ font-weight: 700;
42
+ font-size: 1.1rem;
43
+ text-decoration: none;
44
+ color: var(--fg);
45
+ }
46
+ .site-header nav a {
47
+ margin-left: 1rem;
48
+ color: var(--muted);
49
+ text-decoration: none;
50
+ }
51
+ .site-header nav a:hover { color: var(--fg); }
52
+
53
+ .site-footer {
54
+ text-align: center;
55
+ color: var(--muted);
56
+ padding: 1rem;
57
+ border-top: 1px solid var(--border);
58
+ }
59
+
60
+ h1, h2, h3 { line-height: 1.2; margin-top: 0; }
61
+ h1 { font-size: 1.5rem; }
62
+ h2 { font-size: 1.2rem; }
63
+ h3 { font-size: 1.0rem; color: var(--muted); }
64
+
65
+ .muted { color: var(--muted); }
66
+ .hint { color: var(--muted); font-size: 0.9rem; }
67
+
68
+ a { color: var(--primary); }
69
+
70
+ .card {
71
+ border: 1px solid var(--border);
72
+ border-radius: var(--radius);
73
+ padding: 1rem 1.25rem;
74
+ margin-bottom: 1.25rem;
75
+ background: var(--bg-soft);
76
+ }
77
+ fieldset.card { display: block; }
78
+ fieldset.card legend {
79
+ font-weight: 600;
80
+ padding: 0 0.4rem;
81
+ }
82
+
83
+ .intro { margin-bottom: 1rem; }
84
+ .intro h1 { margin-bottom: 0.25rem; }
85
+
86
+ label { display: block; margin: 0.4rem 0; }
87
+ label.check { display: flex; align-items: center; gap: 0.4rem; }
88
+
89
+ input[type="text"], input[type="number"], select {
90
+ width: 100%;
91
+ padding: 0.4rem 0.5rem;
92
+ font: inherit;
93
+ color: var(--fg);
94
+ border: 1px solid var(--border);
95
+ border-radius: var(--radius);
96
+ background: var(--bg-elev);
97
+ }
98
+ input[type="text"]:focus, input[type="number"]:focus, select:focus,
99
+ input[type="file"]:focus {
100
+ outline: 2px solid var(--primary);
101
+ outline-offset: -1px;
102
+ }
103
+
104
+ input[type="file"] {
105
+ display: block;
106
+ margin-top: 0.25rem;
107
+ color: var(--muted);
108
+ font: inherit;
109
+ }
110
+ input[type="file"]::file-selector-button {
111
+ font: inherit;
112
+ margin-right: 0.6rem;
113
+ padding: 0.35rem 0.7rem;
114
+ border-radius: var(--radius);
115
+ border: 1px solid var(--border);
116
+ background: var(--bg-elev);
117
+ color: var(--fg);
118
+ cursor: pointer;
119
+ }
120
+ input[type="file"]::file-selector-button:hover {
121
+ background: var(--border);
122
+ }
123
+
124
+ .file-pick { display: block; }
125
+ .file-pick > span { font-weight: 500; }
126
+
127
+ .grid {
128
+ display: grid;
129
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
130
+ gap: 0.5rem 1rem;
131
+ margin-top: 0.5rem;
132
+ }
133
+
134
+ details {
135
+ border-top: 1px solid var(--border);
136
+ margin-top: 0.5rem;
137
+ padding-top: 0.5rem;
138
+ }
139
+ details:first-of-type { border-top: 0; padding-top: 0; }
140
+ summary {
141
+ cursor: pointer;
142
+ font-weight: 600;
143
+ padding: 0.25rem 0;
144
+ }
145
+
146
+ .group-row {
147
+ display: grid;
148
+ grid-template-columns: minmax(160px, 1fr) 2fr 2fr auto;
149
+ gap: 0.5rem;
150
+ align-items: end;
151
+ padding: 0.5rem;
152
+ border: 1px dashed var(--border);
153
+ border-radius: var(--radius);
154
+ margin-bottom: 0.5rem;
155
+ }
156
+ .group-row .file-pick { margin: 0; }
157
+
158
+ button, .button {
159
+ display: inline-block;
160
+ font: inherit;
161
+ border: 1px solid var(--border);
162
+ background: var(--bg-elev);
163
+ color: var(--fg);
164
+ border-radius: var(--radius);
165
+ padding: 0.4rem 0.8rem;
166
+ cursor: pointer;
167
+ text-decoration: none;
168
+ text-align: center;
169
+ transition: background 0.12s ease, border-color 0.12s ease;
170
+ }
171
+ button:hover, .button:hover {
172
+ background: var(--border);
173
+ border-color: var(--primary);
174
+ }
175
+ button.primary, .button.primary {
176
+ background: var(--primary);
177
+ color: var(--primary-fg);
178
+ border-color: var(--primary);
179
+ font-weight: 600;
180
+ }
181
+ button.primary:hover, .button.primary:hover {
182
+ background: var(--primary-strong);
183
+ border-color: var(--primary-strong);
184
+ }
185
+ button.secondary { background: var(--bg-elev); }
186
+ button.remove-row { padding: 0.4rem 0.7rem; }
187
+
188
+ .row-actions {
189
+ display: flex;
190
+ gap: 0.5rem;
191
+ margin-top: 0.5rem;
192
+ }
193
+
194
+ .submit-row {
195
+ display: flex;
196
+ justify-content: flex-end;
197
+ margin-bottom: 1rem;
198
+ }
199
+
200
+ .results-header {
201
+ display: flex;
202
+ justify-content: space-between;
203
+ align-items: flex-end;
204
+ gap: 1rem;
205
+ margin-bottom: 1rem;
206
+ flex-wrap: wrap;
207
+ }
208
+ .header-actions { display: flex; gap: 0.4rem; }
209
+
210
+ .selectors { display: flex; gap: 1rem; margin-bottom: 1rem; }
211
+ .selectors label { flex: 0 1 auto; }
212
+ .selectors select { min-width: 220px; }
213
+
214
+ .stats { width: 100%; border-collapse: collapse; margin-bottom: 1.5rem; }
215
+ .stats td { padding: 0.25rem 0.5rem; border-bottom: 1px solid var(--border); }
216
+ .stats td:first-child { color: var(--muted); width: 18rem; }
217
+
218
+ .plot-grid {
219
+ display: grid;
220
+ grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
221
+ gap: 1rem;
222
+ }
223
+ .plot-tile {
224
+ border: 1px solid var(--border);
225
+ border-radius: var(--radius);
226
+ padding: 0.75rem;
227
+ background: #ffffff;
228
+ color: #1c1f24;
229
+ overflow: hidden;
230
+ min-width: 0;
231
+ }
232
+ .plot-tile h3 { margin-bottom: 0.5rem; color: #4a525f; }
233
+ .plot { width: 100%; height: 380px; min-width: 0; }
234
+ .plot .js-plotly-plot, .plot .plot-container { width: 100% !important; }
235
+
236
+ pre.log {
237
+ background: #0b0d12;
238
+ color: #d6e1f0;
239
+ border: 1px solid var(--border);
240
+ padding: 0.75rem;
241
+ border-radius: var(--radius);
242
+ overflow-x: auto;
243
+ font-size: 0.85rem;
244
+ white-space: pre-wrap;
245
+ max-height: 30rem;
246
+ }
247
+
248
+ pre.markdown {
249
+ background: var(--bg-elev);
250
+ border: 1px solid var(--border);
251
+ padding: 0.75rem;
252
+ border-radius: var(--radius);
253
+ white-space: pre-wrap;
254
+ color: var(--fg);
255
+ }
256
+
257
+ .error { border-color: var(--error-fg); background: var(--error-bg); }
258
+ .error h1 { color: var(--error-fg); }
259
+ .error-msg { color: var(--error-fg); font-weight: 600; }
260
+
261
+ a { color: var(--primary); }
262
+ a:hover { color: var(--primary-strong); }
263
+
264
+ h3 { color: var(--fg); opacity: 0.85; }
265
+
266
+ .group-row {
267
+ background: var(--bg-elev);
268
+ border-color: var(--border);
269
+ }
270
+
271
+ .stats td { border-color: var(--border); }
272
+ .stats td:first-child { color: var(--muted); }
273
+
274
+ .downloads { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem; }
static/app.js ADDED
@@ -0,0 +1 @@
 
 
1
+ // Global helpers. Page-specific code lives in dedicated files.
static/results.js ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Results page: per-group + per-sequence plot navigation.
2
+
3
+ (function () {
4
+ const meta = JSON.parse(document.getElementById('meta-data').textContent);
5
+ const jobId = window.location.pathname.split('/').filter(Boolean)[1];
6
+
7
+ const groupSelect = document.getElementById('group-select');
8
+ const seqSelect = document.getElementById('seq-select');
9
+ const seqWrapper = document.getElementById('seq-select-wrapper');
10
+ const statsTable = document.getElementById('stats-table');
11
+ const tiles = Array.from(document.querySelectorAll('.plot-tile'));
12
+
13
+ // Plots whose data is per-reference (sequence selector applies).
14
+ const PER_REF = new Set(['profile', 'mi', 'correlation', 'pairwise_coverage']);
15
+
16
+ function emptyTile(tile, msg) {
17
+ const plot = tile.querySelector('.plot');
18
+ plot.innerHTML = `<p class="muted">${msg}</p>`;
19
+ }
20
+
21
+ function normalizeLayout(layout) {
22
+ // Strip fixed width/height so the container drives sizing. Everything
23
+ // else (theme, fonts, axes, colors) is left exactly as cmuts emits.
24
+ const out = Object.assign({}, layout || {});
25
+ delete out.width;
26
+ delete out.height;
27
+ out.autosize = true;
28
+ return out;
29
+ }
30
+
31
+ async function renderPlot(tile, group, key, seq) {
32
+ const plotEl = tile.querySelector('.plot');
33
+ tile.style.display = '';
34
+ plotEl.innerHTML = '<p class="muted">Loading…</p>';
35
+ const url = `/results/${jobId}/plot/${encodeURIComponent(group)}/${encodeURIComponent(key)}?seq=${seq}`;
36
+ try {
37
+ const resp = await fetch(url);
38
+ if (resp.status === 404) {
39
+ tile.style.display = 'none';
40
+ return;
41
+ }
42
+ if (!resp.ok) {
43
+ emptyTile(tile, `Failed to load (HTTP ${resp.status}).`);
44
+ return;
45
+ }
46
+ const fig = await resp.json();
47
+ if (typeof Plotly === 'undefined') {
48
+ emptyTile(tile, 'Plotly library failed to load.');
49
+ return;
50
+ }
51
+ plotEl.innerHTML = '';
52
+ await Plotly.newPlot(
53
+ plotEl, fig.data, normalizeLayout(fig.layout),
54
+ {responsive: true, displaylogo: false},
55
+ );
56
+ } catch (e) {
57
+ console.error('Plot render failed', key, group, e);
58
+ emptyTile(tile, `Error: ${e.message || e}`);
59
+ }
60
+ }
61
+
62
+ function renderStats(group) {
63
+ const rows = (meta.stats && meta.stats[group]) || [];
64
+ statsTable.innerHTML = rows.map(r =>
65
+ `<tr><td>${r[0]}</td><td>${r[1]}</td></tr>`
66
+ ).join('');
67
+ }
68
+
69
+ function refreshSeqOptions(group) {
70
+ const names = (meta.sequence_names && meta.sequence_names[group]) || [];
71
+ seqSelect.innerHTML = names.map((n, i) =>
72
+ `<option value="${i}">${escapeHtml(n)}</option>`
73
+ ).join('');
74
+ if (names.length > 1) {
75
+ seqWrapper.style.display = '';
76
+ } else {
77
+ seqWrapper.style.display = 'none';
78
+ }
79
+ }
80
+
81
+ function escapeHtml(s) {
82
+ return String(s).replace(/[&<>"']/g, c => ({
83
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
84
+ }[c]));
85
+ }
86
+
87
+ async function renderCombined() {
88
+ const el = document.getElementById('plot-combined');
89
+ if (!el) return;
90
+ try {
91
+ const resp = await fetch(`/results/${jobId}/plot/combined`);
92
+ if (!resp.ok) return;
93
+ const fig = await resp.json();
94
+ Plotly.newPlot(el, fig.data, fig.layout, {responsive: true, displaylogo: false});
95
+ } catch (e) { /* ignore */ }
96
+ }
97
+
98
+ async function refreshAll() {
99
+ const group = groupSelect.value;
100
+ const seq = parseInt(seqSelect.value || '0', 10);
101
+ renderStats(group);
102
+ await Promise.all(tiles.map(tile =>
103
+ renderPlot(tile, group, tile.dataset.key, PER_REF.has(tile.dataset.key) ? seq : 0)
104
+ ));
105
+ }
106
+
107
+ async function refreshPerRef() {
108
+ const group = groupSelect.value;
109
+ const seq = parseInt(seqSelect.value || '0', 10);
110
+ await Promise.all(tiles
111
+ .filter(t => PER_REF.has(t.dataset.key))
112
+ .map(tile => renderPlot(tile, group, tile.dataset.key, seq))
113
+ );
114
+ }
115
+
116
+ groupSelect.addEventListener('change', () => {
117
+ refreshSeqOptions(groupSelect.value);
118
+ refreshAll();
119
+ });
120
+ seqSelect.addEventListener('change', refreshPerRef);
121
+
122
+ refreshSeqOptions(groupSelect.value);
123
+ renderCombined();
124
+ refreshAll();
125
+ })();
templates/_group_row.html ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="group-row">
2
+ <input type="text" name="group_name"
3
+ placeholder="Group name (e.g. 2A3_with_cdiGMP)"
4
+ value="{{ (initial.name if initial else '') }}">
5
+ <label class="file-pick">
6
+ <span>Modified FASTQ (required)</span>
7
+ <input type="file" name="mod_fastq" accept=".fastq,.fq,.gz">
8
+ </label>
9
+ <label class="file-pick">
10
+ <span>Control FASTQ (optional)</span>
11
+ <input type="file" name="nomod_fastq" accept=".fastq,.fq,.gz">
12
+ </label>
13
+ <button type="button" class="remove-row"
14
+ onclick="this.closest('.group-row').remove()">Remove</button>
15
+ </div>
templates/base.html ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>{% block title %}cmuts — RNA Chemical Probing Analysis{% endblock %}</title>
7
+ <link rel="stylesheet" href="/static/app.css">
8
+ <script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
9
+ <script src="https://cdn.plot.ly/plotly-3.0.1.min.js" defer></script>
10
+ </head>
11
+ <body>
12
+ <header class="site-header">
13
+ <a href="/" class="brand">cmuts</a>
14
+ <nav>
15
+ <a href="https://github.com/hmblair/cmuts">GitHub</a>
16
+ <a href="https://hmblair.github.io/cmuts">Docs</a>
17
+ </nav>
18
+ </header>
19
+ <main>
20
+ {% block content %}{% endblock %}
21
+ </main>
22
+ <footer class="site-footer">
23
+ <span>MIT licensed</span>
24
+ </footer>
25
+ {% block scripts %}{% endblock %}
26
+ </body>
27
+ </html>
templates/index.html ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block content %}
3
+ <section class="intro">
4
+ <h1>cmuts — RNA Chemical Probing Analysis</h1>
5
+ <p>
6
+ Upload a FASTA reference and FASTQ file(s) from a MaP-seq experiment to
7
+ compute normalized reactivity profiles. Use <strong>+ Add group</strong>
8
+ to compare multiple conditions — normalization is applied across all
9
+ groups so values are directly comparable.
10
+ </p>
11
+ </section>
12
+
13
+ <form id="run-form" method="post" action="/run" enctype="multipart/form-data">
14
+ <fieldset class="card">
15
+ <legend>Input data</legend>
16
+ <label class="file-pick">
17
+ <span>Reference FASTA (required)</span>
18
+ <input type="file" name="fasta" accept=".fasta,.fa" required>
19
+ </label>
20
+ <label class="file-pick">
21
+ <span>Reference structure CIF (optional, single-reference FASTAs only)</span>
22
+ <input type="file" name="cif" accept=".cif">
23
+ </label>
24
+ </fieldset>
25
+
26
+ <fieldset class="card">
27
+ <legend>Experiment groups</legend>
28
+ <div id="groups">
29
+ {% include "_group_row.html" %}
30
+ </div>
31
+ <div class="row-actions">
32
+ <button type="button" class="secondary"
33
+ hx-get="/group-row"
34
+ hx-target="#groups"
35
+ hx-swap="beforeend">+ Add group</button>
36
+ <button type="button" class="secondary"
37
+ onclick="document.getElementById('example-form').submit()">Run with example data</button>
38
+ </div>
39
+ </fieldset>
40
+
41
+ <fieldset class="card">
42
+ <legend>Options</legend>
43
+
44
+ <details>
45
+ <summary>Alignment</summary>
46
+ <p class="hint">If no adapter sequences are provided, cmuts will attempt to recognize adapters outside the reference sequence and auto-trim.</p>
47
+ <div class="grid">
48
+ <label>5' adapter <input type="text" name="trim_5" placeholder="auto-detected if blank"></label>
49
+ <label>3' adapter <input type="text" name="trim_3" placeholder="auto-detected if blank"></label>
50
+ <label class="check"><input type="checkbox" name="local_align" value="true"> Local alignment</label>
51
+ </div>
52
+ </details>
53
+
54
+ <details>
55
+ <summary>Read filtering</summary>
56
+ <div class="grid">
57
+ <label>Min mapping quality <input type="number" name="min_mapq" value="10" min="0" max="60"></label>
58
+ <label>Min PHRED <input type="number" name="min_phred" value="10" min="0" max="40"></label>
59
+ <label>Min read length <input type="number" name="min_length" value="2" min="0"></label>
60
+ <label>Max read length <input type="number" name="max_length" value="1024" min="0"></label>
61
+ <label class="check"><input type="checkbox" name="no_mismatches" value="true"> Exclude mismatches</label>
62
+ <label>Strand
63
+ <select name="strand">
64
+ <option value="both" selected>both</option>
65
+ <option value="forward">forward</option>
66
+ <option value="reverse">reverse</option>
67
+ </select>
68
+ </label>
69
+ </div>
70
+ </details>
71
+
72
+ <details>
73
+ <summary>Normalization</summary>
74
+ <div class="grid">
75
+ <label>Method
76
+ <select name="norm_method">
77
+ <option value="ubr" selected>ubr</option>
78
+ <option value="outlier">outlier</option>
79
+ <option value="raw">raw</option>
80
+ </select>
81
+ </label>
82
+ <label class="check"><input type="checkbox" name="no_insertions" value="true" checked> Exclude insertions</label>
83
+ <label class="check"><input type="checkbox" name="no_deletions" value="true"> Exclude deletions</label>
84
+ <label class="check"><input type="checkbox" name="clip_low" value="true"> Clip negative</label>
85
+ <label class="check"><input type="checkbox" name="clip_high" value="true"> Clip above 1</label>
86
+ <label>Blank 5' bases <input type="number" name="blank_5p" value="0" min="0"></label>
87
+ <label>Blank 3' bases <input type="number" name="blank_3p" value="0" min="0"></label>
88
+ <label>Min reads / position <input type="number" name="blank_cutoff" value="10" min="0"></label>
89
+ <label>Min reads for norm <input type="number" name="norm_cutoff" value="500" min="0"></label>
90
+ <label>Norm percentile <input type="number" name="norm_percentile" value="90" min="50" max="100"></label>
91
+ </div>
92
+ </details>
93
+
94
+ <details>
95
+ <summary>Pairwise analysis</summary>
96
+ <p class="hint">O(L²) in sequence length, slow for long references.</p>
97
+ <label class="check"><input type="checkbox" name="compute_pairwise" value="true"> Compute pairwise correlations</label>
98
+ <label>Significance (Bonferroni) <input type="number" name="sig" value="0.05" min="0.001" max="0.1" step="0.001"></label>
99
+ </details>
100
+ </fieldset>
101
+
102
+ <div class="submit-row">
103
+ <button type="submit" class="primary">Run Pipeline</button>
104
+ </div>
105
+ </form>
106
+
107
+ <form id="example-form" method="post" action="/run-example" style="display:none"></form>
108
+
109
+ <p class="hint">FASTQ files larger than {{ max_fastq_mb }} MB may exceed memory limits. Results persist for {{ ttl_hours }} hours.</p>
110
+ {% endblock %}
templates/results.html ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block content %}
3
+
4
+ {% if status == "missing" %}
5
+ <section class="card">
6
+ <h1>Result not found</h1>
7
+ <p>This job ID is unknown, or the results have expired
8
+ (kept for {{ ttl_hours }} hours).</p>
9
+ <p><a href="/">Return to the form</a></p>
10
+ </section>
11
+
12
+ {% elif status == "running" %}
13
+ <section class="card" id="running-card">
14
+ <h1>Running — job {{ job_id }}</h1>
15
+ <p>This page will reload automatically when the pipeline finishes.</p>
16
+ <details open>
17
+ <summary>Log</summary>
18
+ <pre id="live-log" class="log">{{ log }}</pre>
19
+ </details>
20
+ </section>
21
+ <script>
22
+ (function poll() {
23
+ const jobId = {{ job_id | tojson }};
24
+ fetch(`/results/${jobId}/status`).then(r => r.json()).then(s => {
25
+ document.getElementById('live-log').textContent = s.log || '';
26
+ if (s.status === 'done' || s.status === 'error') {
27
+ window.location.reload();
28
+ } else {
29
+ setTimeout(poll, 1500);
30
+ }
31
+ }).catch(() => setTimeout(poll, 3000));
32
+ })();
33
+ </script>
34
+
35
+ {% elif status == "error" %}
36
+ <section class="card error">
37
+ <h1>Pipeline failed — job {{ job_id }}</h1>
38
+ <p class="error-msg">{{ error or "See log for details." }}</p>
39
+ <details open>
40
+ <summary>Log</summary>
41
+ <pre class="log">{{ log }}</pre>
42
+ </details>
43
+ <p><a href="/">Return to the form</a></p>
44
+ </section>
45
+
46
+ {% else %}
47
+ <section class="results-header">
48
+ <div>
49
+ <h1>Results — job {{ job_id }}</h1>
50
+ <p class="muted">
51
+ Bookmark this URL to come back later
52
+ (kept for {{ ttl_hours }} hours).
53
+ </p>
54
+ </div>
55
+ <div class="header-actions">
56
+ <a class="button" href="/results/{{ job_id }}/download/h5">HDF5</a>
57
+ <a class="button" href="/results/{{ job_id }}/download/csv">CSV</a>
58
+ <a class="button primary" href="/results/{{ job_id }}/download/all">Download all</a>
59
+ </div>
60
+ </section>
61
+
62
+ {% if meta.has_combined_profile %}
63
+ <section class="card">
64
+ <h2>Combined reactivity profile</h2>
65
+ <div id="plot-combined" class="plot"></div>
66
+ </section>
67
+ {% endif %}
68
+
69
+ <section class="card">
70
+ <h2>Per-group diagnostics</h2>
71
+
72
+ <div class="selectors">
73
+ <label>Group
74
+ <select id="group-select">
75
+ {% for gn in meta.group_names %}
76
+ <option value="{{ gn }}">{{ gn }}</option>
77
+ {% endfor %}
78
+ </select>
79
+ </label>
80
+ <label id="seq-select-wrapper" style="display:none">Sequence
81
+ <select id="seq-select"></select>
82
+ </label>
83
+ </div>
84
+
85
+ <h3>Summary statistics</h3>
86
+ <table class="stats" id="stats-table"></table>
87
+
88
+ <div class="plot-grid">
89
+ <div class="plot-tile" data-key="profile">
90
+ <h3>Reactivity profile</h3>
91
+ <div class="plot"></div>
92
+ </div>
93
+ <div class="plot-tile" data-key="mod_heatmap">
94
+ <h3>Modification heatmap</h3>
95
+ <div class="plot"></div>
96
+ </div>
97
+ <div class="plot-tile" data-key="termination">
98
+ <h3>Termination by position</h3>
99
+ <div class="plot"></div>
100
+ </div>
101
+ <div class="plot-tile" data-key="coverage">
102
+ <h3>Coverage by position</h3>
103
+ <div class="plot"></div>
104
+ </div>
105
+ <div class="plot-tile" data-key="read_hist">
106
+ <h3>Read depth distribution</h3>
107
+ <div class="plot"></div>
108
+ </div>
109
+ <div class="plot-tile" data-key="cumulative_reads">
110
+ <h3>Cumulative reads</h3>
111
+ <div class="plot"></div>
112
+ </div>
113
+ <div class="plot-tile" data-key="snr_scaling">
114
+ <h3>SNR vs read depth</h3>
115
+ <div class="plot"></div>
116
+ </div>
117
+ <div class="plot-tile" data-key="mi">
118
+ <h3>Mutual information</h3>
119
+ <div class="plot"></div>
120
+ </div>
121
+ <div class="plot-tile" data-key="correlation">
122
+ <h3>Correlation</h3>
123
+ <div class="plot"></div>
124
+ </div>
125
+ <div class="plot-tile" data-key="pairwise_coverage">
126
+ <h3>Pairwise coverage</h3>
127
+ <div class="plot"></div>
128
+ </div>
129
+ </div>
130
+ </section>
131
+
132
+ {% if meta.chimerax_md %}
133
+ <section class="card">
134
+ <h2>Structure visualization</h2>
135
+ <pre class="markdown">{{ meta.chimerax_md }}</pre>
136
+ <div class="downloads">
137
+ {% for name in meta.defattr_files %}
138
+ <a class="button" href="/results/{{ job_id }}/download/defattr/{{ name }}">{{ name }}</a>
139
+ {% endfor %}
140
+ </div>
141
+ </section>
142
+ {% endif %}
143
+
144
+ <details class="card">
145
+ <summary><h2 style="display:inline">Log</h2></summary>
146
+ <pre class="log">{{ log }}</pre>
147
+ </details>
148
+
149
+ <script id="meta-data" type="application/json">{{ meta | tojson }}</script>
150
+ <script src="/static/results.js" defer></script>
151
+ {% endif %}
152
+
153
+ {% endblock %}
uv.lock ADDED
The diff for this file is too large to render. See raw diff