Add liability/QC, cluster & tree, and experiment tracking
Browse files- Dockerfile +1 -3
- core/analysis/analyzer.py +32 -0
- core/analysis/clustering.py +201 -0
- core/analysis/liability.py +237 -0
- core/analysis/motifs.py +133 -0
- demo/DEMO_SCRIPT.md +54 -0
- demo/ENPICOM_gap_analysis.md +30 -0
- demo/demo_sequences_extended.csv +15 -0
- models/runs.py +122 -0
- tests/test_clustering.py +73 -0
- tests/test_liability.py +127 -0
- tests/test_runs.py +62 -0
- ui/app.py +12 -4
- ui/components/analysis_dashboard.py +116 -0
- ui/components/cluster_view.py +183 -0
- ui/components/experiment_view.py +197 -0
- ui/components/worklist_view.py +69 -0
- ui/state.py +9 -0
Dockerfile
CHANGED
|
@@ -29,9 +29,7 @@ COPY . .
|
|
| 29 |
ENV PORT=5007 \
|
| 30 |
HOST=0.0.0.0
|
| 31 |
|
| 32 |
-
# Hugging Face Spaces runs
|
| 33 |
-
# library cache dirs at /tmp (world-writable) so Panel/Bokeh/matplotlib can
|
| 34 |
-
# write caches without permission errors.
|
| 35 |
ENV HOME=/tmp \
|
| 36 |
XDG_CACHE_HOME=/tmp/.cache \
|
| 37 |
MPLCONFIGDIR=/tmp/matplotlib \
|
|
|
|
| 29 |
ENV PORT=5007 \
|
| 30 |
HOST=0.0.0.0
|
| 31 |
|
| 32 |
+
# Hugging Face Spaces runs as UID 1000 (non-root); point caches at /tmp
|
|
|
|
|
|
|
| 33 |
ENV HOME=/tmp \
|
| 34 |
XDG_CACHE_HOME=/tmp/.cache \
|
| 35 |
MPLCONFIGDIR=/tmp/matplotlib \
|
core/analysis/analyzer.py
CHANGED
|
@@ -27,6 +27,9 @@ from core.analysis.restriction_sites import (
|
|
| 27 |
)
|
| 28 |
from core.analysis.kozak import check_kozak, KozakResult
|
| 29 |
from core.analysis.structure import predict_structure, StructureResult
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
|
| 32 |
@dataclass
|
|
@@ -68,6 +71,15 @@ class AnalysisReport:
|
|
| 68 |
# Secondary structure (ViennaRNA)
|
| 69 |
structure: Optional[StructureResult] = None
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
# Errors / warnings generated during analysis
|
| 72 |
warnings: List[str] = field(default_factory=list)
|
| 73 |
|
|
@@ -92,6 +104,12 @@ class AnalysisReport:
|
|
| 92 |
"kozak_score": self.kozak.score if self.kozak else None,
|
| 93 |
"kozak_strength": self.kozak.strength if self.kozak else None,
|
| 94 |
"mfe": self.structure.mfe if self.structure else None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
"warnings": self.warnings,
|
| 96 |
}
|
| 97 |
|
|
@@ -269,8 +287,22 @@ class SequenceAnalyzer:
|
|
| 269 |
struct_data = self.analyze_structure(full_seq)
|
| 270 |
report.structure = struct_data["structure"]
|
| 271 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
report.warnings = warnings
|
| 273 |
|
|
|
|
|
|
|
|
|
|
| 274 |
# Cache result
|
| 275 |
seq._analysis_cache[cache_key] = report
|
| 276 |
return report
|
|
|
|
| 27 |
)
|
| 28 |
from core.analysis.kozak import check_kozak, KozakResult
|
| 29 |
from core.analysis.structure import predict_structure, StructureResult
|
| 30 |
+
from core.analysis.uridine import analyze_uridine, UridineReport
|
| 31 |
+
from core.analysis.motifs import scan_motifs, MotifHit
|
| 32 |
+
from core.analysis.liability import assess_liabilities, LiabilityReport
|
| 33 |
|
| 34 |
|
| 35 |
@dataclass
|
|
|
|
| 71 |
# Secondary structure (ViennaRNA)
|
| 72 |
structure: Optional[StructureResult] = None
|
| 73 |
|
| 74 |
+
# Uridine content (immunogenicity proxy)
|
| 75 |
+
uridine: Optional[UridineReport] = None
|
| 76 |
+
|
| 77 |
+
# Sequence-liability motifs (uORF, premature polyA, ARE, splice donor)
|
| 78 |
+
motif_hits: List[MotifHit] = field(default_factory=list)
|
| 79 |
+
|
| 80 |
+
# Aggregated liability / QC assessment
|
| 81 |
+
liability: Optional[LiabilityReport] = None
|
| 82 |
+
|
| 83 |
# Errors / warnings generated during analysis
|
| 84 |
warnings: List[str] = field(default_factory=list)
|
| 85 |
|
|
|
|
| 104 |
"kozak_score": self.kozak.score if self.kozak else None,
|
| 105 |
"kozak_strength": self.kozak.strength if self.kozak else None,
|
| 106 |
"mfe": self.structure.mfe if self.structure else None,
|
| 107 |
+
"uridine_percent": self.uridine.u_percent if self.uridine else None,
|
| 108 |
+
"liability_score": self.liability.score if self.liability else None,
|
| 109 |
+
"liability_verdict": self.liability.verdict if self.liability else None,
|
| 110 |
+
"liability_critical": self.liability.n_critical if self.liability else None,
|
| 111 |
+
"liability_warning": self.liability.n_warning if self.liability else None,
|
| 112 |
+
"liability_flag_count": self.liability.flag_count if self.liability else None,
|
| 113 |
"warnings": self.warnings,
|
| 114 |
}
|
| 115 |
|
|
|
|
| 287 |
struct_data = self.analyze_structure(full_seq)
|
| 288 |
report.structure = struct_data["structure"]
|
| 289 |
|
| 290 |
+
# Uridine content (immunogenicity proxy)
|
| 291 |
+
report.uridine = analyze_uridine(full_seq)
|
| 292 |
+
|
| 293 |
+
# Sequence-liability motifs (region-aware when components are available)
|
| 294 |
+
report.motif_hits = scan_motifs(
|
| 295 |
+
five_prime_utr=seq.five_prime_utr,
|
| 296 |
+
cds=seq.cds,
|
| 297 |
+
three_prime_utr=seq.three_prime_utr,
|
| 298 |
+
full_seq=full_seq,
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
report.warnings = warnings
|
| 302 |
|
| 303 |
+
# Aggregate everything into the liability / QC assessment
|
| 304 |
+
report.liability = assess_liabilities(report, seq)
|
| 305 |
+
|
| 306 |
# Cache result
|
| 307 |
seq._analysis_cache[cache_key] = report
|
| 308 |
return report
|
core/analysis/clustering.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Sequence clustering and tree building for worklist exploration.
|
| 3 |
+
|
| 4 |
+
Provides a lightweight, dependency-light pipeline (numpy only) analogous to an
|
| 5 |
+
immune-repertoire clustering view:
|
| 6 |
+
|
| 7 |
+
1. ``kmer_distance_matrix`` β k-mer cosine distance between sequences.
|
| 8 |
+
2. ``upgma`` β average-linkage hierarchical clustering β a SciPy-style
|
| 9 |
+
``linkage`` array plus a leaf ordering for plotting a dendrogram.
|
| 10 |
+
3. ``flat_clusters`` β cut the tree at a distance threshold into flat clusters.
|
| 11 |
+
|
| 12 |
+
Everything works on raw nucleotide strings; no alignment required.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from collections import Counter
|
| 17 |
+
from dataclasses import dataclass, field
|
| 18 |
+
from typing import Dict, List, Tuple
|
| 19 |
+
|
| 20 |
+
import numpy as np
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def kmer_counts(seq: str, k: int = 4) -> Counter:
|
| 24 |
+
"""Count k-mers in a sequence (DNA alphabet, UβT)."""
|
| 25 |
+
s = (seq or "").upper().replace("U", "T")
|
| 26 |
+
if len(s) < k:
|
| 27 |
+
return Counter()
|
| 28 |
+
return Counter(s[i:i + k] for i in range(len(s) - k + 1))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _cosine_distance(a: Counter, b: Counter) -> float:
|
| 32 |
+
"""1 - cosine similarity between two k-mer count vectors."""
|
| 33 |
+
if not a or not b:
|
| 34 |
+
return 1.0
|
| 35 |
+
# dot product over shared keys
|
| 36 |
+
shared = set(a) & set(b)
|
| 37 |
+
dot = sum(a[k] * b[k] for k in shared)
|
| 38 |
+
na = np.sqrt(sum(v * v for v in a.values()))
|
| 39 |
+
nb = np.sqrt(sum(v * v for v in b.values()))
|
| 40 |
+
if na == 0 or nb == 0:
|
| 41 |
+
return 1.0
|
| 42 |
+
cos = dot / (na * nb)
|
| 43 |
+
return float(max(0.0, 1.0 - cos))
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def kmer_distance_matrix(sequences: List[str], k: int = 4) -> np.ndarray:
|
| 47 |
+
"""Symmetric pairwise k-mer cosine distance matrix (nΓn)."""
|
| 48 |
+
vecs = [kmer_counts(s, k) for s in sequences]
|
| 49 |
+
n = len(vecs)
|
| 50 |
+
d = np.zeros((n, n), dtype=float)
|
| 51 |
+
for i in range(n):
|
| 52 |
+
for j in range(i + 1, n):
|
| 53 |
+
dist = _cosine_distance(vecs[i], vecs[j])
|
| 54 |
+
d[i, j] = d[j, i] = dist
|
| 55 |
+
return d
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def upgma(dist: np.ndarray) -> Tuple[np.ndarray, List[int]]:
|
| 59 |
+
"""
|
| 60 |
+
Average-linkage (UPGMA) hierarchical clustering.
|
| 61 |
+
|
| 62 |
+
Parameters
|
| 63 |
+
----------
|
| 64 |
+
dist : np.ndarray
|
| 65 |
+
Square symmetric distance matrix (nΓn).
|
| 66 |
+
|
| 67 |
+
Returns
|
| 68 |
+
-------
|
| 69 |
+
linkage : np.ndarray
|
| 70 |
+
SciPy-style (n-1)Γ4 array: each row [id_a, id_b, height, size].
|
| 71 |
+
Leaf ids are 0..n-1; internal nodes get ids n, n+1, β¦
|
| 72 |
+
leaf_order : list[int]
|
| 73 |
+
Left-to-right leaf ordering for plotting a non-crossing dendrogram.
|
| 74 |
+
"""
|
| 75 |
+
n = dist.shape[0]
|
| 76 |
+
if n < 2:
|
| 77 |
+
return np.empty((0, 4)), list(range(n))
|
| 78 |
+
|
| 79 |
+
# active clusters: id -> (members, size). distances kept in a dict.
|
| 80 |
+
sizes: Dict[int, int] = {i: 1 for i in range(n)}
|
| 81 |
+
members: Dict[int, List[int]] = {i: [i] for i in range(n)}
|
| 82 |
+
active = list(range(n))
|
| 83 |
+
D: Dict[Tuple[int, int], float] = {}
|
| 84 |
+
for i in range(n):
|
| 85 |
+
for j in range(i + 1, n):
|
| 86 |
+
D[(i, j)] = dist[i, j]
|
| 87 |
+
|
| 88 |
+
def key(a: int, b: int) -> Tuple[int, int]:
|
| 89 |
+
return (a, b) if a < b else (b, a)
|
| 90 |
+
|
| 91 |
+
linkage = []
|
| 92 |
+
next_id = n
|
| 93 |
+
while len(active) > 1:
|
| 94 |
+
# find closest pair
|
| 95 |
+
best = None
|
| 96 |
+
best_pair = None
|
| 97 |
+
for ai in range(len(active)):
|
| 98 |
+
for bi in range(ai + 1, len(active)):
|
| 99 |
+
a, b = active[ai], active[bi]
|
| 100 |
+
dval = D[key(a, b)]
|
| 101 |
+
if best is None or dval < best:
|
| 102 |
+
best = dval
|
| 103 |
+
best_pair = (a, b)
|
| 104 |
+
a, b = best_pair
|
| 105 |
+
new = next_id
|
| 106 |
+
next_id += 1
|
| 107 |
+
sa, sb = sizes[a], sizes[b]
|
| 108 |
+
members[new] = members[a] + members[b]
|
| 109 |
+
sizes[new] = sa + sb
|
| 110 |
+
linkage.append([float(a), float(b), float(best), float(sizes[new])])
|
| 111 |
+
|
| 112 |
+
# update distances (average linkage weighted by size)
|
| 113 |
+
for c in active:
|
| 114 |
+
if c in (a, b):
|
| 115 |
+
continue
|
| 116 |
+
dac = D[key(a, c)]
|
| 117 |
+
dbc = D[key(b, c)]
|
| 118 |
+
D[key(new, c)] = (sa * dac + sb * dbc) / (sa + sb)
|
| 119 |
+
active.remove(a)
|
| 120 |
+
active.remove(b)
|
| 121 |
+
active.append(new)
|
| 122 |
+
|
| 123 |
+
# leaf order from the merge tree (recursive, left then right)
|
| 124 |
+
def leaves_of(node: int) -> List[int]:
|
| 125 |
+
return members[node]
|
| 126 |
+
|
| 127 |
+
root = active[0]
|
| 128 |
+
leaf_order = leaves_of(root)
|
| 129 |
+
return np.array(linkage, dtype=float), leaf_order
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def flat_clusters(linkage: np.ndarray, n_leaves: int, threshold: float) -> List[int]:
|
| 133 |
+
"""
|
| 134 |
+
Cut the dendrogram at ``threshold``: merge nodes joined below the threshold,
|
| 135 |
+
returning a cluster id (0-based, contiguous) per original leaf.
|
| 136 |
+
"""
|
| 137 |
+
if n_leaves == 0:
|
| 138 |
+
return []
|
| 139 |
+
parent = list(range(n_leaves + len(linkage)))
|
| 140 |
+
|
| 141 |
+
def find(x: int) -> int:
|
| 142 |
+
while parent[x] != x:
|
| 143 |
+
parent[x] = parent[parent[x]]
|
| 144 |
+
x = parent[x]
|
| 145 |
+
return x
|
| 146 |
+
|
| 147 |
+
def union(x: int, y: int) -> None:
|
| 148 |
+
parent[find(x)] = find(y)
|
| 149 |
+
|
| 150 |
+
for i, row in enumerate(linkage):
|
| 151 |
+
a, b, height = int(row[0]), int(row[1]), row[2]
|
| 152 |
+
new = n_leaves + i
|
| 153 |
+
if height <= threshold:
|
| 154 |
+
union(a, new)
|
| 155 |
+
union(b, new)
|
| 156 |
+
|
| 157 |
+
# map each leaf's root to a contiguous cluster id
|
| 158 |
+
roots = [find(i) for i in range(n_leaves)]
|
| 159 |
+
remap: Dict[int, int] = {}
|
| 160 |
+
out = []
|
| 161 |
+
for r in roots:
|
| 162 |
+
if r not in remap:
|
| 163 |
+
remap[r] = len(remap)
|
| 164 |
+
out.append(remap[r])
|
| 165 |
+
return out
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
@dataclass
|
| 169 |
+
class DendrogramLayout:
|
| 170 |
+
"""Coordinates for drawing a dendrogram."""
|
| 171 |
+
leaf_order: List[int] = field(default_factory=list)
|
| 172 |
+
leaf_x: Dict[int, float] = field(default_factory=dict) # leaf id -> x
|
| 173 |
+
# each link: (x0, x1, y0, y1) segments forming the bracket
|
| 174 |
+
segments: List[Tuple[float, float, float, float]] = field(default_factory=list)
|
| 175 |
+
node_x: Dict[int, float] = field(default_factory=dict)
|
| 176 |
+
node_y: Dict[int, float] = field(default_factory=dict)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def dendrogram_layout(linkage: np.ndarray, leaf_order: List[int]) -> DendrogramLayout:
|
| 180 |
+
"""Compute x/y coordinates + bracket segments for a horizontal-leaf dendrogram."""
|
| 181 |
+
layout = DendrogramLayout(leaf_order=list(leaf_order))
|
| 182 |
+
n = len(leaf_order)
|
| 183 |
+
# leaves on the x-axis at integer positions, y=0
|
| 184 |
+
for pos, leaf in enumerate(leaf_order):
|
| 185 |
+
layout.leaf_x[leaf] = float(pos)
|
| 186 |
+
layout.node_x[leaf] = float(pos)
|
| 187 |
+
layout.node_y[leaf] = 0.0
|
| 188 |
+
|
| 189 |
+
for i, row in enumerate(linkage):
|
| 190 |
+
a, b, height = int(row[0]), int(row[1]), float(row[2])
|
| 191 |
+
new = n + i
|
| 192 |
+
xa, xb = layout.node_x[a], layout.node_x[b]
|
| 193 |
+
ya, yb = layout.node_y[a], layout.node_y[b]
|
| 194 |
+
xnew = (xa + xb) / 2.0
|
| 195 |
+
layout.node_x[new] = xnew
|
| 196 |
+
layout.node_y[new] = height
|
| 197 |
+
# bracket: up from a, across at height, down to b
|
| 198 |
+
layout.segments.append((xa, xa, ya, height)) # left vertical
|
| 199 |
+
layout.segments.append((xb, xb, yb, height)) # right vertical
|
| 200 |
+
layout.segments.append((xa, xb, height, height)) # horizontal top
|
| 201 |
+
return layout
|
core/analysis/liability.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Liability / QC aggregator.
|
| 3 |
+
|
| 4 |
+
Rolls the individual analysis results (GC, homopolymers, restriction sites,
|
| 5 |
+
uridine, CDS validation, Kozak, secondary structure, sequence motifs) into a
|
| 6 |
+
single severity-ranked liability report with an overall QC score (0β100) and a
|
| 7 |
+
pass / review / fail verdict β analogous to a developability/liability overlay.
|
| 8 |
+
|
| 9 |
+
This module is a pure aggregator: it reads attributes off an already-computed
|
| 10 |
+
analysis report (duck-typed) and the sequence object, so it imports neither the
|
| 11 |
+
analyzer nor the Panel UI. It only depends on the homopolymer detector (to
|
| 12 |
+
re-scan the construct *body*, excluding the legitimate poly-A tail).
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from dataclasses import dataclass, field
|
| 17 |
+
from typing import Any, List, Optional
|
| 18 |
+
|
| 19 |
+
from core.analysis.homopolymers import detect_homopolymers
|
| 20 |
+
|
| 21 |
+
# Severity levels, ordered mostβleast severe
|
| 22 |
+
CRITICAL = "critical"
|
| 23 |
+
WARNING = "warning"
|
| 24 |
+
INFO = "info"
|
| 25 |
+
|
| 26 |
+
_SEVERITY_ORDER = {CRITICAL: 0, WARNING: 1, INFO: 2}
|
| 27 |
+
_PENALTY = {CRITICAL: 25, WARNING: 10, INFO: 3}
|
| 28 |
+
|
| 29 |
+
# Thresholds
|
| 30 |
+
_GC_LOW, _GC_HIGH = 40.0, 65.0 # warning band
|
| 31 |
+
_GC_LOW_CRIT, _GC_HIGH_CRIT = 30.0, 70.0 # critical band
|
| 32 |
+
_HOMOPOLYMER_WARN = 10 # body run length β warning
|
| 33 |
+
_HOMOPOLYMER_CRIT = 15 # body run length β critical
|
| 34 |
+
_URIDINE_WARN_PCT = 40.0
|
| 35 |
+
_MFE_PER_NT_INFO = -0.45 # very negative β highly structured
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class LiabilityFlag:
|
| 40 |
+
"""A single liability finding."""
|
| 41 |
+
id: str
|
| 42 |
+
category: str # "GC", "Homopolymer", "Restriction", "Uridine",
|
| 43 |
+
# "CDS", "Kozak", "Structure", "Motif"
|
| 44 |
+
severity: str # CRITICAL | WARNING | INFO
|
| 45 |
+
title: str
|
| 46 |
+
detail: str
|
| 47 |
+
location: str = "" # human-readable location, e.g. "CDS pos 123"
|
| 48 |
+
recommendation: str = ""
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@dataclass
|
| 52 |
+
class LiabilityReport:
|
| 53 |
+
"""Aggregated liability assessment for one sequence."""
|
| 54 |
+
flags: List[LiabilityFlag] = field(default_factory=list)
|
| 55 |
+
score: int = 100 # 0β100, higher is cleaner
|
| 56 |
+
verdict: str = "pass" # "pass" | "review" | "fail"
|
| 57 |
+
n_critical: int = 0
|
| 58 |
+
n_warning: int = 0
|
| 59 |
+
n_info: int = 0
|
| 60 |
+
checks_run: int = 0
|
| 61 |
+
|
| 62 |
+
@property
|
| 63 |
+
def flag_count(self) -> int:
|
| 64 |
+
return len(self.flags)
|
| 65 |
+
|
| 66 |
+
def sorted_flags(self) -> List[LiabilityFlag]:
|
| 67 |
+
return sorted(self.flags, key=lambda f: _SEVERITY_ORDER.get(f.severity, 9))
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _body_sequence(seq: Any) -> str:
|
| 71 |
+
"""Construct body = everything except the legitimate poly-A tail."""
|
| 72 |
+
parts = [
|
| 73 |
+
getattr(seq, "five_prime_utr", None),
|
| 74 |
+
getattr(seq, "kozak", None),
|
| 75 |
+
getattr(seq, "cds", None),
|
| 76 |
+
getattr(seq, "three_prime_utr", None),
|
| 77 |
+
]
|
| 78 |
+
return "".join(p for p in parts if p).upper().replace("U", "T")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def assess_liabilities(report: Any, seq: Any) -> LiabilityReport:
|
| 82 |
+
"""
|
| 83 |
+
Build a LiabilityReport from an analysis ``report`` and its ``seq``.
|
| 84 |
+
|
| 85 |
+
``report`` is duck-typed: it is expected to expose the attributes set by
|
| 86 |
+
SequenceAnalyzer (gc_percent_global, restriction_enzymes_present, uridine,
|
| 87 |
+
has_start_codon/has_stop_codon/in_frame, kozak, structure, motif_hits).
|
| 88 |
+
"""
|
| 89 |
+
flags: List[LiabilityFlag] = []
|
| 90 |
+
checks = 0
|
| 91 |
+
|
| 92 |
+
def add(category, severity, title, detail, location="", recommendation=""):
|
| 93 |
+
flags.append(LiabilityFlag(
|
| 94 |
+
id=f"{category.lower()}-{len(flags)}",
|
| 95 |
+
category=category, severity=severity, title=title,
|
| 96 |
+
detail=detail, location=location, recommendation=recommendation,
|
| 97 |
+
))
|
| 98 |
+
|
| 99 |
+
# ββ GC content ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 100 |
+
checks += 1
|
| 101 |
+
gc = getattr(report, "gc_percent_global", None)
|
| 102 |
+
if gc is not None and gc > 0:
|
| 103 |
+
if gc < _GC_LOW_CRIT or gc > _GC_HIGH_CRIT:
|
| 104 |
+
add("GC", CRITICAL, "GC content far outside optimal range",
|
| 105 |
+
f"Global GC is {gc:.1f}% (optimal {_GC_LOW:.0f}β{_GC_HIGH:.0f}%).",
|
| 106 |
+
"global",
|
| 107 |
+
"Re-balance GC; extremes hurt synthesis, translation, and stability.")
|
| 108 |
+
elif gc < _GC_LOW or gc > _GC_HIGH:
|
| 109 |
+
add("GC", WARNING, "GC content outside optimal range",
|
| 110 |
+
f"Global GC is {gc:.1f}% (optimal {_GC_LOW:.0f}β{_GC_HIGH:.0f}%).",
|
| 111 |
+
"global",
|
| 112 |
+
"Nudge GC toward 40β65% during codon optimisation.")
|
| 113 |
+
|
| 114 |
+
# ββ Homopolymers in the body (exclude poly-A tail) ββββββββββββββββββββββββ
|
| 115 |
+
checks += 1
|
| 116 |
+
body = _body_sequence(seq)
|
| 117 |
+
if body:
|
| 118 |
+
body_runs = detect_homopolymers(body, min_run=_HOMOPOLYMER_WARN)
|
| 119 |
+
if body_runs:
|
| 120 |
+
longest = max(body_runs, key=lambda r: r.length)
|
| 121 |
+
sev = CRITICAL if longest.length >= _HOMOPOLYMER_CRIT else WARNING
|
| 122 |
+
add("Homopolymer", sev, "Homopolymer run in construct body",
|
| 123 |
+
f"{len(body_runs)} run(s) β₯{_HOMOPOLYMER_WARN} nt; longest "
|
| 124 |
+
f"{longest.nucleotide}Γ{longest.length}.",
|
| 125 |
+
f"body pos {longest.start}",
|
| 126 |
+
"Break up long single-base runs to avoid synthesis errors and "
|
| 127 |
+
"polymerase slippage.")
|
| 128 |
+
|
| 129 |
+
# ββ Restriction sites βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 130 |
+
checks += 1
|
| 131 |
+
enzymes = getattr(report, "restriction_enzymes_present", None) or []
|
| 132 |
+
if enzymes:
|
| 133 |
+
add("Restriction", WARNING, "Internal restriction sites present",
|
| 134 |
+
f"Sites for: {', '.join(sorted(enzymes))}.",
|
| 135 |
+
"construct",
|
| 136 |
+
"Remove internal sites or pick a cloning strategy that avoids them.")
|
| 137 |
+
|
| 138 |
+
# ββ Uridine content / high-U stretches ββββββββββββββββββββββββββββββββββββ
|
| 139 |
+
checks += 1
|
| 140 |
+
uri = getattr(report, "uridine", None)
|
| 141 |
+
if uri is not None:
|
| 142 |
+
stretches = getattr(uri, "high_u_stretches", []) or []
|
| 143 |
+
u_pct = getattr(uri, "u_percent", 0.0)
|
| 144 |
+
if u_pct >= _URIDINE_WARN_PCT or stretches:
|
| 145 |
+
detail = f"Uridine {u_pct:.1f}%"
|
| 146 |
+
if stretches:
|
| 147 |
+
detail += f"; {len(stretches)} high-U stretch(es)"
|
| 148 |
+
add("Uridine", WARNING, "Elevated uridine content",
|
| 149 |
+
detail + ".",
|
| 150 |
+
"construct",
|
| 151 |
+
"High U is immunostimulatory β optimise sequence and/or use "
|
| 152 |
+
"modified nucleotides (e.g. N1-methylpseudouridine).")
|
| 153 |
+
|
| 154 |
+
# ββ CDS integrity βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 155 |
+
if getattr(report, "has_start_codon", None) is not None:
|
| 156 |
+
checks += 1
|
| 157 |
+
if report.has_start_codon is False:
|
| 158 |
+
add("CDS", CRITICAL, "CDS missing start codon",
|
| 159 |
+
"CDS does not begin with ATG.", "CDS 5' end",
|
| 160 |
+
"Ensure the CDS starts with ATG.")
|
| 161 |
+
if getattr(report, "has_stop_codon", None) is False:
|
| 162 |
+
add("CDS", CRITICAL, "CDS missing stop codon",
|
| 163 |
+
"CDS does not end with a stop codon.", "CDS 3' end",
|
| 164 |
+
"Append a stop codon (TAA/TAG/TGA).")
|
| 165 |
+
if getattr(report, "in_frame", None) is False:
|
| 166 |
+
add("CDS", CRITICAL, "CDS not in frame",
|
| 167 |
+
"CDS length is not divisible by 3.", "CDS",
|
| 168 |
+
"Fix indels so the CDS length is a multiple of 3.")
|
| 169 |
+
|
| 170 |
+
# ββ Kozak context βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 171 |
+
kz = getattr(report, "kozak", None)
|
| 172 |
+
if kz is not None:
|
| 173 |
+
checks += 1
|
| 174 |
+
strength = getattr(kz, "strength", None)
|
| 175 |
+
if strength == "weak":
|
| 176 |
+
add("Kozak", WARNING, "Weak Kozak context",
|
| 177 |
+
f"Kozak score {getattr(kz, 'score', 0):.2f} (weak).",
|
| 178 |
+
"around start codon",
|
| 179 |
+
"Strengthen Kozak: purine (A/G) at -3 and G at +4.")
|
| 180 |
+
elif strength == "adequate":
|
| 181 |
+
add("Kozak", INFO, "Sub-optimal Kozak context",
|
| 182 |
+
f"Kozak score {getattr(kz, 'score', 0):.2f} (adequate).",
|
| 183 |
+
"around start codon",
|
| 184 |
+
"Optional: optimise -3/+4 positions for stronger initiation.")
|
| 185 |
+
|
| 186 |
+
# ββ Secondary structure (if computed) βββββββββββββββββββββββββββββββββββββ
|
| 187 |
+
struct = getattr(report, "structure", None)
|
| 188 |
+
if struct is not None and not getattr(struct, "is_stub", True):
|
| 189 |
+
checks += 1
|
| 190 |
+
length = max(len(getattr(struct, "sequence", "") or ""), 1)
|
| 191 |
+
per_nt = getattr(struct, "mfe", 0.0) / length
|
| 192 |
+
if per_nt < _MFE_PER_NT_INFO:
|
| 193 |
+
add("Structure", INFO, "Highly structured mRNA",
|
| 194 |
+
f"MFE {struct.mfe:.1f} kcal/mol ({per_nt:.2f}/nt).",
|
| 195 |
+
"global",
|
| 196 |
+
"Strong structure (esp. near the 5' cap/start) can impede "
|
| 197 |
+
"translation initiation.")
|
| 198 |
+
|
| 199 |
+
# ββ Sequence motifs βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 200 |
+
motif_hits = getattr(report, "motif_hits", None) or []
|
| 201 |
+
if motif_hits:
|
| 202 |
+
checks += 1
|
| 203 |
+
# group by motif name
|
| 204 |
+
by_name: dict = {}
|
| 205 |
+
for h in motif_hits:
|
| 206 |
+
by_name.setdefault(h.name, []).append(h)
|
| 207 |
+
for name, group in by_name.items():
|
| 208 |
+
first = group[0]
|
| 209 |
+
sev = min((h.severity for h in group), key=lambda s: _SEVERITY_ORDER.get(s, 9))
|
| 210 |
+
positions = ", ".join(f"{h.region}:{h.start}" for h in group[:5])
|
| 211 |
+
if len(group) > 5:
|
| 212 |
+
positions += f" (+{len(group) - 5} more)"
|
| 213 |
+
add("Motif", sev, first.label,
|
| 214 |
+
f"{len(group)} occurrence(s). {first.description}",
|
| 215 |
+
positions,
|
| 216 |
+
first.recommendation)
|
| 217 |
+
|
| 218 |
+
# ββ Score & verdict βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 219 |
+
n_crit = sum(1 for f in flags if f.severity == CRITICAL)
|
| 220 |
+
n_warn = sum(1 for f in flags if f.severity == WARNING)
|
| 221 |
+
n_info = sum(1 for f in flags if f.severity == INFO)
|
| 222 |
+
|
| 223 |
+
penalty = sum(_PENALTY.get(f.severity, 0) for f in flags)
|
| 224 |
+
score = max(0, min(100, 100 - penalty))
|
| 225 |
+
|
| 226 |
+
if n_crit > 0:
|
| 227 |
+
verdict = "fail"
|
| 228 |
+
elif n_warn > 0:
|
| 229 |
+
verdict = "review"
|
| 230 |
+
else:
|
| 231 |
+
verdict = "pass"
|
| 232 |
+
|
| 233 |
+
return LiabilityReport(
|
| 234 |
+
flags=flags, score=score, verdict=verdict,
|
| 235 |
+
n_critical=n_crit, n_warning=n_warn, n_info=n_info,
|
| 236 |
+
checks_run=checks,
|
| 237 |
+
)
|
core/analysis/motifs.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Sequence-liability motif scanning for mRNA constructs.
|
| 3 |
+
|
| 4 |
+
Scans the functional regions of an mRNA for short sequence motifs that are
|
| 5 |
+
known to compromise expression, stability, or processing:
|
| 6 |
+
|
| 7 |
+
- **uORF start (upstream AUG)** in the 5'UTR β can initiate an upstream
|
| 8 |
+
open reading frame and reduce translation of the main CDS.
|
| 9 |
+
- **Premature polyadenylation signal** (AAUAAA / AUUAAA) inside the CDS β
|
| 10 |
+
can cause truncated transcripts.
|
| 11 |
+
- **AU-rich element (ARE, AUUUA)** in the 3'UTR β recruits decay machinery
|
| 12 |
+
and shortens mRNA half-life.
|
| 13 |
+
- **Cryptic 5' splice-donor consensus** (GT[AG]AGT) anywhere β risk of
|
| 14 |
+
aberrant splicing when transcribed from a DNA template.
|
| 15 |
+
|
| 16 |
+
All scanning is done on DNA alphabet (T, not U); inputs are normalised.
|
| 17 |
+
Severities are plain strings ("critical" / "warning" / "info") so this module
|
| 18 |
+
stays free of any dependency on the liability aggregator.
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import re
|
| 23 |
+
from dataclasses import dataclass
|
| 24 |
+
from typing import List, Optional
|
| 25 |
+
|
| 26 |
+
# Severity labels (kept as bare strings to avoid import cycles)
|
| 27 |
+
CRITICAL = "critical"
|
| 28 |
+
WARNING = "warning"
|
| 29 |
+
INFO = "info"
|
| 30 |
+
|
| 31 |
+
# Cap per-motif hits so a pathological sequence can't produce thousands of rows
|
| 32 |
+
_MAX_HITS_PER_MOTIF = 50
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class MotifHit:
|
| 37 |
+
"""A single sequence-liability motif occurrence."""
|
| 38 |
+
name: str # machine name, e.g. "uorf"
|
| 39 |
+
label: str # human label, e.g. "Upstream AUG (uORF)"
|
| 40 |
+
region: str # "5'UTR" | "CDS" | "3'UTR" | "full"
|
| 41 |
+
start: int # 0-based start within the scanned region
|
| 42 |
+
end: int # exclusive end within the region
|
| 43 |
+
match: str # the matched subsequence (DNA alphabet)
|
| 44 |
+
severity: str # CRITICAL | WARNING | INFO
|
| 45 |
+
description: str # why it matters
|
| 46 |
+
recommendation: str # what to do about it
|
| 47 |
+
|
| 48 |
+
def __repr__(self) -> str:
|
| 49 |
+
return f"MotifHit({self.name} {self.region}@{self.start} {self.match!r})"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _norm(seq: Optional[str]) -> str:
|
| 53 |
+
return (seq or "").upper().replace("U", "T")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _find_all(pattern: str, seq: str) -> List[re.Match]:
|
| 57 |
+
"""Find non-overlapping regex matches, capped."""
|
| 58 |
+
return list(re.finditer(pattern, seq))[:_MAX_HITS_PER_MOTIF]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def scan_motifs(
|
| 62 |
+
five_prime_utr: Optional[str] = None,
|
| 63 |
+
cds: Optional[str] = None,
|
| 64 |
+
three_prime_utr: Optional[str] = None,
|
| 65 |
+
full_seq: Optional[str] = None,
|
| 66 |
+
) -> List[MotifHit]:
|
| 67 |
+
"""
|
| 68 |
+
Scan an mRNA's regions for liability motifs.
|
| 69 |
+
|
| 70 |
+
Pass the individual components when available. ``full_seq`` is used for
|
| 71 |
+
region-agnostic scans (splice donor) and as a fallback when components
|
| 72 |
+
are not provided (e.g. a monolithic ``full_mrna`` record).
|
| 73 |
+
"""
|
| 74 |
+
hits: List[MotifHit] = []
|
| 75 |
+
|
| 76 |
+
utr5 = _norm(five_prime_utr)
|
| 77 |
+
cds_s = _norm(cds)
|
| 78 |
+
utr3 = _norm(three_prime_utr)
|
| 79 |
+
full = _norm(full_seq) or (utr5 + cds_s + utr3)
|
| 80 |
+
|
| 81 |
+
# ββ uORF: any ATG in the 5'UTR ββββββββββββββββββββββββββββββββββββββββββββ
|
| 82 |
+
for m in _find_all("ATG", utr5):
|
| 83 |
+
hits.append(MotifHit(
|
| 84 |
+
name="uorf",
|
| 85 |
+
label="Upstream AUG (uORF)",
|
| 86 |
+
region="5'UTR",
|
| 87 |
+
start=m.start(), end=m.end(), match=m.group(),
|
| 88 |
+
severity=WARNING,
|
| 89 |
+
description="An AUG in the 5'UTR can start an upstream ORF and "
|
| 90 |
+
"reduce ribosome loading on the main CDS.",
|
| 91 |
+
recommendation="Remove or silently mutate upstream AUGs in the 5'UTR.",
|
| 92 |
+
))
|
| 93 |
+
|
| 94 |
+
# ββ Premature polyadenylation signal inside the CDS βββββββββββββββββββββββ
|
| 95 |
+
for m in _find_all("AATAAA|ATTAAA", cds_s):
|
| 96 |
+
hits.append(MotifHit(
|
| 97 |
+
name="premature_polya",
|
| 98 |
+
label="Premature polyA signal",
|
| 99 |
+
region="CDS",
|
| 100 |
+
start=m.start(), end=m.end(), match=m.group(),
|
| 101 |
+
severity=CRITICAL,
|
| 102 |
+
description="A canonical polyadenylation signal inside the CDS can "
|
| 103 |
+
"cause premature cleavage and a truncated protein.",
|
| 104 |
+
recommendation="Codon-optimise to remove the internal AAUAAA/AUUAAA signal.",
|
| 105 |
+
))
|
| 106 |
+
|
| 107 |
+
# ββ AU-rich element (ARE) in the 3'UTR ββββββββββββββββββββββββββββββββββββ
|
| 108 |
+
for m in _find_all("ATTTA", utr3):
|
| 109 |
+
hits.append(MotifHit(
|
| 110 |
+
name="are",
|
| 111 |
+
label="AU-rich element (ARE)",
|
| 112 |
+
region="3'UTR",
|
| 113 |
+
start=m.start(), end=m.end(), match=m.group(),
|
| 114 |
+
severity=WARNING,
|
| 115 |
+
description="AUUUA pentamers recruit mRNA-decay machinery and shorten "
|
| 116 |
+
"transcript half-life.",
|
| 117 |
+
recommendation="Remove ARE motifs from the 3'UTR to improve stability.",
|
| 118 |
+
))
|
| 119 |
+
|
| 120 |
+
# ββ Cryptic 5' splice-donor consensus (region-agnostic) βββββββββββββββββββ
|
| 121 |
+
for m in _find_all("GT[AG]AGT", full):
|
| 122 |
+
hits.append(MotifHit(
|
| 123 |
+
name="splice_donor",
|
| 124 |
+
label="Cryptic splice donor",
|
| 125 |
+
region="full",
|
| 126 |
+
start=m.start(), end=m.end(), match=m.group(),
|
| 127 |
+
severity=INFO,
|
| 128 |
+
description="Matches the 5' splice-donor consensus; may cause aberrant "
|
| 129 |
+
"splicing if transcribed from a DNA template in cells.",
|
| 130 |
+
recommendation="Consider disrupting the GU[A/G]AGU consensus if splicing is a concern.",
|
| 131 |
+
))
|
| 132 |
+
|
| 133 |
+
return hits
|
demo/DEMO_SCRIPT.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# mRNA Design Studio β Demo Script (one page)
|
| 2 |
+
|
| 3 |
+
**Live app:** https://offtargeteffect-mrna-design-studio.hf.space
|
| 4 |
+
**Login:** username `admin` Β· password `vOAMljsXrzCemLZK4A38` *(or remove the password for a smoother live demo β see Prep)*
|
| 5 |
+
**Open in its own browser tab** β not the Hugging Face embedded preview (that loops on login).
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Prep (do 5 min before)
|
| 10 |
+
- [ ] Visit the URL to **wake the Space** (free tier sleeps; first load is slow).
|
| 11 |
+
- [ ] Have the sample file ready to drag in: `demo/demo_sequences_extended.csv` (14 constructs).
|
| 12 |
+
- [ ] *(Optional)* For the live database demo, have the Postgres connection details on a sticky note.
|
| 13 |
+
- [ ] *(Optional)* Remove the login: Space β Settings β secrets β delete `MRNA_STUDIO_PASSWORD` β app opens with no login.
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## The story β follow the sidebar top to bottom (~8β10 min)
|
| 18 |
+
|
| 19 |
+
**1. Import Data (90s)** β "It ingests real-world sequence tables and structures them automatically."
|
| 20 |
+
- Drag `demo/demo_sequences_extended.csv` onto the CSV uploader.
|
| 21 |
+
- Show the **auto-suggested column mapping** (gene_name, cds, UTRs, β¦).
|
| 22 |
+
- Click **Import Records** β 14 sequences land in the Worklist.
|
| 23 |
+
- *Or* demo the **PostgreSQL** path: pick PostgreSQL, paste the connection details, Connect β select `mrna_sequences` β Preview β Import.
|
| 24 |
+
|
| 25 |
+
**2. Worklist β Analyze (2 min)** β "Instant QC across the whole panel."
|
| 26 |
+
- Select all β **Analyze**.
|
| 27 |
+
- Point out **GC%**, **CAI** (codon adaptation), **homopolymer** runs (the poly-A tails!), **restriction sites**.
|
| 28 |
+
- Note the contrast: component-based vs monolithic records both analyze cleanly.
|
| 29 |
+
|
| 30 |
+
**3. Model Repository (1 min)** β "Pluggable scoring β local models or remote APIs."
|
| 31 |
+
- Show the two built-in scorers: **mRNA Stability Scorer** and **RNA Structure Scorer**.
|
| 32 |
+
- Mention you can register a remote API endpoint too.
|
| 33 |
+
|
| 34 |
+
**4. Worklist β Score & Export (2 min)** β "Rank candidates, hand off to the lab."
|
| 35 |
+
- Back on the Worklist, **Score** with a loaded model β **sort by score**.
|
| 36 |
+
- **Export CSV** of the ranked panel.
|
| 37 |
+
|
| 38 |
+
**5. Parts Workshop (1 min)** β "A reusable parts library."
|
| 39 |
+
- Browse 5'UTR / Kozak / CDS / 3'UTR / poly-A parts; compose a construct.
|
| 40 |
+
|
| 41 |
+
**6. Assemble Plasmid β Generate Sequences (2 min)** β "Close the loop."
|
| 42 |
+
- Pick the **pUC19-MCS** backbone, run **QC**, export the assembled construct.
|
| 43 |
+
- In **Generate Sequences**, produce a codon-optimized variant.
|
| 44 |
+
|
| 45 |
+
---
|
| 46 |
+
|
| 47 |
+
## If you have only 3 minutes
|
| 48 |
+
Import `demo/demo_sequences_extended.csv` β **Analyze** β **Score** β **Export.**
|
| 49 |
+
That's the whole value: ingest β analyze β score β export.
|
| 50 |
+
|
| 51 |
+
## Likely questions
|
| 52 |
+
- *"Where does the data live?"* β CSV/Excel upload or a PostgreSQL connection you provide.
|
| 53 |
+
- *"Can I use my own models?"* β Yes β register a local Python model or a remote API endpoint.
|
| 54 |
+
- *"Is it hosted?"* β Runs on Hugging Face Spaces (Docker); also runs locally with `make run`.
|
demo/ENPICOM_gap_analysis.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# mRNA Design Studio vs. ENPICOM IGX β gap analysis & roadmap
|
| 2 |
+
|
| 3 |
+
## Positioning (one line)
|
| 4 |
+
ENPICOM = **discover & de-risk** candidates from massive sequenced repertoires (antibodies/TCRs).
|
| 5 |
+
mRNA Design Studio = **design, build & QC** chosen sequences for expression (mRNA).
|
| 6 |
+
Same philosophy (no-code, data + AI for bench scientists); different pipeline stage and scale.
|
| 7 |
+
|
| 8 |
+
## Shared backbone (already have)
|
| 9 |
+
Import data + metadata Β· pluggable ML scoring Β· sequence analysis Β· candidate select/export Β· code-free UI.
|
| 10 |
+
|
| 11 |
+
## What would make us a closer antibody-discovery analog
|
| 12 |
+
|
| 13 |
+
### Quick wins (weeks)
|
| 14 |
+
1. **Sequence clustering** β group by similarity (CDS/UTR family for mRNA; CDR3/V-gene for Ab). Mirrors IGX-Cluster; biggest "explore" gap.
|
| 15 |
+
2. **Richer liability/QC panel** β extend current QC (restriction sites, homopolymers) into a flagged "liability" view: immunogenic motifs, GC extremes, secondary-structure hotspots. Mirrors IGX-Annotate (shallow version).
|
| 16 |
+
3. **Bulk / large-table ingestion** β FASTA/FASTQ import + dedup so we handle thousands, not dozens.
|
| 17 |
+
|
| 18 |
+
### Medium bets (1β2 quarters)
|
| 19 |
+
4. **Tree / cluster visualization** for candidate prioritization (phylogenetic-style for Ab; family/variant tree for mRNA). Mirrors IGX-Branch.
|
| 20 |
+
5. **Model lifecycle, not just a registry** β add training, versioning, and experiment tracking (we currently only *call* registered models). Mirrors ENPICOM's AI/MLOps layer.
|
| 21 |
+
6. **Projects + collaboration** β multi-user workspaces, metadata lineage, audit trail. We already have Postgres + auth as a foundation.
|
| 22 |
+
|
| 23 |
+
### Bigger bets (strategic)
|
| 24 |
+
7. **NGS repertoire scale** β millions of reads, server-side processing. This is ENPICOM's core moat and the largest gap.
|
| 25 |
+
8. **Structure-based developability** β fold/structure models for surface-liability prediction (the antibody-specific heavy lift).
|
| 26 |
+
9. **Round/enrichment tracking** β compare selection/panning rounds over time.
|
| 27 |
+
|
| 28 |
+
## Honest take
|
| 29 |
+
Closing items 1β4 makes us a credible "design + light analysis" peer for a *focused* workflow.
|
| 30 |
+
Items 7β8 are where ENPICOM's enterprise scale and antibody-structure depth live β matching those is a much larger investment and a different product bet.
|
demo/demo_sequences_extended.csv
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
id,gene_name,five_prime_utr,cds,three_prime_utr,poly_a_tail,full_mrna,target_protein,organism,expression_system,gc_target_percent,notes
|
| 2 |
+
1,eGFP-hBG-HEK,ACATTTGCTTCTGACACAACTGTGTTCACTAGCAACCTCAAACAGACACCATGGTGCATCTGACTCCTGAGGAGAAGTCT,ATGGTGAGCAAGGGCGAGGAGCTGTTCACCGGGGTGGTGCCCATCCTGGTCGAGCTGGACGGCGACGTAAACGGCCACAAGTTCAGCGTGTCCGGCGAGGGCGAGGGCGATGCCACCTACGGCAAGCTGACCCTGAAGTTCATCTGCACCACCGGCAAGCTGCCCGTGCCCTGGCCCACCCTCGTGACCACCCTGACCTACGGCGTGCAGTGCTTCAGCCGCTACCCCGACCACATGAAGCAGCACGACTTCTTCAAGTCCGCCATGCCCGAAGGCTACGTCCAGGAGCGCACCATCTTCTTCAAGGACGACGGCAACTACAAGACCCGCGCCGAGGTGAAGTTCGAGGGCGACACCCTGGTGAACCGCATCGAGCTGAAGGGCATCGACTTCAAGGAGGACGGCAACATCCTGGGGCACAAGCTGGAGTACAACTACAACAGCCACAACGTCTATATCATGGCCGACAAGCAGAAGAACGGCATCAAGGTGAACTTCAAGATCCGCCACAACATCGAGGACGGCAGCGTGCAGCTCGCCGACCACTACCAGCAGAACACCCCCATCGGCGACGGCCCCGTGCTGCTGCCCGACAACCACTACCTGAGCACCCAGTCCGCCCTGAGCAAAGACCCCAACGAGAAGCGCGATCACATGGTCCTGCTGGAGTTCGTGACCGCCGCCGGGATCACTCTCGGCATGGACGAGCTGTACAAGTAA,GCTCGCTTTCTTGCTGTCCAATTTCTATTAAAGGTTCCTTTGTTCCCTAAGTCCAACTACTAAACTGGGGGATATTATGAAGGGCCTTGAGCATCTGGAT,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,,Enhanced GFP,Aequorea victoria,HEK293T,61.5,"Beta-globin UTRs, standard 120A tail. Benchmark reporter."
|
| 3 |
+
2,eGFP-Alb-CHO,AGATCTTCTTTTAAATTTCTTTTTACTGAATTCAGCCAATATATGTAATCCTACTTTCAATCAATTTTCCTAAGCAATG,ATGGTGAGCAAGGGCGAGGAGCTGTTCACCGGGGTGGTGCCCATCCTGGTCGAGCTGGACGGCGACGTAAACGGCCACAAGTTCAGCGTGTCCGGCGAGGGCGAGGGCGATGCCACCTACGGCAAGCTGACCCTGAAGTTCATCTGCACCACCGGCAAGCTGCCCGTGCCCTGGCCCACCCTCGTGACCACCCTGACCTACGGCGTGCAGTGCTTCAGCCGCTACCCCGACCACATGAAGCAGCACGACTTCTTCAAGTCCGCCATGCCCGAAGGCTACGTCCAGGAGCGCACCATCTTCTTCAAGGACGACGGCAACTACAAGACCCGCGCCGAGGTGAAGTTCGAGGGCGACACCCTGGTGAACCGCATCGAGCTGAAGGGCATCGACTTCAAGGAGGACGGCAACATCCTGGGGCACAAGCTGGAGTACAACTACAACAGCCACAACGTCTATATCATGGCCGACAAGCAGAAGAACGGCATCAAGGTGAACTTCAAGATCCGCCACAACATCGAGGACGGCAGCGTGCAGCTCGCCGACCACTACCAGCAGAACACCCCCATCGGCGACGGCCCCGTGCTGCTGCCCGACAACCACTACCTGAGCACCCAGTCCGCCCTGAGCAAAGACCCCAACGAGAAGCGCGATCACATGGTCCTGCTGGAGTTCGTGACCGCCGCCGGGATCACTCTCGGCATGGACGAGCTGTACAAGTAA,AATAAAGATCTTTATTTTCATTAGATCTGTGTGTTGGTTTTTTGTGTGAATCGATAGTACTAAATACTTTTCAGACACCAGAAATGCAGAGCAGTTCAGA,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,,Enhanced GFP,Aequorea victoria,CHO,61.5,Albumin UTRs for prolonged expression in CHO.
|
| 4 |
+
3,eGFP-Alb-HEK-shortA,AGATCTTCTTTTAAATTTCTTTTTACTGAATTCAGCCAATATATGTAATCCTACTTTCAATCAATTTTCCTAAGCAATG,ATGGTGAGCAAGGGCGAGGAGCTGTTCACCGGGGTGGTGCCCATCCTGGTCGAGCTGGACGGCGACGTAAACGGCCACAAGTTCAGCGTGTCCGGCGAGGGCGAGGGCGATGCCACCTACGGCAAGCTGACCCTGAAGTTCATCTGCACCACCGGCAAGCTGCCCGTGCCCTGGCCCACCCTCGTGACCACCCTGACCTACGGCGTGCAGTGCTTCAGCCGCTACCCCGACCACATGAAGCAGCACGACTTCTTCAAGTCCGCCATGCCCGAAGGCTACGTCCAGGAGCGCACCATCTTCTTCAAGGACGACGGCAACTACAAGACCCGCGCCGAGGTGAAGTTCGAGGGCGACACCCTGGTGAACCGCATCGAGCTGAAGGGCATCGACTTCAAGGAGGACGGCAACATCCTGGGGCACAAGCTGGAGTACAACTACAACAGCCACAACGTCTATATCATGGCCGACAAGCAGAAGAACGGCATCAAGGTGAACTTCAAGATCCGCCACAACATCGAGGACGGCAGCGTGCAGCTCGCCGACCACTACCAGCAGAACACCCCCATCGGCGACGGCCCCGTGCTGCTGCCCGACAACCACTACCTGAGCACCCAGTCCGCCCTGAGCAAAGACCCCAACGAGAAGCGCGATCACATGGTCCTGCTGGAGTTCGTGACCGCCGCCGGGATCACTCTCGGCATGGACGAGCTGTACAAGTAA,AATAAAGATCTTTATTTTCATTAGATCTGTGTGTTGGTTTTTTGTGTGAATCGATAGTACTAAATACTTTTCAGACACCAGAAATGCAGAGCAGTTCAGA,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,,Enhanced GFP,Aequorea victoria,HEK293T,61.5,Short 80A tail variant β tail-length screen.
|
| 5 |
+
4,eGFP-noUTR-codonopt,,ATGGTGAGCAAGGGCGAGGAGCTGTTCACCGGGGTGGTGCCCATCCTGGTCGAGCTGGACGGCGACGTAAACGGCCACAAGTTCAGCGTGTCCGGCGAGGGCGAGGGCGATGCCACCTACGGCAAGCTGACCCTGAAGTTCATCTGCACCACCGGCAAGCTGCCCGTGCCCTGGCCCACCCTCGTGACCACCCTGACCTACGGCGTGCAGTGCTTCAGCCGCTACCCCGACCACATGAAGCAGCACGACTTCTTCAAGTCCGCCATGCCCGAAGGCTACGTCCAGGAGCGCACCATCTTCTTCAAGGACGACGGCAACTACAAGACCCGCGCCGAGGTGAAGTTCGAGGGCGACACCCTGGTGAACCGCATCGAGCTGAAGGGCATCGACTTCAAGGAGGACGGCAACATCCTGGGGCACAAGCTGGAGTACAACTACAACAGCCACAACGTCTATATCATGGCCGACAAGCAGAAGAACGGCATCAAGGTGAACTTCAAGATCCGCCACAACATCGAGGACGGCAGCGTGCAGCTCGCCGACCACTACCAGCAGAACACCCCCATCGGCGACGGCCCCGTGCTGCTGCCCGACAACCACTACCTGAGCACCCAGTCCGCCCTGAGCAAAGACCCCAACGAGAAGCGCGATCACATGGTCCTGCTGGAGTTCGTGACCGCCGCCGGGATCACTCTCGGCATGGACGAGCTGTACAAGTAA,,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,,Enhanced GFP,Aequorea victoria,in vitro,61.5,Bare CDS for codon-optimization testing.
|
| 6 |
+
5,eGFP-hBG-Jurkat,ACATTTGCTTCTGACACAACTGTGTTCACTAGCAACCTCAAACAGACACCATGGTGCATCTGACTCCTGAGGAGAAGTCT,ATGGTGAGCAAGGGCGAGGAGCTGTTCACCGGGGTGGTGCCCATCCTGGTCGAGCTGGACGGCGACGTAAACGGCCACAAGTTCAGCGTGTCCGGCGAGGGCGAGGGCGATGCCACCTACGGCAAGCTGACCCTGAAGTTCATCTGCACCACCGGCAAGCTGCCCGTGCCCTGGCCCACCCTCGTGACCACCCTGACCTACGGCGTGCAGTGCTTCAGCCGCTACCCCGACCACATGAAGCAGCACGACTTCTTCAAGTCCGCCATGCCCGAAGGCTACGTCCAGGAGCGCACCATCTTCTTCAAGGACGACGGCAACTACAAGACCCGCGCCGAGGTGAAGTTCGAGGGCGACACCCTGGTGAACCGCATCGAGCTGAAGGGCATCGACTTCAAGGAGGACGGCAACATCCTGGGGCACAAGCTGGAGTACAACTACAACAGCCACAACGTCTATATCATGGCCGACAAGCAGAAGAACGGCATCAAGGTGAACTTCAAGATCCGCCACAACATCGAGGACGGCAGCGTGCAGCTCGCCGACCACTACCAGCAGAACACCCCCATCGGCGACGGCCCCGTGCTGCTGCCCGACAACCACTACCTGAGCACCCAGTCCGCCCTGAGCAAAGACCCCAACGAGAAGCGCGATCACATGGTCCTGCTGGAGTTCGTGACCGCCGCCGGGATCACTCTCGGCATGGACGAGCTGTACAAGTAA,GCTCGCTTTCTTGCTGTCCAATTTCTATTAAAGGTTCCTTTGTTCCCTAAGTCCAACTACTAAACTGGGGGATATTATGAAGGGCCTTGAGCATCTGGAT,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,,Enhanced GFP,Aequorea victoria,Jurkat,61.5,Beta-globin UTRs in T-cell line.
|
| 7 |
+
6,mCherry-hBG-HEK,ACATTTGCTTCTGACACAACTGTGTTCACTAGCAACCTCAAACAGACACCATGGTGCATCTGACTCCTGAGGAGAAGTCT,ATGGTGAGCAAGGGCGAGGAGGATAACATGGCCATCATCAAGGAGTTCATGCGCTTCAAGGTGCACATGGAGGGCTCCGTGAACGGCCACGAGTTCGAGATCGAGGGCGAGGGCGAGGGCCGCCCCTACGAGGGCACCCAGACCGCCAAGCTGAAGGTGACCAAGGGTGGCCCCCTGCCCTTCGCCTGGGACATCCTGTCCCCTCAGTTCATGTACGGCTCCAAGGCCTACGTGAAGCACCCCGCCGACATCCCCGACTACTTGAAGCTGTCCTTCCCCGAGGGCTTCAAGTGGGAGCGCGTGATGAACTTCGAGGACGGCGGCGTGGTGACCGTGACCCAGGACTCCTCCCTGCAGGACGGCGAGTTCATCTACAAGGTGAAGCTGCGCGGCACCAACTTCCCCTCCGACGGCCCCGTAATGCAGAAGAAGACCATGGGCTGGGAGGCCTCCTCCGAGCGGATGTACCCCGAGGACGGCGCCCTGAAGGGCGAGATCAAGCAGAGGCTGAAGCTGAAGGACGGCGGCCACTACGACGCTGAGGTCAAGACCACCTACAAGGCCAAGAAGCCCGTGCAGCTGCCCGGCGCCTACAACGTCAACATCAAGTTGGACATCACCTCCCACAACGAGGACTACACCATCGTGGAACAGTACGAACGCGCCGAGGGCCGCCACTCCACCGGCGGCATGGACGAGCTGTACAAGTAA,GCTCGCTTTCTTGCTGTCCAATTTCTATTAAAGGTTCCTTTGTTCCCTAAGTCCAACTACTAAACTGGGGGATATTATGAAGGGCCTTGAGCATCTGGAT,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,,mCherry RFP,Discosoma sp.,HEK293T,62.6,Red reporter with beta-globin UTRs.
|
| 8 |
+
7,mCherry-Alb-CHO,AGATCTTCTTTTAAATTTCTTTTTACTGAATTCAGCCAATATATGTAATCCTACTTTCAATCAATTTTCCTAAGCAATG,ATGGTGAGCAAGGGCGAGGAGGATAACATGGCCATCATCAAGGAGTTCATGCGCTTCAAGGTGCACATGGAGGGCTCCGTGAACGGCCACGAGTTCGAGATCGAGGGCGAGGGCGAGGGCCGCCCCTACGAGGGCACCCAGACCGCCAAGCTGAAGGTGACCAAGGGTGGCCCCCTGCCCTTCGCCTGGGACATCCTGTCCCCTCAGTTCATGTACGGCTCCAAGGCCTACGTGAAGCACCCCGCCGACATCCCCGACTACTTGAAGCTGTCCTTCCCCGAGGGCTTCAAGTGGGAGCGCGTGATGAACTTCGAGGACGGCGGCGTGGTGACCGTGACCCAGGACTCCTCCCTGCAGGACGGCGAGTTCATCTACAAGGTGAAGCTGCGCGGCACCAACTTCCCCTCCGACGGCCCCGTAATGCAGAAGAAGACCATGGGCTGGGAGGCCTCCTCCGAGCGGATGTACCCCGAGGACGGCGCCCTGAAGGGCGAGATCAAGCAGAGGCTGAAGCTGAAGGACGGCGGCCACTACGACGCTGAGGTCAAGACCACCTACAAGGCCAAGAAGCCCGTGCAGCTGCCCGGCGCCTACAACGTCAACATCAAGTTGGACATCACCTCCCACAACGAGGACTACACCATCGTGGAACAGTACGAACGCGCCGAGGGCCGCCACTCCACCGGCGGCATGGACGAGCTGTACAAGTAA,AATAAAGATCTTTATTTTCATTAGATCTGTGTGTTGGTTTTTTGTGTGAATCGATAGTACTAAATACTTTTCAGACACCAGAAATGCAGAGCAGTTCAGA,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,,mCherry RFP,Discosoma sp.,CHO,62.6,"Albumin UTRs, CHO production."
|
| 9 |
+
8,mCherry-Alb-Primary,AGATCTTCTTTTAAATTTCTTTTTACTGAATTCAGCCAATATATGTAATCCTACTTTCAATCAATTTTCCTAAGCAATG,ATGGTGAGCAAGGGCGAGGAGGATAACATGGCCATCATCAAGGAGTTCATGCGCTTCAAGGTGCACATGGAGGGCTCCGTGAACGGCCACGAGTTCGAGATCGAGGGCGAGGGCGAGGGCCGCCCCTACGAGGGCACCCAGACCGCCAAGCTGAAGGTGACCAAGGGTGGCCCCCTGCCCTTCGCCTGGGACATCCTGTCCCCTCAGTTCATGTACGGCTCCAAGGCCTACGTGAAGCACCCCGCCGACATCCCCGACTACTTGAAGCTGTCCTTCCCCGAGGGCTTCAAGTGGGAGCGCGTGATGAACTTCGAGGACGGCGGCGTGGTGACCGTGACCCAGGACTCCTCCCTGCAGGACGGCGAGTTCATCTACAAGGTGAAGCTGCGCGGCACCAACTTCCCCTCCGACGGCCCCGTAATGCAGAAGAAGACCATGGGCTGGGAGGCCTCCTCCGAGCGGATGTACCCCGAGGACGGCGCCCTGAAGGGCGAGATCAAGCAGAGGCTGAAGCTGAAGGACGGCGGCCACTACGACGCTGAGGTCAAGACCACCTACAAGGCCAAGAAGCCCGTGCAGCTGCCCGGCGCCTACAACGTCAACATCAAGTTGGACATCACCTCCCACAACGAGGACTACACCATCGTGGAACAGTACGAACGCGCCGAGGGCCGCCACTCCACCGGCGGCATGGACGAGCTGTACAAGTAA,AATAAAGATCTTTATTTTCATTAGATCTGTGTGTTGGTTTTTTGTGTGAATCGATAGTACTAAATACTTTTCAGACACCAGAAATGCAGAGCAGTTCAGA,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,,mCherry RFP,Discosoma sp.,Primary T cells,62.6,Long tail for primary-cell stability.
|
| 10 |
+
9,mCherry-noUTR-base,,ATGGTGAGCAAGGGCGAGGAGGATAACATGGCCATCATCAAGGAGTTCATGCGCTTCAAGGTGCACATGGAGGGCTCCGTGAACGGCCACGAGTTCGAGATCGAGGGCGAGGGCGAGGGCCGCCCCTACGAGGGCACCCAGACCGCCAAGCTGAAGGTGACCAAGGGTGGCCCCCTGCCCTTCGCCTGGGACATCCTGTCCCCTCAGTTCATGTACGGCTCCAAGGCCTACGTGAAGCACCCCGCCGACATCCCCGACTACTTGAAGCTGTCCTTCCCCGAGGGCTTCAAGTGGGAGCGCGTGATGAACTTCGAGGACGGCGGCGTGGTGACCGTGACCCAGGACTCCTCCCTGCAGGACGGCGAGTTCATCTACAAGGTGAAGCTGCGCGGCACCAACTTCCCCTCCGACGGCCCCGTAATGCAGAAGAAGACCATGGGCTGGGAGGCCTCCTCCGAGCGGATGTACCCCGAGGACGGCGCCCTGAAGGGCGAGATCAAGCAGAGGCTGAAGCTGAAGGACGGCGGCCACTACGACGCTGAGGTCAAGACCACCTACAAGGCCAAGAAGCCCGTGCAGCTGCCCGGCGCCTACAACGTCAACATCAAGTTGGACATCACCTCCCACAACGAGGACTACACCATCGTGGAACAGTACGAACGCGCCGAGGGCCGCCACTCCACCGGCGGCATGGACGAGCTGTACAAGTAA,,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,,mCherry RFP,Discosoma sp.,in vitro,62.6,Minimal construct β GC/CAI baseline.
|
| 11 |
+
10,mCherry-hBG-shortA,ACATTTGCTTCTGACACAACTGTGTTCACTAGCAACCTCAAACAGACACCATGGTGCATCTGACTCCTGAGGAGAAGTCT,ATGGTGAGCAAGGGCGAGGAGGATAACATGGCCATCATCAAGGAGTTCATGCGCTTCAAGGTGCACATGGAGGGCTCCGTGAACGGCCACGAGTTCGAGATCGAGGGCGAGGGCGAGGGCCGCCCCTACGAGGGCACCCAGACCGCCAAGCTGAAGGTGACCAAGGGTGGCCCCCTGCCCTTCGCCTGGGACATCCTGTCCCCTCAGTTCATGTACGGCTCCAAGGCCTACGTGAAGCACCCCGCCGACATCCCCGACTACTTGAAGCTGTCCTTCCCCGAGGGCTTCAAGTGGGAGCGCGTGATGAACTTCGAGGACGGCGGCGTGGTGACCGTGACCCAGGACTCCTCCCTGCAGGACGGCGAGTTCATCTACAAGGTGAAGCTGCGCGGCACCAACTTCCCCTCCGACGGCCCCGTAATGCAGAAGAAGACCATGGGCTGGGAGGCCTCCTCCGAGCGGATGTACCCCGAGGACGGCGCCCTGAAGGGCGAGATCAAGCAGAGGCTGAAGCTGAAGGACGGCGGCCACTACGACGCTGAGGTCAAGACCACCTACAAGGCCAAGAAGCCCGTGCAGCTGCCCGGCGCCTACAACGTCAACATCAAGTTGGACATCACCTCCCACAACGAGGACTACACCATCGTGGAACAGTACGAACGCGCCGAGGGCCGCCACTCCACCGGCGGCATGGACGAGCTGTACAAGTAA,GCTCGCTTTCTTGCTGTCCAATTTCTATTAAAGGTTCCTTTGTTCCCTAAGTCCAACTACTAAACTGGGGGATATTATGAAGGGCCTTGAGCATCTGGAT,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,,mCherry RFP,Discosoma sp.,HEK293T,62.6,Short tail variant for stability comparison.
|
| 12 |
+
11,eGFP-mono-hBG,,,,,ACATTTGCTTCTGACACAACTGTGTTCACTAGCAACCTCAAACAGACACCATGGTGCATCTGACTCCTGAGGAGAAGTCTGCCACCATGGTGAGCAAGGGCGAGGAGCTGTTCACCGGGGTGGTGCCCATCCTGGTCGAGCTGGACGGCGACGTAAACGGCCACAAGTTCAGCGTGTCCGGCGAGGGCGAGGGCGATGCCACCTACGGCAAGCTGACCCTGAAGTTCATCTGCACCACCGGCAAGCTGCCCGTGCCCTGGCCCACCCTCGTGACCACCCTGACCTACGGCGTGCAGTGCTTCAGCCGCTACCCCGACCACATGAAGCAGCACGACTTCTTCAAGTCCGCCATGCCCGAAGGCTACGTCCAGGAGCGCACCATCTTCTTCAAGGACGACGGCAACTACAAGACCCGCGCCGAGGTGAAGTTCGAGGGCGACACCCTGGTGAACCGCATCGAGCTGAAGGGCATCGACTTCAAGGAGGACGGCAACATCCTGGGGCACAAGCTGGAGTACAACTACAACAGCCACAACGTCTATATCATGGCCGACAAGCAGAAGAACGGCATCAAGGTGAACTTCAAGATCCGCCACAACATCGAGGACGGCAGCGTGCAGCTCGCCGACCACTACCAGCAGAACACCCCCATCGGCGACGGCCCCGTGCTGCTGCCCGACAACCACTACCTGAGCACCCAGTCCGCCCTGAGCAAAGACCCCAACGAGAAGCGCGATCACATGGTCCTGCTGGAGTTCGTGACCGCCGCCGGGATCACTCTCGGCATGGACGAGCTGTACAAGTAAGCTCGCTTTCTTGCTGTCCAATTTCTATTAAAGGTTCCTTTGTTCCCTAAGTCCAACTACTAAACTGGGGGATATTATGAAGGGCCTTGAGCATCTGGATAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,Enhanced GFP,Aequorea victoria,HEK293T,51.5,Monolithic archive record (assembled mRNA).
|
| 13 |
+
12,eGFP-mono-Alb,,,,,AGATCTTCTTTTAAATTTCTTTTTACTGAATTCAGCCAATATATGTAATCCTACTTTCAATCAATTTTCCTAAGCAATGGCCACCATGGTGAGCAAGGGCGAGGAGCTGTTCACCGGGGTGGTGCCCATCCTGGTCGAGCTGGACGGCGACGTAAACGGCCACAAGTTCAGCGTGTCCGGCGAGGGCGAGGGCGATGCCACCTACGGCAAGCTGACCCTGAAGTTCATCTGCACCACCGGCAAGCTGCCCGTGCCCTGGCCCACCCTCGTGACCACCCTGACCTACGGCGTGCAGTGCTTCAGCCGCTACCCCGACCACATGAAGCAGCACGACTTCTTCAAGTCCGCCATGCCCGAAGGCTACGTCCAGGAGCGCACCATCTTCTTCAAGGACGACGGCAACTACAAGACCCGCGCCGAGGTGAAGTTCGAGGGCGACACCCTGGTGAACCGCATCGAGCTGAAGGGCATCGACTTCAAGGAGGACGGCAACATCCTGGGGCACAAGCTGGAGTACAACTACAACAGCCACAACGTCTATATCATGGCCGACAAGCAGAAGAACGGCATCAAGGTGAACTTCAAGATCCGCCACAACATCGAGGACGGCAGCGTGCAGCTCGCCGACCACTACCAGCAGAACACCCCCATCGGCGACGGCCCCGTGCTGCTGCCCGACAACCACTACCTGAGCACCCAGTCCGCCCTGAGCAAAGACCCCAACGAGAAGCGCGATCACATGGTCCTGCTGGAGTTCGTGACCGCCGCCGGGATCACTCTCGGCATGGACGAGCTGTACAAGTAAAATAAAGATCTTTATTTTCATTAGATCTGTGTGTTGGTTTTTTGTGTGAATCGATAGTACTAAATACTTTTCAGACACCAGAAATGCAGAGCAGTTCAGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,Enhanced GFP,Aequorea victoria,CHO,49.9,"Monolithic, albumin UTRs."
|
| 14 |
+
13,mCherry-mono-hBG,,,,,ACATTTGCTTCTGACACAACTGTGTTCACTAGCAACCTCAAACAGACACCATGGTGCATCTGACTCCTGAGGAGAAGTCTGCCACCATGGTGAGCAAGGGCGAGGAGGATAACATGGCCATCATCAAGGAGTTCATGCGCTTCAAGGTGCACATGGAGGGCTCCGTGAACGGCCACGAGTTCGAGATCGAGGGCGAGGGCGAGGGCCGCCCCTACGAGGGCACCCAGACCGCCAAGCTGAAGGTGACCAAGGGTGGCCCCCTGCCCTTCGCCTGGGACATCCTGTCCCCTCAGTTCATGTACGGCTCCAAGGCCTACGTGAAGCACCCCGCCGACATCCCCGACTACTTGAAGCTGTCCTTCCCCGAGGGCTTCAAGTGGGAGCGCGTGATGAACTTCGAGGACGGCGGCGTGGTGACCGTGACCCAGGACTCCTCCCTGCAGGACGGCGAGTTCATCTACAAGGTGAAGCTGCGCGGCACCAACTTCCCCTCCGACGGCCCCGTAATGCAGAAGAAGACCATGGGCTGGGAGGCCTCCTCCGAGCGGATGTACCCCGAGGACGGCGCCCTGAAGGGCGAGATCAAGCAGAGGCTGAAGCTGAAGGACGGCGGCCACTACGACGCTGAGGTCAAGACCACCTACAAGGCCAAGAAGCCCGTGCAGCTGCCCGGCGCCTACAACGTCAACATCAAGTTGGACATCACCTCCCACAACGAGGACTACACCATCGTGGAACAGTACGAACGCGCCGAGGGCCGCCACTCCACCGGCGGCATGGACGAGCTGTACAAGTAAGCTCGCTTTCTTGCTGTCCAATTTCTATTAAAGGTTCCTTTGTTCCCTAAGTCCAACTACTAAACTGGGGGATATTATGAAGGGCCTTGAGCATCTGGATAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,mCherry RFP,Discosoma sp.,HEK293T,52.1,"Monolithic red reporter, beta-globin UTRs."
|
| 15 |
+
14,mCherry-mono-Alb,,,,,AGATCTTCTTTTAAATTTCTTTTTACTGAATTCAGCCAATATATGTAATCCTACTTTCAATCAATTTTCCTAAGCAATGGCCACCATGGTGAGCAAGGGCGAGGAGGATAACATGGCCATCATCAAGGAGTTCATGCGCTTCAAGGTGCACATGGAGGGCTCCGTGAACGGCCACGAGTTCGAGATCGAGGGCGAGGGCGAGGGCCGCCCCTACGAGGGCACCCAGACCGCCAAGCTGAAGGTGACCAAGGGTGGCCCCCTGCCCTTCGCCTGGGACATCCTGTCCCCTCAGTTCATGTACGGCTCCAAGGCCTACGTGAAGCACCCCGCCGACATCCCCGACTACTTGAAGCTGTCCTTCCCCGAGGGCTTCAAGTGGGAGCGCGTGATGAACTTCGAGGACGGCGGCGTGGTGACCGTGACCCAGGACTCCTCCCTGCAGGACGGCGAGTTCATCTACAAGGTGAAGCTGCGCGGCACCAACTTCCCCTCCGACGGCCCCGTAATGCAGAAGAAGACCATGGGCTGGGAGGCCTCCTCCGAGCGGATGTACCCCGAGGACGGCGCCCTGAAGGGCGAGATCAAGCAGAGGCTGAAGCTGAAGGACGGCGGCCACTACGACGCTGAGGTCAAGACCACCTACAAGGCCAAGAAGCCCGTGCAGCTGCCCGGCGCCTACAACGTCAACATCAAGTTGGACATCACCTCCCACAACGAGGACTACACCATCGTGGAACAGTACGAACGCGCCGAGGGCCGCCACTCCACCGGCGGCATGGACGAGCTGTACAAGTAAAATAAAGATCTTTATTTTCATTAGATCTGTGTGTTGGTTTTTTGTGTGAATCGATAGTACTAAATACTTTTCAGACACCAGAAATGCAGAGCAGTTCAGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,mCherry RFP,Discosoma sp.,Jurkat,50.5,"Monolithic, albumin UTRs, T-cell line."
|
models/runs.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Model run / experiment tracking.
|
| 3 |
+
|
| 4 |
+
A lightweight MLOps layer: every time a scoring model is run against a worklist
|
| 5 |
+
we record a ``ModelRun`` (model name + version, timestamp, score statistics, and
|
| 6 |
+
per-sequence scores). ``RunHistory`` stores them and can compare two runs β e.g.
|
| 7 |
+
two versions of the same model β to show how scores shifted.
|
| 8 |
+
|
| 9 |
+
Pure-Python (uses only the stdlib ``statistics`` module).
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import math
|
| 14 |
+
import statistics
|
| 15 |
+
import uuid
|
| 16 |
+
from dataclasses import dataclass, field
|
| 17 |
+
from typing import Dict, List, Optional
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class ModelRun:
|
| 22 |
+
"""A single scoring run over a set of sequences."""
|
| 23 |
+
model_name: str
|
| 24 |
+
model_version: str
|
| 25 |
+
model_source: str
|
| 26 |
+
worklist_name: str
|
| 27 |
+
n_sequences: int
|
| 28 |
+
score_min: float
|
| 29 |
+
score_max: float
|
| 30 |
+
score_mean: float
|
| 31 |
+
score_std: float
|
| 32 |
+
timestamp: str # ISO-8601
|
| 33 |
+
run_id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
|
| 34 |
+
notes: str = ""
|
| 35 |
+
scores: Dict[str, float] = field(default_factory=dict) # seq_id -> score
|
| 36 |
+
|
| 37 |
+
@property
|
| 38 |
+
def label(self) -> str:
|
| 39 |
+
return f"{self.model_name} v{self.model_version} Β· {self.timestamp}"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def summarize_run(
|
| 43 |
+
model_name: str,
|
| 44 |
+
model_version: str,
|
| 45 |
+
model_source: str,
|
| 46 |
+
worklist_name: str,
|
| 47 |
+
scores: Dict[str, float],
|
| 48 |
+
timestamp: str,
|
| 49 |
+
notes: str = "",
|
| 50 |
+
) -> ModelRun:
|
| 51 |
+
"""Build a ModelRun from a {seq_id: score} mapping (ignoring NaNs in stats)."""
|
| 52 |
+
valid = [v for v in scores.values() if v is not None and not math.isnan(v)]
|
| 53 |
+
n = len(valid)
|
| 54 |
+
smin = min(valid) if valid else float("nan")
|
| 55 |
+
smax = max(valid) if valid else float("nan")
|
| 56 |
+
smean = statistics.fmean(valid) if valid else float("nan")
|
| 57 |
+
sstd = statistics.pstdev(valid) if len(valid) > 1 else 0.0
|
| 58 |
+
return ModelRun(
|
| 59 |
+
model_name=model_name, model_version=model_version,
|
| 60 |
+
model_source=model_source, worklist_name=worklist_name,
|
| 61 |
+
n_sequences=n, score_min=smin, score_max=smax,
|
| 62 |
+
score_mean=smean, score_std=sstd, timestamp=timestamp,
|
| 63 |
+
notes=notes, scores=dict(scores),
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@dataclass
|
| 68 |
+
class RunComparison:
|
| 69 |
+
"""Per-sequence delta between two runs over their shared sequences."""
|
| 70 |
+
run_a: ModelRun
|
| 71 |
+
run_b: ModelRun
|
| 72 |
+
shared_ids: List[str]
|
| 73 |
+
deltas: Dict[str, float] # seq_id -> (b - a)
|
| 74 |
+
mean_delta: float
|
| 75 |
+
n_improved: int # b > a
|
| 76 |
+
n_worsened: int # b < a
|
| 77 |
+
n_unchanged: int
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class RunHistory:
|
| 81 |
+
"""Append-only store of ModelRun records."""
|
| 82 |
+
|
| 83 |
+
def __init__(self) -> None:
|
| 84 |
+
self.runs: List[ModelRun] = []
|
| 85 |
+
|
| 86 |
+
def add(self, run: ModelRun) -> None:
|
| 87 |
+
self.runs.append(run)
|
| 88 |
+
|
| 89 |
+
def for_model(self, model_name: str) -> List[ModelRun]:
|
| 90 |
+
return [r for r in self.runs if r.model_name == model_name]
|
| 91 |
+
|
| 92 |
+
def model_names(self) -> List[str]:
|
| 93 |
+
# preserve first-seen order
|
| 94 |
+
seen: List[str] = []
|
| 95 |
+
for r in self.runs:
|
| 96 |
+
if r.model_name not in seen:
|
| 97 |
+
seen.append(r.model_name)
|
| 98 |
+
return seen
|
| 99 |
+
|
| 100 |
+
@staticmethod
|
| 101 |
+
def compare(run_a: ModelRun, run_b: ModelRun) -> RunComparison:
|
| 102 |
+
shared = [sid for sid in run_a.scores if sid in run_b.scores]
|
| 103 |
+
deltas: Dict[str, float] = {}
|
| 104 |
+
improved = worsened = unchanged = 0
|
| 105 |
+
for sid in shared:
|
| 106 |
+
a, b = run_a.scores[sid], run_b.scores[sid]
|
| 107 |
+
if a is None or b is None or math.isnan(a) or math.isnan(b):
|
| 108 |
+
continue
|
| 109 |
+
d = b - a
|
| 110 |
+
deltas[sid] = d
|
| 111 |
+
if d > 1e-9:
|
| 112 |
+
improved += 1
|
| 113 |
+
elif d < -1e-9:
|
| 114 |
+
worsened += 1
|
| 115 |
+
else:
|
| 116 |
+
unchanged += 1
|
| 117 |
+
mean_delta = statistics.fmean(deltas.values()) if deltas else 0.0
|
| 118 |
+
return RunComparison(
|
| 119 |
+
run_a=run_a, run_b=run_b, shared_ids=list(deltas.keys()),
|
| 120 |
+
deltas=deltas, mean_delta=mean_delta,
|
| 121 |
+
n_improved=improved, n_worsened=worsened, n_unchanged=unchanged,
|
| 122 |
+
)
|
tests/test_clustering.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for sequence clustering and dendrogram building."""
|
| 2 |
+
import numpy as np
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from core.analysis.clustering import (
|
| 6 |
+
kmer_distance_matrix, upgma, flat_clusters, dendrogram_layout,
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class TestDistance:
|
| 11 |
+
def test_identical_sequences_zero_distance(self):
|
| 12 |
+
d = kmer_distance_matrix(["ATGCATGC", "ATGCATGC"], k=3)
|
| 13 |
+
assert d[0, 1] == pytest.approx(0.0, abs=1e-9)
|
| 14 |
+
|
| 15 |
+
def test_disjoint_sequences_high_distance(self):
|
| 16 |
+
d = kmer_distance_matrix(["AAAAAAAA", "GGGGGGGG"], k=3)
|
| 17 |
+
assert d[0, 1] > 0.9
|
| 18 |
+
|
| 19 |
+
def test_matrix_is_symmetric_zero_diagonal(self):
|
| 20 |
+
d = kmer_distance_matrix(["ATGCATGC", "TTGGCCAA", "ATGCATGG"], k=3)
|
| 21 |
+
assert np.allclose(d, d.T)
|
| 22 |
+
assert np.allclose(np.diag(d), 0.0)
|
| 23 |
+
|
| 24 |
+
def test_short_sequence_safe(self):
|
| 25 |
+
d = kmer_distance_matrix(["AT", "ATGC"], k=4) # first too short for k
|
| 26 |
+
assert d.shape == (2, 2)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class TestUPGMA:
|
| 30 |
+
SEQS = ["ATGCATGCATGC", "ATGCATGCATGG", "TTTTGGGGTTTT", "TTTTGGGGTTTG"]
|
| 31 |
+
|
| 32 |
+
def test_linkage_shape(self):
|
| 33 |
+
link, order = upgma(kmer_distance_matrix(self.SEQS, k=3))
|
| 34 |
+
assert link.shape == (3, 4) # n-1 merges
|
| 35 |
+
assert sorted(order) == [0, 1, 2, 3] # leaf order is a permutation
|
| 36 |
+
|
| 37 |
+
def test_single_sequence_safe(self):
|
| 38 |
+
link, order = upgma(kmer_distance_matrix(["ATGC"], k=3))
|
| 39 |
+
assert link.shape == (0, 4)
|
| 40 |
+
assert order == [0]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class TestFlatClusters:
|
| 44 |
+
SEQS = ["ATGCATGCATGC", "ATGCATGCATGG", "TTTTGGGGTTTT", "TTTTGGGGTTTG"]
|
| 45 |
+
|
| 46 |
+
def _link(self):
|
| 47 |
+
return upgma(kmer_distance_matrix(self.SEQS, k=3))[0]
|
| 48 |
+
|
| 49 |
+
def test_two_clusters_group_correctly(self):
|
| 50 |
+
cl = flat_clusters(self._link(), 4, threshold=0.5)
|
| 51 |
+
assert cl[0] == cl[1] and cl[2] == cl[3] and cl[0] != cl[2]
|
| 52 |
+
|
| 53 |
+
def test_high_threshold_single_cluster(self):
|
| 54 |
+
cl = flat_clusters(self._link(), 4, threshold=10.0)
|
| 55 |
+
assert len(set(cl)) == 1
|
| 56 |
+
|
| 57 |
+
def test_negative_threshold_all_singletons(self):
|
| 58 |
+
cl = flat_clusters(self._link(), 4, threshold=-1.0)
|
| 59 |
+
assert len(set(cl)) == 4
|
| 60 |
+
|
| 61 |
+
def test_cluster_ids_contiguous_from_zero(self):
|
| 62 |
+
cl = flat_clusters(self._link(), 4, threshold=0.5)
|
| 63 |
+
assert set(cl) == set(range(len(set(cl))))
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class TestDendrogramLayout:
|
| 67 |
+
def test_segments_and_heights(self):
|
| 68 |
+
seqs = ["ATGCATGCATGC", "ATGCATGCATGG", "TTTTGGGGTTTT", "TTTTGGGGTTTG"]
|
| 69 |
+
link, order = upgma(kmer_distance_matrix(seqs, k=3))
|
| 70 |
+
lay = dendrogram_layout(link, order)
|
| 71 |
+
assert len(lay.segments) == 3 * 3 # 3 segments per merge
|
| 72 |
+
assert all(y >= 0 for y in lay.node_y.values())
|
| 73 |
+
assert len(lay.leaf_x) == 4
|
tests/test_liability.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for sequence-liability motif scanning and the liability aggregator."""
|
| 2 |
+
from types import SimpleNamespace
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
from core.analysis.motifs import scan_motifs
|
| 7 |
+
from core.analysis.liability import assess_liabilities, CRITICAL, WARNING
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
# ββ Motif scanning ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 11 |
+
|
| 12 |
+
class TestMotifs:
|
| 13 |
+
def test_uorf_in_5utr(self):
|
| 14 |
+
hits = scan_motifs(five_prime_utr="GGGCATGGGG", cds="ATGAAATAA", three_prime_utr="")
|
| 15 |
+
names = [h.name for h in hits]
|
| 16 |
+
assert "uorf" in names
|
| 17 |
+
uorf = next(h for h in hits if h.name == "uorf")
|
| 18 |
+
assert uorf.region == "5'UTR"
|
| 19 |
+
assert uorf.severity == WARNING
|
| 20 |
+
|
| 21 |
+
def test_premature_polya_in_cds_is_critical(self):
|
| 22 |
+
hits = scan_motifs(cds="ATGAATAAACCCTAA")
|
| 23 |
+
prem = [h for h in hits if h.name == "premature_polya"]
|
| 24 |
+
assert prem and prem[0].severity == CRITICAL
|
| 25 |
+
assert prem[0].region == "CDS"
|
| 26 |
+
|
| 27 |
+
def test_are_in_3utr(self):
|
| 28 |
+
hits = scan_motifs(three_prime_utr="GGATTTAGG")
|
| 29 |
+
are = [h for h in hits if h.name == "are"]
|
| 30 |
+
assert are and are[0].region == "3'UTR"
|
| 31 |
+
|
| 32 |
+
def test_splice_donor_detected_in_full(self):
|
| 33 |
+
hits = scan_motifs(full_seq="CCCGTAAGTCCC")
|
| 34 |
+
assert any(h.name == "splice_donor" for h in hits)
|
| 35 |
+
|
| 36 |
+
def test_clean_sequence_has_no_motifs(self):
|
| 37 |
+
# CDS with no AATAAA/ATTAAA, UTRs without ATG/ATTTA, no GT[AG]AGT
|
| 38 |
+
hits = scan_motifs(
|
| 39 |
+
five_prime_utr="CCGCCGCCGCC",
|
| 40 |
+
cds="ATGGGCGGCGGCTAA",
|
| 41 |
+
three_prime_utr="CCGCCGCCG",
|
| 42 |
+
)
|
| 43 |
+
assert hits == []
|
| 44 |
+
|
| 45 |
+
def test_uridine_input_is_normalised(self):
|
| 46 |
+
# RNA alphabet (U) should be treated like T
|
| 47 |
+
hits = scan_motifs(three_prime_utr="GGAUUUAGG")
|
| 48 |
+
assert any(h.name == "are" for h in hits)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ββ Liability aggregation βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 52 |
+
|
| 53 |
+
def _clean_report():
|
| 54 |
+
return SimpleNamespace(
|
| 55 |
+
gc_percent_global=52.0,
|
| 56 |
+
restriction_enzymes_present=[],
|
| 57 |
+
uridine=SimpleNamespace(u_percent=22.0, high_u_stretches=[]),
|
| 58 |
+
has_start_codon=True, has_stop_codon=True, in_frame=True,
|
| 59 |
+
kozak=SimpleNamespace(strength="strong", score=0.9),
|
| 60 |
+
structure=SimpleNamespace(is_stub=True, mfe=0.0, sequence=""),
|
| 61 |
+
motif_hits=[],
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _clean_seq():
|
| 66 |
+
return SimpleNamespace(
|
| 67 |
+
five_prime_utr="CCGCCACC", kozak=None,
|
| 68 |
+
cds="ATGGGCGGCGGCTAA", three_prime_utr="CCGCCG",
|
| 69 |
+
poly_a="A" * 120,
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class TestLiability:
|
| 74 |
+
def test_clean_sequence_passes(self):
|
| 75 |
+
rep = assess_liabilities(_clean_report(), _clean_seq())
|
| 76 |
+
assert rep.verdict == "pass"
|
| 77 |
+
assert rep.score == 100
|
| 78 |
+
assert rep.n_critical == 0 and rep.flag_count == 0
|
| 79 |
+
|
| 80 |
+
def test_polya_tail_not_flagged_as_homopolymer(self):
|
| 81 |
+
# body has no long run; the 120-A tail must be ignored
|
| 82 |
+
rep = assess_liabilities(_clean_report(), _clean_seq())
|
| 83 |
+
assert not any(f.category == "Homopolymer" for f in rep.flags)
|
| 84 |
+
|
| 85 |
+
def test_body_homopolymer_flagged(self):
|
| 86 |
+
seq = _clean_seq()
|
| 87 |
+
seq.cds = "ATG" + "A" * 16 + "GGCTAA" # 16-A run in the body
|
| 88 |
+
rep = assess_liabilities(_clean_report(), seq)
|
| 89 |
+
hp = [f for f in rep.flags if f.category == "Homopolymer"]
|
| 90 |
+
assert hp and hp[0].severity == CRITICAL
|
| 91 |
+
|
| 92 |
+
def test_extreme_gc_is_critical(self):
|
| 93 |
+
rep_dict = _clean_report()
|
| 94 |
+
rep_dict.gc_percent_global = 25.0
|
| 95 |
+
rep = assess_liabilities(rep_dict, _clean_seq())
|
| 96 |
+
assert any(f.category == "GC" and f.severity == CRITICAL for f in rep.flags)
|
| 97 |
+
|
| 98 |
+
def test_restriction_and_uridine_are_warnings(self):
|
| 99 |
+
r = _clean_report()
|
| 100 |
+
r.restriction_enzymes_present = ["EcoRI", "BamHI"]
|
| 101 |
+
r.uridine = SimpleNamespace(u_percent=46.0, high_u_stretches=[(1, 51, 50)])
|
| 102 |
+
rep = assess_liabilities(r, _clean_seq())
|
| 103 |
+
cats = {f.category for f in rep.flags}
|
| 104 |
+
assert "Restriction" in cats and "Uridine" in cats
|
| 105 |
+
assert rep.verdict == "review"
|
| 106 |
+
|
| 107 |
+
def test_missing_start_codon_fails(self):
|
| 108 |
+
r = _clean_report()
|
| 109 |
+
r.has_start_codon = False
|
| 110 |
+
rep = assess_liabilities(r, _clean_seq())
|
| 111 |
+
assert rep.verdict == "fail"
|
| 112 |
+
assert any(f.category == "CDS" and f.severity == CRITICAL for f in rep.flags)
|
| 113 |
+
|
| 114 |
+
def test_score_decreases_with_severity(self):
|
| 115 |
+
r = _clean_report()
|
| 116 |
+
r.has_start_codon = False # critical (-25)
|
| 117 |
+
r.restriction_enzymes_present = ["EcoRI"] # warning (-10)
|
| 118 |
+
rep = assess_liabilities(r, _clean_seq())
|
| 119 |
+
assert rep.score <= 65
|
| 120 |
+
assert rep.verdict == "fail"
|
| 121 |
+
|
| 122 |
+
def test_motif_hits_become_flags(self):
|
| 123 |
+
r = _clean_report()
|
| 124 |
+
r.motif_hits = scan_motifs(cds="ATGAATAAACCCTAA") # premature polyA (critical)
|
| 125 |
+
rep = assess_liabilities(r, _clean_seq())
|
| 126 |
+
assert any(f.category == "Motif" and f.severity == CRITICAL for f in rep.flags)
|
| 127 |
+
assert rep.verdict == "fail"
|
tests/test_runs.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for model run / experiment tracking."""
|
| 2 |
+
import math
|
| 3 |
+
|
| 4 |
+
from models.runs import summarize_run, RunHistory
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _run(name, version, scores, ts="2026-01-01 00:00:00"):
|
| 8 |
+
return summarize_run(
|
| 9 |
+
model_name=name, model_version=version, model_source="local",
|
| 10 |
+
worklist_name="wl", scores=scores, timestamp=ts,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class TestSummarize:
|
| 15 |
+
def test_basic_stats(self):
|
| 16 |
+
r = _run("m", "1.0", {"a": 1.0, "b": 3.0, "c": 5.0})
|
| 17 |
+
assert r.n_sequences == 3
|
| 18 |
+
assert r.score_min == 1.0 and r.score_max == 5.0
|
| 19 |
+
assert r.score_mean == 3.0
|
| 20 |
+
assert r.score_std > 0
|
| 21 |
+
|
| 22 |
+
def test_nan_scores_excluded(self):
|
| 23 |
+
r = _run("m", "1.0", {"a": 2.0, "b": float("nan")})
|
| 24 |
+
assert r.n_sequences == 1
|
| 25 |
+
assert r.score_mean == 2.0
|
| 26 |
+
|
| 27 |
+
def test_empty_scores_safe(self):
|
| 28 |
+
r = _run("m", "1.0", {})
|
| 29 |
+
assert r.n_sequences == 0
|
| 30 |
+
assert math.isnan(r.score_mean)
|
| 31 |
+
|
| 32 |
+
def test_label(self):
|
| 33 |
+
r = _run("MyModel", "2.1", {"a": 1.0})
|
| 34 |
+
assert "MyModel" in r.label and "2.1" in r.label
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class TestRunHistory:
|
| 38 |
+
def test_add_and_for_model(self):
|
| 39 |
+
h = RunHistory()
|
| 40 |
+
h.add(_run("A", "1.0", {"x": 1.0}))
|
| 41 |
+
h.add(_run("B", "1.0", {"x": 2.0}))
|
| 42 |
+
h.add(_run("A", "2.0", {"x": 3.0}))
|
| 43 |
+
assert len(h.runs) == 3
|
| 44 |
+
assert len(h.for_model("A")) == 2
|
| 45 |
+
assert h.model_names() == ["A", "B"]
|
| 46 |
+
|
| 47 |
+
def test_compare_deltas(self):
|
| 48 |
+
a = _run("A", "1.0", {"s1": 1.0, "s2": 2.0, "s3": 5.0})
|
| 49 |
+
b = _run("A", "2.0", {"s1": 2.0, "s2": 2.0, "s3": 4.0}) # +1, 0, -1
|
| 50 |
+
cmp = RunHistory.compare(a, b)
|
| 51 |
+
assert cmp.n_improved == 1
|
| 52 |
+
assert cmp.n_worsened == 1
|
| 53 |
+
assert cmp.n_unchanged == 1
|
| 54 |
+
assert cmp.mean_delta == 0.0
|
| 55 |
+
assert set(cmp.shared_ids) == {"s1", "s2", "s3"}
|
| 56 |
+
|
| 57 |
+
def test_compare_only_shared_sequences(self):
|
| 58 |
+
a = _run("A", "1.0", {"s1": 1.0, "only_a": 9.0})
|
| 59 |
+
b = _run("A", "2.0", {"s1": 2.0, "only_b": 8.0})
|
| 60 |
+
cmp = RunHistory.compare(a, b)
|
| 61 |
+
assert cmp.shared_ids == ["s1"]
|
| 62 |
+
assert cmp.deltas["s1"] == 1.0
|
ui/app.py
CHANGED
|
@@ -40,6 +40,8 @@ from ui.components.plasmid_view import PlasmidView
|
|
| 40 |
from ui.components.model_repository import ModelRepositoryPanel
|
| 41 |
from ui.components.plasmid_assembly import PlasmidAssemblyPanel
|
| 42 |
from ui.components.generate_sequences import GenerateSequencesPanel
|
|
|
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
# ββ Design tokens βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -150,11 +152,13 @@ _TAB_NAMES = [
|
|
| 150 |
"Import Data",
|
| 151 |
"Model Repository",
|
| 152 |
"Worklist",
|
|
|
|
|
|
|
| 153 |
"Parts Workshop",
|
| 154 |
"Assemble Plasmid",
|
| 155 |
"Generate Sequences",
|
| 156 |
]
|
| 157 |
-
_TAB_KEYS = ["import_db", "model_repo", "worklist", "parts", "assemble", "generate"]
|
| 158 |
|
| 159 |
|
| 160 |
logger = logging.getLogger(__name__)
|
|
@@ -185,6 +189,8 @@ class StudioApp(param.Parameterized):
|
|
| 185 |
self._model_repo = ModelRepositoryPanel(self.state)
|
| 186 |
self._assembly = PlasmidAssemblyPanel(self.state)
|
| 187 |
self._generate = GenerateSequencesPanel(self.state)
|
|
|
|
|
|
|
| 188 |
|
| 189 |
# ββ Build persistent widgets once βββββββββββββββββββββββββββββββββββββ
|
| 190 |
self._tabs = pn.Tabs(
|
|
@@ -200,9 +206,11 @@ class StudioApp(param.Parameterized):
|
|
| 200 |
pn.panel(self._worklist.panel),
|
| 201 |
sizing_mode="stretch_width",
|
| 202 |
)),
|
| 203 |
-
(_TAB_NAMES[3], pn.panel(self.
|
| 204 |
-
(_TAB_NAMES[4], pn.panel(self.
|
| 205 |
-
(_TAB_NAMES[5], pn.panel(self.
|
|
|
|
|
|
|
| 206 |
active=0,
|
| 207 |
sizing_mode="stretch_width",
|
| 208 |
)
|
|
|
|
| 40 |
from ui.components.model_repository import ModelRepositoryPanel
|
| 41 |
from ui.components.plasmid_assembly import PlasmidAssemblyPanel
|
| 42 |
from ui.components.generate_sequences import GenerateSequencesPanel
|
| 43 |
+
from ui.components.cluster_view import ClusterView
|
| 44 |
+
from ui.components.experiment_view import ExperimentView
|
| 45 |
|
| 46 |
|
| 47 |
# ββ Design tokens βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 152 |
"Import Data",
|
| 153 |
"Model Repository",
|
| 154 |
"Worklist",
|
| 155 |
+
"Cluster & Tree",
|
| 156 |
+
"Experiments",
|
| 157 |
"Parts Workshop",
|
| 158 |
"Assemble Plasmid",
|
| 159 |
"Generate Sequences",
|
| 160 |
]
|
| 161 |
+
_TAB_KEYS = ["import_db", "model_repo", "worklist", "clusters", "experiments", "parts", "assemble", "generate"]
|
| 162 |
|
| 163 |
|
| 164 |
logger = logging.getLogger(__name__)
|
|
|
|
| 189 |
self._model_repo = ModelRepositoryPanel(self.state)
|
| 190 |
self._assembly = PlasmidAssemblyPanel(self.state)
|
| 191 |
self._generate = GenerateSequencesPanel(self.state)
|
| 192 |
+
self._cluster = ClusterView(self.state)
|
| 193 |
+
self._experiments = ExperimentView(self.state)
|
| 194 |
|
| 195 |
# ββ Build persistent widgets once βββββββββββββββββββββββββββββββββββββ
|
| 196 |
self._tabs = pn.Tabs(
|
|
|
|
| 206 |
pn.panel(self._worklist.panel),
|
| 207 |
sizing_mode="stretch_width",
|
| 208 |
)),
|
| 209 |
+
(_TAB_NAMES[3], pn.panel(self._cluster.panel)),
|
| 210 |
+
(_TAB_NAMES[4], pn.panel(self._experiments.panel)),
|
| 211 |
+
(_TAB_NAMES[5], pn.panel(self._parts.panel)),
|
| 212 |
+
(_TAB_NAMES[6], pn.panel(self._assembly.panel)),
|
| 213 |
+
(_TAB_NAMES[7], pn.panel(self._generate.panel)),
|
| 214 |
active=0,
|
| 215 |
sizing_mode="stretch_width",
|
| 216 |
)
|
ui/components/analysis_dashboard.py
CHANGED
|
@@ -181,6 +181,115 @@ def _structure_card(report: AnalysisReport) -> pn.pane.HTML:
|
|
| 181 |
""")
|
| 182 |
|
| 183 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
def _metric_panel(title: str, content: pn.viewable.Viewable) -> pn.Column:
|
| 185 |
return pn.Column(
|
| 186 |
pn.pane.HTML(
|
|
@@ -266,6 +375,11 @@ class AnalysisDashboard(param.Parameterized):
|
|
| 266 |
),
|
| 267 |
pn.pane.HTML(warnings_html) if warnings_html else pn.pane.HTML(""),
|
| 268 |
pn.pane.HTML(summary_html),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
pn.layout.Divider(),
|
| 270 |
_gc_plot(report),
|
| 271 |
pn.GridBox(
|
|
@@ -274,6 +388,8 @@ class AnalysisDashboard(param.Parameterized):
|
|
| 274 |
_metric_panel("Kozak Context", _kozak_card(report)),
|
| 275 |
_metric_panel("Homopolymers", _homopolymer_card(report)),
|
| 276 |
_metric_panel("Restriction Sites", _restriction_card(report)),
|
|
|
|
|
|
|
| 277 |
_metric_panel("Secondary Structure (MFE)", _structure_card(report)),
|
| 278 |
ncols=2,
|
| 279 |
sizing_mode="stretch_width",
|
|
|
|
| 181 |
""")
|
| 182 |
|
| 183 |
|
| 184 |
+
def _uridine_card(report: AnalysisReport) -> pn.pane.HTML:
|
| 185 |
+
u = report.uridine
|
| 186 |
+
if u is None:
|
| 187 |
+
return pn.pane.HTML('<div style="color:#64748B;font-size:12px;">No uridine data.</div>')
|
| 188 |
+
color = "#059669" if u.u_percent < 35 else "#D97706" if u.u_percent < 45 else "#DC2626"
|
| 189 |
+
stretches = len(u.high_u_stretches)
|
| 190 |
+
return pn.pane.HTML(f"""
|
| 191 |
+
<div>
|
| 192 |
+
<span style="font-size:18px;font-weight:700;color:{color};">{u.u_percent:.1f}%</span>
|
| 193 |
+
<span style="font-size:11px;color:#64748B;margin-left:6px;">uridine</span>
|
| 194 |
+
</div>
|
| 195 |
+
<div style="font-size:11px;color:#64748B;margin-top:4px;">
|
| 196 |
+
{stretches} high-U stretch(es) Β· U/A ratio {u.ua_ratio:.2f}</div>
|
| 197 |
+
<div style="font-size:10px;color:#94A3B8;margin-top:3px;">
|
| 198 |
+
High U is immunostimulatory β modified nucleotides mitigate it.</div>
|
| 199 |
+
""")
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def _motif_card(report: AnalysisReport) -> pn.pane.HTML:
|
| 203 |
+
hits = report.motif_hits
|
| 204 |
+
if not hits:
|
| 205 |
+
return pn.pane.HTML(
|
| 206 |
+
'<div style="color:#059669;font-size:12px;">No liability motifs detected.</div>'
|
| 207 |
+
)
|
| 208 |
+
sev_color = {"critical": "#DC2626", "warning": "#D97706", "info": "#64748B"}
|
| 209 |
+
rows = ""
|
| 210 |
+
for h in hits[:12]:
|
| 211 |
+
c = sev_color.get(h.severity, "#64748B")
|
| 212 |
+
rows += (
|
| 213 |
+
f'<div style="display:flex;gap:8px;align-items:baseline;margin-bottom:3px;">'
|
| 214 |
+
f'<span style="background:{c};color:white;border-radius:3px;padding:1px 5px;'
|
| 215 |
+
f'font-size:9px;text-transform:uppercase;">{h.severity}</span>'
|
| 216 |
+
f'<span style="font-size:12px;">{h.label}</span>'
|
| 217 |
+
f'<span style="font-size:10px;color:#94A3B8;font-family:monospace;">'
|
| 218 |
+
f'{h.region}:{h.start}</span></div>'
|
| 219 |
+
)
|
| 220 |
+
more = f'<div style="font-size:10px;color:#94A3B8;">+{len(hits)-12} more</div>' if len(hits) > 12 else ""
|
| 221 |
+
return pn.pane.HTML(rows + more)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
_VERDICT_STYLE = {
|
| 225 |
+
"pass": ("#059669", "PASS"),
|
| 226 |
+
"review": ("#D97706", "REVIEW"),
|
| 227 |
+
"fail": ("#DC2626", "FAIL"),
|
| 228 |
+
}
|
| 229 |
+
_SEV_STYLE = {
|
| 230 |
+
"critical": ("#DC2626", "Critical"),
|
| 231 |
+
"warning": ("#D97706", "Warning"),
|
| 232 |
+
"info": ("#64748B", "Info"),
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def render_liability_panel(report: AnalysisReport) -> pn.pane.HTML:
|
| 237 |
+
"""Reusable liability / QC scorecard + ranked flag list (pure HTML)."""
|
| 238 |
+
lia = getattr(report, "liability", None)
|
| 239 |
+
if lia is None:
|
| 240 |
+
return pn.pane.HTML('<div style="color:#64748B;font-size:12px;">No liability assessment.</div>')
|
| 241 |
+
|
| 242 |
+
vcolor, vlabel = _VERDICT_STYLE.get(lia.verdict, ("#64748B", lia.verdict.upper()))
|
| 243 |
+
score_color = "#059669" if lia.score >= 85 else "#D97706" if lia.score >= 60 else "#DC2626"
|
| 244 |
+
|
| 245 |
+
counts = (
|
| 246 |
+
f'<span style="color:#DC2626;font-weight:700;">{lia.n_critical}</span> critical Β· '
|
| 247 |
+
f'<span style="color:#D97706;font-weight:700;">{lia.n_warning}</span> warning Β· '
|
| 248 |
+
f'<span style="color:#64748B;font-weight:700;">{lia.n_info}</span> info'
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
if not lia.flags:
|
| 252 |
+
flag_html = (
|
| 253 |
+
'<div style="color:#059669;font-size:12px;padding:6px 0;">'
|
| 254 |
+
f'No liabilities flagged across {lia.checks_run} checks.</div>'
|
| 255 |
+
)
|
| 256 |
+
else:
|
| 257 |
+
items = ""
|
| 258 |
+
for f in lia.sorted_flags():
|
| 259 |
+
sc, sl = _SEV_STYLE.get(f.severity, ("#64748B", f.severity.title()))
|
| 260 |
+
loc = f'<span style="font-family:monospace;color:#94A3B8;font-size:10px;margin-left:6px;">{f.location}</span>' if f.location else ""
|
| 261 |
+
rec = f'<div style="font-size:11px;color:#64748B;margin-top:2px;">β³ {f.recommendation}</div>' if f.recommendation else ""
|
| 262 |
+
items += f"""
|
| 263 |
+
<div style="border-left:3px solid {sc};padding:6px 0 6px 10px;margin-bottom:8px;">
|
| 264 |
+
<div style="display:flex;align-items:baseline;gap:8px;">
|
| 265 |
+
<span style="background:{sc};color:white;border-radius:3px;padding:1px 6px;
|
| 266 |
+
font-size:9px;text-transform:uppercase;font-weight:700;">{sl}</span>
|
| 267 |
+
<span style="font-size:13px;font-weight:600;color:#0F172A;">{f.title}</span>
|
| 268 |
+
{loc}
|
| 269 |
+
</div>
|
| 270 |
+
<div style="font-size:12px;color:#334155;margin-top:2px;">{f.detail}</div>
|
| 271 |
+
{rec}
|
| 272 |
+
</div>"""
|
| 273 |
+
flag_html = items
|
| 274 |
+
|
| 275 |
+
return pn.pane.HTML(f"""
|
| 276 |
+
<div style="border:1px solid #CBD5E1;border-radius:8px;padding:14px 16px;background:white;">
|
| 277 |
+
<div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;
|
| 278 |
+
border-bottom:1px solid #E2E8F0;padding-bottom:10px;margin-bottom:10px;">
|
| 279 |
+
<div>
|
| 280 |
+
<div style="font-size:10px;color:#64748B;letter-spacing:.05em;">QC SCORE</div>
|
| 281 |
+
<div style="font-size:30px;font-weight:800;color:{score_color};line-height:1;">{lia.score}</div>
|
| 282 |
+
</div>
|
| 283 |
+
<div style="background:{vcolor};color:white;border-radius:6px;padding:6px 14px;
|
| 284 |
+
font-size:14px;font-weight:800;letter-spacing:.05em;">{vlabel}</div>
|
| 285 |
+
<div style="font-size:12px;color:#475569;">{counts}</div>
|
| 286 |
+
<div style="font-size:11px;color:#94A3B8;margin-left:auto;">{lia.checks_run} checks</div>
|
| 287 |
+
</div>
|
| 288 |
+
{flag_html}
|
| 289 |
+
</div>
|
| 290 |
+
""")
|
| 291 |
+
|
| 292 |
+
|
| 293 |
def _metric_panel(title: str, content: pn.viewable.Viewable) -> pn.Column:
|
| 294 |
return pn.Column(
|
| 295 |
pn.pane.HTML(
|
|
|
|
| 375 |
),
|
| 376 |
pn.pane.HTML(warnings_html) if warnings_html else pn.pane.HTML(""),
|
| 377 |
pn.pane.HTML(summary_html),
|
| 378 |
+
pn.pane.HTML(
|
| 379 |
+
'<div style="font-size:13px;font-weight:700;color:#0F172A;'
|
| 380 |
+
'margin:6px 0 6px 0;">Liability / QC assessment</div>'
|
| 381 |
+
),
|
| 382 |
+
render_liability_panel(report),
|
| 383 |
pn.layout.Divider(),
|
| 384 |
_gc_plot(report),
|
| 385 |
pn.GridBox(
|
|
|
|
| 388 |
_metric_panel("Kozak Context", _kozak_card(report)),
|
| 389 |
_metric_panel("Homopolymers", _homopolymer_card(report)),
|
| 390 |
_metric_panel("Restriction Sites", _restriction_card(report)),
|
| 391 |
+
_metric_panel("Uridine Content", _uridine_card(report)),
|
| 392 |
+
_metric_panel("Liability Motifs", _motif_card(report)),
|
| 393 |
_metric_panel("Secondary Structure (MFE)", _structure_card(report)),
|
| 394 |
ncols=2,
|
| 395 |
sizing_mode="stretch_width",
|
ui/components/cluster_view.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Cluster & tree explorer.
|
| 3 |
+
|
| 4 |
+
Clusters the current worklist's sequences by k-mer distance and renders an
|
| 5 |
+
interactive dendrogram (UPGMA tree) with a distance cutoff that splits the tree
|
| 6 |
+
into flat clusters β an exploration view analogous to repertoire clustering.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import TYPE_CHECKING, List, Tuple
|
| 11 |
+
|
| 12 |
+
import panel as pn
|
| 13 |
+
import param
|
| 14 |
+
import plotly.graph_objects as go
|
| 15 |
+
|
| 16 |
+
from core.analysis.clustering import (
|
| 17 |
+
kmer_distance_matrix, upgma, flat_clusters, dendrogram_layout,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
if TYPE_CHECKING:
|
| 21 |
+
from ui.state import AppState
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# Categorical palette for clusters (cycled)
|
| 25 |
+
_PALETTE = [
|
| 26 |
+
"#0F766E", "#2563EB", "#D97706", "#DC2626", "#7C3AED",
|
| 27 |
+
"#059669", "#DB2777", "#0891B2", "#CA8A04", "#4F46E5",
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _empty(msg: str) -> pn.pane.HTML:
|
| 32 |
+
return pn.pane.HTML(f'<div style="color:#64748B;padding:30px;text-align:center;">{msg}</div>')
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class ClusterView(param.Parameterized):
|
| 36 |
+
"""Worklist clustering + dendrogram panel."""
|
| 37 |
+
|
| 38 |
+
def __init__(self, state: "AppState", **params: object) -> None:
|
| 39 |
+
super().__init__(**params)
|
| 40 |
+
self._state = state
|
| 41 |
+
self._k = pn.widgets.IntSlider(
|
| 42 |
+
name="k-mer size", start=2, end=6, value=4, width=160, margin=(4, 10))
|
| 43 |
+
self._threshold = pn.widgets.FloatSlider(
|
| 44 |
+
name="Distance cutoff", start=0.0, end=1.0, step=0.02, value=0.30,
|
| 45 |
+
width=260, margin=(4, 10))
|
| 46 |
+
self._region = pn.widgets.Select(
|
| 47 |
+
name="Compare", options=["Full sequence", "CDS only"],
|
| 48 |
+
value="Full sequence", width=150, margin=(4, 10))
|
| 49 |
+
|
| 50 |
+
# ββ data ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 51 |
+
def _sequences(self) -> Tuple[List[str], List[str]]:
|
| 52 |
+
names: List[str] = []
|
| 53 |
+
seqs: List[str] = []
|
| 54 |
+
for item in self._state.worklist.items:
|
| 55 |
+
s = item.sequence
|
| 56 |
+
if self._region.value == "CDS only":
|
| 57 |
+
txt = s.cds or ""
|
| 58 |
+
else:
|
| 59 |
+
try:
|
| 60 |
+
txt = s.assembled_sequence
|
| 61 |
+
except Exception:
|
| 62 |
+
txt = s.cds or ""
|
| 63 |
+
if txt:
|
| 64 |
+
names.append(s.name)
|
| 65 |
+
seqs.append(txt)
|
| 66 |
+
return names, seqs
|
| 67 |
+
|
| 68 |
+
# ββ rendering βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 69 |
+
def _render(self, k: int, threshold: float, region: str) -> pn.viewable.Viewable:
|
| 70 |
+
names, seqs = self._sequences()
|
| 71 |
+
n = len(seqs)
|
| 72 |
+
if n < 2:
|
| 73 |
+
return _empty("Add at least 2 sequences (with content) to the worklist to cluster.")
|
| 74 |
+
|
| 75 |
+
D = kmer_distance_matrix(seqs, k=k)
|
| 76 |
+
link, order = upgma(D)
|
| 77 |
+
clusters = flat_clusters(link, n, threshold)
|
| 78 |
+
layout = dendrogram_layout(link, order)
|
| 79 |
+
n_clusters = len(set(clusters))
|
| 80 |
+
|
| 81 |
+
fig = go.Figure()
|
| 82 |
+
|
| 83 |
+
# dendrogram branches as a single trace (None-separated segments)
|
| 84 |
+
xs: List = []
|
| 85 |
+
ys: List = []
|
| 86 |
+
for (x0, x1, y0, y1) in layout.segments:
|
| 87 |
+
xs += [x0, x1, None]
|
| 88 |
+
ys += [y0, y1, None]
|
| 89 |
+
fig.add_trace(go.Scatter(
|
| 90 |
+
x=xs, y=ys, mode="lines",
|
| 91 |
+
line={"color": "#94A3B8", "width": 1.2},
|
| 92 |
+
hoverinfo="skip", showlegend=False,
|
| 93 |
+
))
|
| 94 |
+
|
| 95 |
+
# cutoff line
|
| 96 |
+
fig.add_hline(
|
| 97 |
+
y=threshold, line_dash="dash", line_color="#DC2626", opacity=0.7,
|
| 98 |
+
annotation_text=f"cutoff {threshold:.2f}", annotation_position="top left",
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
# leaf markers grouped per cluster (legend = clusters)
|
| 102 |
+
for c in range(n_clusters):
|
| 103 |
+
cx = [layout.leaf_x[leaf] for leaf in order if clusters[leaf] == c]
|
| 104 |
+
ctext = [names[leaf] for leaf in order if clusters[leaf] == c]
|
| 105 |
+
if not cx:
|
| 106 |
+
continue
|
| 107 |
+
fig.add_trace(go.Scatter(
|
| 108 |
+
x=cx, y=[0] * len(cx), mode="markers",
|
| 109 |
+
marker={"size": 11, "color": _PALETTE[c % len(_PALETTE)],
|
| 110 |
+
"line": {"color": "white", "width": 1}},
|
| 111 |
+
name=f"Cluster {c}", text=ctext,
|
| 112 |
+
hovertemplate="%{text}<br>cluster " + str(c) + "<extra></extra>",
|
| 113 |
+
))
|
| 114 |
+
|
| 115 |
+
fig.update_layout(
|
| 116 |
+
title={"text": f"UPGMA dendrogram β {n} sequences, {n_clusters} cluster(s)",
|
| 117 |
+
"font": {"size": 13}},
|
| 118 |
+
xaxis={"tickmode": "array",
|
| 119 |
+
"tickvals": [layout.leaf_x[leaf] for leaf in order],
|
| 120 |
+
"ticktext": [names[leaf] for leaf in order],
|
| 121 |
+
"tickangle": -40, "tickfont": {"size": 10}},
|
| 122 |
+
yaxis={"title": "k-mer distance", "rangemode": "tozero"},
|
| 123 |
+
height=440, margin={"l": 60, "r": 20, "t": 40, "b": 130},
|
| 124 |
+
plot_bgcolor="#F8FAFC", paper_bgcolor="white",
|
| 125 |
+
legend={"orientation": "h", "y": -0.45, "font": {"size": 10}},
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
# cluster membership summary
|
| 129 |
+
groups: dict = {}
|
| 130 |
+
for leaf in order:
|
| 131 |
+
groups.setdefault(clusters[leaf], []).append(names[leaf])
|
| 132 |
+
rows = ""
|
| 133 |
+
for c in sorted(groups):
|
| 134 |
+
color = _PALETTE[c % len(_PALETTE)]
|
| 135 |
+
members = ", ".join(groups[c])
|
| 136 |
+
rows += (
|
| 137 |
+
f'<tr>'
|
| 138 |
+
f'<td style="padding:4px 10px;"><span style="display:inline-block;width:10px;'
|
| 139 |
+
f'height:10px;border-radius:2px;background:{color};margin-right:6px;"></span>'
|
| 140 |
+
f'Cluster {c}</td>'
|
| 141 |
+
f'<td style="padding:4px 10px;color:#475569;font-size:12px;">{len(groups[c])}</td>'
|
| 142 |
+
f'<td style="padding:4px 10px;color:#334155;font-size:12px;">{members}</td>'
|
| 143 |
+
f'</tr>'
|
| 144 |
+
)
|
| 145 |
+
table = pn.pane.HTML(
|
| 146 |
+
'<div style="font-size:12px;font-weight:700;margin:10px 0 4px 0;">Cluster membership</div>'
|
| 147 |
+
'<table style="border-collapse:collapse;width:100%;">'
|
| 148 |
+
'<tr style="border-bottom:1px solid #E2E8F0;font-size:11px;color:#64748B;">'
|
| 149 |
+
'<td style="padding:4px 10px;">Cluster</td><td style="padding:4px 10px;">Size</td>'
|
| 150 |
+
'<td style="padding:4px 10px;">Members</td></tr>'
|
| 151 |
+
f'{rows}</table>'
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
return pn.Column(pn.pane.Plotly(fig, sizing_mode="stretch_width"), table,
|
| 155 |
+
sizing_mode="stretch_width")
|
| 156 |
+
|
| 157 |
+
@param.depends("_state.worklist")
|
| 158 |
+
def panel(self) -> pn.Column:
|
| 159 |
+
wl = self._state.worklist
|
| 160 |
+
if wl is None or wl.count == 0:
|
| 161 |
+
return pn.Column(
|
| 162 |
+
pn.pane.HTML('<div style="font-size:16px;font-weight:800;padding:8px 0;">'
|
| 163 |
+
'Cluster & Tree</div>'),
|
| 164 |
+
_empty("Worklist is empty. Import sequences to cluster them."),
|
| 165 |
+
styles={"padding": "8px 16px"},
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
controls = pn.Row(
|
| 169 |
+
self._region, self._k, self._threshold,
|
| 170 |
+
sizing_mode="stretch_width",
|
| 171 |
+
styles={"background": "#F8FAFC", "border": "1px solid #E2E8F0",
|
| 172 |
+
"border-radius": "6px", "padding": "4px 8px", "margin": "0 0 8px 0"},
|
| 173 |
+
)
|
| 174 |
+
body = pn.bind(self._render, self._k, self._threshold, self._region)
|
| 175 |
+
return pn.Column(
|
| 176 |
+
pn.pane.HTML('<div style="font-size:16px;font-weight:800;padding:8px 0;">'
|
| 177 |
+
f'Cluster & Tree <span style="color:#64748B;font-size:13px;">'
|
| 178 |
+
f'({wl.count} sequences)</span></div>'),
|
| 179 |
+
controls,
|
| 180 |
+
pn.panel(body),
|
| 181 |
+
sizing_mode="stretch_width",
|
| 182 |
+
styles={"padding": "8px 16px"},
|
| 183 |
+
)
|
ui/components/experiment_view.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Experiments / model lifecycle view.
|
| 3 |
+
|
| 4 |
+
Surfaces the model lifecycle that sits on top of the registry:
|
| 5 |
+
- registered models and their versions,
|
| 6 |
+
- a run history (every scoring run, with version + score statistics),
|
| 7 |
+
- a run-vs-run comparison (e.g. two versions of the same model) showing how
|
| 8 |
+
per-sequence scores shifted.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from typing import TYPE_CHECKING, Dict, List, Optional
|
| 13 |
+
|
| 14 |
+
import panel as pn
|
| 15 |
+
import param
|
| 16 |
+
|
| 17 |
+
if TYPE_CHECKING:
|
| 18 |
+
from ui.state import AppState
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _fmt(x: Optional[float]) -> str:
|
| 22 |
+
return "β" if x is None or (isinstance(x, float) and x != x) else f"{x:.3f}"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class ExperimentView(param.Parameterized):
|
| 26 |
+
"""Model lifecycle / experiment tracking panel."""
|
| 27 |
+
|
| 28 |
+
def __init__(self, state: "AppState", **params: object) -> None:
|
| 29 |
+
super().__init__(**params)
|
| 30 |
+
self._state = state
|
| 31 |
+
self._run_a = pn.widgets.Select(name="Run A (baseline)", width=320, margin=(4, 10))
|
| 32 |
+
self._run_b = pn.widgets.Select(name="Run B (compare)", width=320, margin=(4, 10))
|
| 33 |
+
|
| 34 |
+
# ββ registered models βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 35 |
+
def _models_table(self) -> pn.pane.HTML:
|
| 36 |
+
reg = self._state.model_registry
|
| 37 |
+
models = reg.all_models if reg else []
|
| 38 |
+
if not models:
|
| 39 |
+
return pn.pane.HTML('<div style="color:#64748B;font-size:12px;">No models registered yet.</div>')
|
| 40 |
+
rows = ""
|
| 41 |
+
for m in models:
|
| 42 |
+
try:
|
| 43 |
+
ver = m.model.version
|
| 44 |
+
except Exception:
|
| 45 |
+
ver = "β"
|
| 46 |
+
rows += (
|
| 47 |
+
f'<tr style="border-bottom:1px solid #F1F5F9;">'
|
| 48 |
+
f'<td style="padding:4px 10px;font-size:12px;">{m.model.name}</td>'
|
| 49 |
+
f'<td style="padding:4px 10px;font-size:12px;color:#475569;">{m.model_type}</td>'
|
| 50 |
+
f'<td style="padding:4px 10px;font-size:12px;"><span style="background:#F0FDFA;'
|
| 51 |
+
f'color:#0F766E;border-radius:3px;padding:1px 6px;">v{ver}</span></td>'
|
| 52 |
+
f'<td style="padding:4px 10px;font-size:12px;color:#64748B;">{m.source}</td>'
|
| 53 |
+
f'</tr>'
|
| 54 |
+
)
|
| 55 |
+
return pn.pane.HTML(
|
| 56 |
+
'<table style="border-collapse:collapse;width:100%;">'
|
| 57 |
+
'<tr style="font-size:11px;color:#64748B;border-bottom:1px solid #E2E8F0;">'
|
| 58 |
+
'<td style="padding:4px 10px;">Model</td><td style="padding:4px 10px;">Type</td>'
|
| 59 |
+
'<td style="padding:4px 10px;">Version</td><td style="padding:4px 10px;">Source</td></tr>'
|
| 60 |
+
f'{rows}</table>'
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
# ββ run history βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 64 |
+
def _runs_table(self) -> pn.pane.HTML:
|
| 65 |
+
runs = self._state.run_history.runs
|
| 66 |
+
if not runs:
|
| 67 |
+
return pn.pane.HTML(
|
| 68 |
+
'<div style="color:#64748B;font-size:12px;">No runs yet. Score a worklist '
|
| 69 |
+
'with a model (Worklist β Run) to record an experiment.</div>'
|
| 70 |
+
)
|
| 71 |
+
rows = ""
|
| 72 |
+
for r in reversed(runs): # newest first
|
| 73 |
+
rows += (
|
| 74 |
+
f'<tr style="border-bottom:1px solid #F1F5F9;">'
|
| 75 |
+
f'<td style="padding:4px 10px;font-size:11px;color:#64748B;">{r.timestamp}</td>'
|
| 76 |
+
f'<td style="padding:4px 10px;font-size:12px;">{r.model_name}</td>'
|
| 77 |
+
f'<td style="padding:4px 10px;font-size:12px;">v{r.model_version}</td>'
|
| 78 |
+
f'<td style="padding:4px 10px;font-size:12px;">{r.n_sequences}</td>'
|
| 79 |
+
f'<td style="padding:4px 10px;font-size:12px;">{_fmt(r.score_mean)}</td>'
|
| 80 |
+
f'<td style="padding:4px 10px;font-size:12px;color:#64748B;">'
|
| 81 |
+
f'{_fmt(r.score_min)}β{_fmt(r.score_max)}</td>'
|
| 82 |
+
f'<td style="padding:4px 10px;font-size:11px;color:#94A3B8;">{r.worklist_name}</td>'
|
| 83 |
+
f'</tr>'
|
| 84 |
+
)
|
| 85 |
+
return pn.pane.HTML(
|
| 86 |
+
'<table style="border-collapse:collapse;width:100%;">'
|
| 87 |
+
'<tr style="font-size:11px;color:#64748B;border-bottom:1px solid #E2E8F0;">'
|
| 88 |
+
'<td style="padding:4px 10px;">Time</td><td style="padding:4px 10px;">Model</td>'
|
| 89 |
+
'<td style="padding:4px 10px;">Version</td><td style="padding:4px 10px;">N</td>'
|
| 90 |
+
'<td style="padding:4px 10px;">Mean</td><td style="padding:4px 10px;">Range</td>'
|
| 91 |
+
'<td style="padding:4px 10px;">Worklist</td></tr>'
|
| 92 |
+
f'{rows}</table>'
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
# ββ comparison ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 96 |
+
def _run_options(self) -> Dict[str, object]:
|
| 97 |
+
return {f"{r.run_id} Β· {r.label}": r.run_id for r in reversed(self._state.run_history.runs)}
|
| 98 |
+
|
| 99 |
+
def _name_lookup(self) -> Dict[str, str]:
|
| 100 |
+
names: Dict[str, str] = {}
|
| 101 |
+
for item in self._state.worklist.items:
|
| 102 |
+
names[item.sequence.id] = item.sequence.name
|
| 103 |
+
return names
|
| 104 |
+
|
| 105 |
+
def _render_comparison(self, run_a_id: str, run_b_id: str) -> pn.viewable.Viewable:
|
| 106 |
+
from models.runs import RunHistory
|
| 107 |
+
runs = {r.run_id: r for r in self._state.run_history.runs}
|
| 108 |
+
ra, rb = runs.get(run_a_id), runs.get(run_b_id)
|
| 109 |
+
if not ra or not rb:
|
| 110 |
+
return pn.pane.HTML('<div style="color:#64748B;font-size:12px;">Pick two runs to compare.</div>')
|
| 111 |
+
if ra.run_id == rb.run_id:
|
| 112 |
+
return pn.pane.HTML('<div style="color:#64748B;font-size:12px;">Pick two different runs.</div>')
|
| 113 |
+
|
| 114 |
+
cmp = RunHistory.compare(ra, rb)
|
| 115 |
+
if not cmp.shared_ids:
|
| 116 |
+
return pn.pane.HTML(
|
| 117 |
+
'<div style="color:#D97706;font-size:12px;">No shared sequences between these runs.</div>'
|
| 118 |
+
)
|
| 119 |
+
d = cmp.mean_delta
|
| 120 |
+
dcolor = "#059669" if d > 0 else "#DC2626" if d < 0 else "#64748B"
|
| 121 |
+
summary = pn.pane.HTML(f"""
|
| 122 |
+
<div style="display:flex;gap:18px;align-items:center;flex-wrap:wrap;
|
| 123 |
+
border:1px solid #E2E8F0;border-radius:8px;padding:10px 14px;margin:6px 0;">
|
| 124 |
+
<div><div style="font-size:10px;color:#64748B;">MEAN Ξ (B β A)</div>
|
| 125 |
+
<div style="font-size:22px;font-weight:800;color:{dcolor};">{d:+.3f}</div></div>
|
| 126 |
+
<div style="font-size:12px;color:#059669;">β² {cmp.n_improved} improved</div>
|
| 127 |
+
<div style="font-size:12px;color:#DC2626;">βΌ {cmp.n_worsened} worsened</div>
|
| 128 |
+
<div style="font-size:12px;color:#64748B;">= {cmp.n_unchanged} unchanged</div>
|
| 129 |
+
<div style="font-size:11px;color:#94A3B8;margin-left:auto;">
|
| 130 |
+
{len(cmp.shared_ids)} shared sequences</div>
|
| 131 |
+
</div>
|
| 132 |
+
""")
|
| 133 |
+
|
| 134 |
+
names = self._name_lookup()
|
| 135 |
+
ordered = sorted(cmp.deltas.items(), key=lambda kv: kv[1]) # worstβbest
|
| 136 |
+
rows = ""
|
| 137 |
+
for sid, delta in ordered[:50]:
|
| 138 |
+
c = "#059669" if delta > 0 else "#DC2626" if delta < 0 else "#64748B"
|
| 139 |
+
nm = names.get(sid, sid[:8])
|
| 140 |
+
rows += (
|
| 141 |
+
f'<tr style="border-bottom:1px solid #F1F5F9;">'
|
| 142 |
+
f'<td style="padding:3px 10px;font-size:12px;">{nm}</td>'
|
| 143 |
+
f'<td style="padding:3px 10px;font-size:12px;color:#64748B;">{_fmt(ra.scores.get(sid))}</td>'
|
| 144 |
+
f'<td style="padding:3px 10px;font-size:12px;color:#64748B;">{_fmt(rb.scores.get(sid))}</td>'
|
| 145 |
+
f'<td style="padding:3px 10px;font-size:12px;font-weight:700;color:{c};">{delta:+.3f}</td>'
|
| 146 |
+
f'</tr>'
|
| 147 |
+
)
|
| 148 |
+
table = pn.pane.HTML(
|
| 149 |
+
'<table style="border-collapse:collapse;width:100%;">'
|
| 150 |
+
'<tr style="font-size:11px;color:#64748B;border-bottom:1px solid #E2E8F0;">'
|
| 151 |
+
'<td style="padding:3px 10px;">Sequence</td>'
|
| 152 |
+
f'<td style="padding:3px 10px;">A (v{ra.model_version})</td>'
|
| 153 |
+
f'<td style="padding:3px 10px;">B (v{rb.model_version})</td>'
|
| 154 |
+
'<td style="padding:3px 10px;">Ξ</td></tr>'
|
| 155 |
+
f'{rows}</table>'
|
| 156 |
+
)
|
| 157 |
+
return pn.Column(summary, table, sizing_mode="stretch_width")
|
| 158 |
+
|
| 159 |
+
# ββ panel βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 160 |
+
@param.depends("_state.run_history", "_state.model_registry")
|
| 161 |
+
def panel(self) -> pn.Column:
|
| 162 |
+
# refresh comparison dropdown options
|
| 163 |
+
opts = self._run_options()
|
| 164 |
+
self._run_a.options = opts
|
| 165 |
+
self._run_b.options = opts
|
| 166 |
+
run_ids = list(opts.values())
|
| 167 |
+
if len(run_ids) >= 2:
|
| 168 |
+
self._run_a.value = run_ids[1] # older of the two newest
|
| 169 |
+
self._run_b.value = run_ids[0] # newest
|
| 170 |
+
elif run_ids:
|
| 171 |
+
self._run_a.value = self._run_b.value = run_ids[0]
|
| 172 |
+
|
| 173 |
+
comparison = pn.bind(self._render_comparison, self._run_a, self._run_b)
|
| 174 |
+
|
| 175 |
+
def card(title: str, body: pn.viewable.Viewable) -> pn.Column:
|
| 176 |
+
return pn.Column(
|
| 177 |
+
pn.pane.HTML(f'<div style="font-size:13px;font-weight:700;margin:6px 0;">{title}</div>'),
|
| 178 |
+
body,
|
| 179 |
+
styles={"background": "white", "border": "1px solid #CBD5E1",
|
| 180 |
+
"border-radius": "8px", "padding": "12px 14px"},
|
| 181 |
+
margin=(0, 0, 12, 0), sizing_mode="stretch_width",
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
return pn.Column(
|
| 185 |
+
pn.pane.HTML(
|
| 186 |
+
'<div style="font-size:16px;font-weight:800;padding:8px 0 2px 0;">Experiments</div>'
|
| 187 |
+
'<div style="font-size:12px;color:#64748B;margin-bottom:10px;">'
|
| 188 |
+
'Model versions, scoring-run history, and version-to-version comparison.</div>'
|
| 189 |
+
),
|
| 190 |
+
card("Registered models", self._models_table()),
|
| 191 |
+
card("Run history", self._runs_table()),
|
| 192 |
+
card("Compare runs (version A β B)",
|
| 193 |
+
pn.Column(pn.Row(self._run_a, self._run_b), pn.panel(comparison),
|
| 194 |
+
sizing_mode="stretch_width")),
|
| 195 |
+
sizing_mode="stretch_width",
|
| 196 |
+
styles={"padding": "8px 16px", "max-height": "78vh", "overflow-y": "auto"},
|
| 197 |
+
)
|
ui/components/worklist_view.py
CHANGED
|
@@ -55,11 +55,17 @@ class WorklistView(param.Parameterized):
|
|
| 55 |
row["CAI"] = f"{base.get('cai', 0):.3f}" if base.get('cai') else "β"
|
| 56 |
row["Homopolymers"] = base.get('homopolymer_count', 0)
|
| 57 |
row["Restriction Sites"] = base.get('restriction_site_count', 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
else:
|
| 59 |
row["GC%"] = "β"
|
| 60 |
row["CAI"] = "β"
|
| 61 |
row["Homopolymers"] = "β"
|
| 62 |
row["Restriction Sites"] = "β"
|
|
|
|
|
|
|
| 63 |
|
| 64 |
# Add model score columns from analyses
|
| 65 |
for analysis_name, analysis_data in item.analyses.items():
|
|
@@ -262,10 +268,39 @@ class WorklistView(param.Parameterized):
|
|
| 262 |
self._create_wl_section,
|
| 263 |
pn.pane.HTML(f'<div style="margin-bottom:8px;">{origin_chips}</div>') if origin_chips else pn.pane.HTML(""),
|
| 264 |
table_or_empty,
|
|
|
|
| 265 |
sizing_mode="stretch_width",
|
| 266 |
styles={"padding": "8px 16px"},
|
| 267 |
)
|
| 268 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
def _create_worklist_from_selection(self, name: str, selection: List[int], df: pd.DataFrame) -> None:
|
| 270 |
"""Create a new worklist from selected rows."""
|
| 271 |
if not selection:
|
|
@@ -380,7 +415,41 @@ class WorklistView(param.Parameterized):
|
|
| 380 |
item.status = "error"
|
| 381 |
item.notes = f"{model_name}: {str(e)}"
|
| 382 |
|
|
|
|
|
|
|
|
|
|
| 383 |
self._state.param.trigger("worklist")
|
| 384 |
self._state.set_status(
|
| 385 |
f"{model_name} complete: {analyzed_count} scored, {skipped_count} skipped (already scored)"
|
| 386 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
row["CAI"] = f"{base.get('cai', 0):.3f}" if base.get('cai') else "β"
|
| 56 |
row["Homopolymers"] = base.get('homopolymer_count', 0)
|
| 57 |
row["Restriction Sites"] = base.get('restriction_site_count', 0)
|
| 58 |
+
verdict = base.get('liability_verdict')
|
| 59 |
+
score = base.get('liability_score')
|
| 60 |
+
row["QC"] = f"{verdict.title()} Β· {score}" if verdict is not None else "β"
|
| 61 |
+
row["Liabilities"] = base.get('liability_flag_count', 0) if base.get('liability_flag_count') is not None else "β"
|
| 62 |
else:
|
| 63 |
row["GC%"] = "β"
|
| 64 |
row["CAI"] = "β"
|
| 65 |
row["Homopolymers"] = "β"
|
| 66 |
row["Restriction Sites"] = "β"
|
| 67 |
+
row["QC"] = "β"
|
| 68 |
+
row["Liabilities"] = "β"
|
| 69 |
|
| 70 |
# Add model score columns from analyses
|
| 71 |
for analysis_name, analysis_data in item.analyses.items():
|
|
|
|
| 268 |
self._create_wl_section,
|
| 269 |
pn.pane.HTML(f'<div style="margin-bottom:8px;">{origin_chips}</div>') if origin_chips else pn.pane.HTML(""),
|
| 270 |
table_or_empty,
|
| 271 |
+
self._liability_detail,
|
| 272 |
sizing_mode="stretch_width",
|
| 273 |
styles={"padding": "8px 16px"},
|
| 274 |
)
|
| 275 |
|
| 276 |
+
@param.depends("_state.active_sequence")
|
| 277 |
+
def _liability_detail(self) -> pn.viewable.Viewable:
|
| 278 |
+
"""Liability / QC breakdown for the currently selected sequence."""
|
| 279 |
+
seq = self._state.active_sequence
|
| 280 |
+
if seq is None:
|
| 281 |
+
return pn.pane.HTML(
|
| 282 |
+
'<div style="color:#94A3B8;font-size:12px;padding:10px 2px;">'
|
| 283 |
+
'Click a row to see its liability / QC breakdown.</div>'
|
| 284 |
+
)
|
| 285 |
+
try:
|
| 286 |
+
from core.analysis.analyzer import SequenceAnalyzer
|
| 287 |
+
from ui.components.analysis_dashboard import render_liability_panel
|
| 288 |
+
|
| 289 |
+
report = SequenceAnalyzer().run_full_analysis(seq)
|
| 290 |
+
return pn.Column(
|
| 291 |
+
pn.pane.HTML(
|
| 292 |
+
f'<div style="font-size:13px;font-weight:700;margin:10px 0 6px 0;">'
|
| 293 |
+
f'Liabilities β {seq.name}</div>'
|
| 294 |
+
),
|
| 295 |
+
render_liability_panel(report),
|
| 296 |
+
sizing_mode="stretch_width",
|
| 297 |
+
)
|
| 298 |
+
except Exception as e: # noqa: BLE001 β surface any analysis error inline
|
| 299 |
+
return pn.pane.HTML(
|
| 300 |
+
f'<div style="color:#DC2626;font-size:12px;padding:8px 0;">'
|
| 301 |
+
f'Liability analysis error: {e}</div>'
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
def _create_worklist_from_selection(self, name: str, selection: List[int], df: pd.DataFrame) -> None:
|
| 305 |
"""Create a new worklist from selected rows."""
|
| 306 |
if not selection:
|
|
|
|
| 415 |
item.status = "error"
|
| 416 |
item.notes = f"{model_name}: {str(e)}"
|
| 417 |
|
| 418 |
+
# Record this scoring run for experiment / version tracking
|
| 419 |
+
self._record_run(model_reg)
|
| 420 |
+
|
| 421 |
self._state.param.trigger("worklist")
|
| 422 |
self._state.set_status(
|
| 423 |
f"{model_name} complete: {analyzed_count} scored, {skipped_count} skipped (already scored)"
|
| 424 |
)
|
| 425 |
+
|
| 426 |
+
def _record_run(self, model_reg: object) -> None:
|
| 427 |
+
"""Capture a ModelRun snapshot of the current scores for this model."""
|
| 428 |
+
from datetime import datetime
|
| 429 |
+
from models.runs import summarize_run
|
| 430 |
+
|
| 431 |
+
name = model_reg.model.name
|
| 432 |
+
# gather all current scores for this model across the worklist
|
| 433 |
+
scores = {}
|
| 434 |
+
for item in self._state.worklist.items:
|
| 435 |
+
data = item.analyses.get(name)
|
| 436 |
+
if isinstance(data, dict) and "score" in data:
|
| 437 |
+
scores[item.sequence.id] = data["score"]
|
| 438 |
+
if not scores:
|
| 439 |
+
return
|
| 440 |
+
|
| 441 |
+
try:
|
| 442 |
+
version = model_reg.model.version
|
| 443 |
+
except Exception:
|
| 444 |
+
version = "1.0"
|
| 445 |
+
|
| 446 |
+
run = summarize_run(
|
| 447 |
+
model_name=name,
|
| 448 |
+
model_version=str(version),
|
| 449 |
+
model_source=getattr(model_reg, "source", ""),
|
| 450 |
+
worklist_name=self._state.worklist.name,
|
| 451 |
+
scores=scores,
|
| 452 |
+
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
| 453 |
+
)
|
| 454 |
+
self._state.run_history.add(run)
|
| 455 |
+
self._state.param.trigger("run_history")
|
ui/state.py
CHANGED
|
@@ -21,6 +21,7 @@ from core.models.worklist import Worklist
|
|
| 21 |
from core.models.parts import SequencePart
|
| 22 |
from core.database.base import DatabaseConnector, SchemaMapper
|
| 23 |
from models.base import ModelRegistry
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
class AppState(param.Parameterized):
|
|
@@ -68,6 +69,12 @@ class AppState(param.Parameterized):
|
|
| 68 |
instantiate=True,
|
| 69 |
)
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
# ββ Plasmid backbones βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 72 |
backbone_library: List[PlasmidBackbone] = param.List(
|
| 73 |
default=[], doc="Available plasmid backbones"
|
|
@@ -97,6 +104,8 @@ class AppState(param.Parameterized):
|
|
| 97 |
params["worklist"] = Worklist()
|
| 98 |
if "model_registry" not in params:
|
| 99 |
params["model_registry"] = ModelRegistry()
|
|
|
|
|
|
|
| 100 |
super().__init__(**params)
|
| 101 |
|
| 102 |
# Load seed parts library if parts_library is empty
|
|
|
|
| 21 |
from core.models.parts import SequencePart
|
| 22 |
from core.database.base import DatabaseConnector, SchemaMapper
|
| 23 |
from models.base import ModelRegistry
|
| 24 |
+
from models.runs import RunHistory
|
| 25 |
|
| 26 |
|
| 27 |
class AppState(param.Parameterized):
|
|
|
|
| 69 |
instantiate=True,
|
| 70 |
)
|
| 71 |
|
| 72 |
+
# ββ Experiment / run history (model lifecycle) βββββββββββββββββββββββββββββ
|
| 73 |
+
run_history: RunHistory = param.ClassSelector(
|
| 74 |
+
class_=RunHistory, default=None, allow_None=False,
|
| 75 |
+
instantiate=True,
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
# ββ Plasmid backbones βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 79 |
backbone_library: List[PlasmidBackbone] = param.List(
|
| 80 |
default=[], doc="Available plasmid backbones"
|
|
|
|
| 104 |
params["worklist"] = Worklist()
|
| 105 |
if "model_registry" not in params:
|
| 106 |
params["model_registry"] = ModelRegistry()
|
| 107 |
+
if "run_history" not in params:
|
| 108 |
+
params["run_history"] = RunHistory()
|
| 109 |
super().__init__(**params)
|
| 110 |
|
| 111 |
# Load seed parts library if parts_library is empty
|