Spaces:
Sleeping
Rewrite as FastAPI + Jinja + HTMX, replacing Gradio
Browse filesThe 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.
- .gitignore +4 -0
- Dockerfile +12 -5
- README.md +57 -0
- app.py +460 -1602
- pipeline.py +784 -0
- pyproject.toml +25 -0
- static/app.css +274 -0
- static/app.js +1 -0
- static/results.js +125 -0
- templates/_group_row.html +15 -0
- templates/base.html +27 -0
- templates/index.html +110 -0
- templates/results.html +153 -0
- uv.lock +0 -0
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
.DS_Store
|
|
@@ -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
|
| 48 |
-
RUN pip install --no-cache-dir
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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/
|
|
|
|
|
|
|
| 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"]
|
|
@@ -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
|
|
@@ -1,1676 +1,534 @@
|
|
| 1 |
-
|
| 2 |
-
|
|
|
|
|
|
|
| 3 |
|
| 4 |
from __future__ import annotations
|
| 5 |
|
| 6 |
-
import
|
| 7 |
-
import
|
| 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 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
import
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 124 |
-
|
| 125 |
-
|
| 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 |
-
|
| 378 |
-
|
| 379 |
-
|
| 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 |
-
|
| 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 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 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
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 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 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 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 |
-
|
| 964 |
-
|
| 965 |
-
|
| 966 |
-
|
| 967 |
-
|
| 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 |
-
|
| 1023 |
-
|
| 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
|
| 1037 |
-
|
| 1038 |
-
|
| 1039 |
-
|
| 1040 |
-
|
| 1041 |
-
|
| 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 |
-
|
| 1079 |
-
|
| 1080 |
-
|
| 1081 |
-
|
| 1082 |
-
|
| 1083 |
-
|
| 1084 |
-
|
| 1085 |
-
|
| 1086 |
-
|
| 1087 |
-
|
| 1088 |
-
|
| 1089 |
-
|
| 1090 |
-
|
| 1091 |
-
|
| 1092 |
-
|
| 1093 |
-
|
| 1094 |
-
|
| 1095 |
-
|
| 1096 |
-
|
| 1097 |
-
|
| 1098 |
-
|
| 1099 |
-
|
| 1100 |
-
|
| 1101 |
-
|
| 1102 |
-
|
| 1103 |
-
|
| 1104 |
-
|
| 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` → `cmuts core` → `cmuts normalize`
|
| 1168 |
-
 | 
|
| 1169 |
-
[GitHub](https://github.com/hmblair/cmuts)
|
| 1170 |
-
 | 
|
| 1171 |
-
[Documentation](https://hmblair.github.io/cmuts)
|
| 1172 |
-
 | 
|
| 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 |
-
|
| 1192 |
-
|
| 1193 |
-
|
| 1194 |
-
|
| 1195 |
-
|
| 1196 |
-
|
| 1197 |
-
|
| 1198 |
-
|
| 1199 |
-
|
| 1200 |
-
|
| 1201 |
-
|
| 1202 |
-
|
| 1203 |
-
|
| 1204 |
-
|
| 1205 |
-
|
| 1206 |
-
|
| 1207 |
-
|
| 1208 |
-
|
| 1209 |
-
|
| 1210 |
-
|
| 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²) 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 |
-
|
| 1291 |
-
|
| 1292 |
-
|
| 1293 |
-
|
| 1294 |
-
|
| 1295 |
-
|
| 1296 |
-
|
| 1297 |
-
|
| 1298 |
-
|
| 1299 |
-
|
| 1300 |
-
|
| 1301 |
-
|
| 1302 |
-
|
| 1303 |
-
|
| 1304 |
-
|
| 1305 |
-
|
| 1306 |
-
|
| 1307 |
-
|
| 1308 |
-
|
| 1309 |
-
|
| 1310 |
-
|
| 1311 |
-
|
| 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 |
-
|
| 1323 |
-
|
| 1324 |
-
|
| 1325 |
-
|
| 1326 |
-
|
| 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 |
-
|
| 1400 |
-
|
| 1401 |
-
|
| 1402 |
-
|
| 1403 |
-
|
| 1404 |
-
|
| 1405 |
-
|
| 1406 |
-
|
| 1407 |
-
|
| 1408 |
-
|
| 1409 |
-
|
| 1410 |
-
|
| 1411 |
-
|
| 1412 |
-
|
| 1413 |
-
|
| 1414 |
-
|
| 1415 |
-
|
| 1416 |
-
|
| 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 |
-
|
| 1439 |
-
|
| 1440 |
-
|
| 1441 |
-
outputs=[output_plot, mi_plot, correlation_plot, pairwise_coverage_plot],
|
| 1442 |
-
)
|
| 1443 |
|
| 1444 |
-
|
| 1445 |
-
|
| 1446 |
-
|
| 1447 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1448 |
)
|
| 1449 |
-
|
| 1450 |
-
|
| 1451 |
-
|
| 1452 |
-
|
| 1453 |
-
|
| 1454 |
-
|
| 1455 |
-
|
| 1456 |
-
|
| 1457 |
-
|
| 1458 |
-
|
| 1459 |
-
|
| 1460 |
-
|
| 1461 |
-
|
| 1462 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1498 |
-
|
| 1499 |
-
|
| 1500 |
-
|
| 1501 |
-
|
| 1502 |
-
|
| 1503 |
-
|
| 1504 |
-
|
| 1505 |
-
|
| 1506 |
-
|
| 1507 |
-
|
| 1508 |
-
|
| 1509 |
-
|
| 1510 |
-
|
| 1511 |
-
|
| 1512 |
-
|
| 1513 |
-
|
| 1514 |
-
|
| 1515 |
-
|
| 1516 |
-
|
| 1517 |
-
|
| 1518 |
-
|
| 1519 |
-
|
| 1520 |
-
|
| 1521 |
-
|
| 1522 |
-
|
| 1523 |
-
|
| 1524 |
-
|
| 1525 |
-
|
| 1526 |
-
|
| 1527 |
-
|
| 1528 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1529 |
|
| 1530 |
-
## Adapter trimming
|
| 1531 |
|
| 1532 |
-
|
| 1533 |
-
cmuts will attempt to recognize adapter sequences outside the
|
| 1534 |
-
reference and auto-trim them.
|
| 1535 |
|
| 1536 |
-
## Settings
|
| 1537 |
|
| 1538 |
-
|
| 1539 |
-
|
| 1540 |
-
|
| 1541 |
-
|
| 1542 |
-
|
| 1543 |
-
|
| 1544 |
-
|
|
|
|
|
|
|
| 1545 |
|
| 1546 |
-
## Interpreting the Output
|
| 1547 |
|
| 1548 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1557 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1558 |
|
| 1559 |
-
### Output files
|
| 1560 |
|
| 1561 |
-
|
| 1562 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1563 |
|
| 1564 |
-
```python
|
| 1565 |
-
import h5py
|
| 1566 |
|
| 1567 |
-
|
| 1568 |
-
|
| 1569 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1570 |
continue
|
| 1571 |
-
|
| 1572 |
-
|
| 1573 |
-
|
| 1574 |
-
|
| 1575 |
-
|
| 1576 |
-
|
| 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 |
-
|
| 1662 |
-
|
| 1663 |
-
media_type="text/csv",
|
| 1664 |
-
filename=filename,
|
| 1665 |
-
)
|
| 1666 |
|
| 1667 |
|
| 1668 |
-
#
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
)
|
|
@@ -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)
|
|
@@ -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 |
+
|
|
@@ -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; }
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
// Global helpers. Page-specific code lives in dedicated files.
|
|
@@ -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 |
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
| 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 |
+
})();
|
|
@@ -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>
|
|
@@ -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>
|
|
@@ -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 %}
|
|
@@ -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 %}
|
|
The diff for this file is too large to render.
See raw diff
|
|
|