offtargeteffect commited on
Commit
ffc7197
Β·
verified Β·
1 Parent(s): ab1afc0

Add codon-optimization analysis panel

Browse files
Dockerfile CHANGED
@@ -29,7 +29,7 @@ COPY . .
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 \
 
29
  ENV PORT=5007 \
30
  HOST=0.0.0.0
31
 
32
+ # HF Spaces runs as UID 1000; caches -> /tmp
33
  ENV HOME=/tmp \
34
  XDG_CACHE_HOME=/tmp/.cache \
35
  MPLCONFIGDIR=/tmp/matplotlib \
core/analysis/codon_analysis.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Codon-optimization analysis for an mRNA CDS.
3
+
4
+ Goes beyond a single CAI number to show *where* codon usage helps or hurts
5
+ expression:
6
+
7
+ - **Per-codon optimality** β€” each codon's relative adaptiveness (0–1) vs the
8
+ best synonymous codon for that amino acid in the host.
9
+ - **%MinMax profile** β€” the classic sliding-window measure (Clarke & Clark):
10
+ positive = a run of common/fast codons, negative = rare/slow codons (the
11
+ kind of cluster that stalls ribosomes).
12
+ - **Rare-codon clusters** β€” runs of low-optimality codons worth recoding.
13
+ - **Original vs optimized** β€” projected CAI gain and rare-codon reduction if
14
+ the CDS were codon-optimized for the host (reuses the existing optimizer).
15
+
16
+ Pure-Python (stdlib only); reuses the host codon tables already in the project.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass, field
21
+ from typing import Dict, List, Optional, Tuple
22
+
23
+ from core.analysis.cai import CODON_TABLES, calculate_cai
24
+ from core.sequence_tools.codon_optimizer import CODON_TABLE, AA_TO_CODONS
25
+
26
+ _STOP = {"TAA", "TAG", "TGA"}
27
+ RARE_THRESHOLD = 0.20 # optimality below this = rare codon
28
+ RARE_CLUSTER_MIN = 3 # consecutive rare codons β†’ a cluster
29
+ DEFAULT_WINDOW = 17 # codons, for the %MinMax sliding window
30
+
31
+
32
+ def resolve_organism(organism: Optional[str]) -> str:
33
+ key = (organism or "human").lower().replace(" ", "").replace(".", "")
34
+ if key in ("ecoli", "escherichiacoli"):
35
+ return "ecoli"
36
+ return "human" if key not in CODON_TABLES else key
37
+
38
+
39
+ def _codons(cds: str) -> List[str]:
40
+ s = (cds or "").upper().replace("U", "T")
41
+ return [s[i:i + 3] for i in range(0, len(s) - len(s) % 3, 3)]
42
+
43
+
44
+ def _freq_stats(table: Dict[str, float]) -> Tuple[Dict[str, float], Dict[str, float], Dict[str, float], Dict[str, float]]:
45
+ """Per-codon synonymous frequency, and per-AA max/min/avg of those freqs."""
46
+ freq: Dict[str, float] = {}
47
+ aa_max: Dict[str, float] = {}
48
+ aa_min: Dict[str, float] = {}
49
+ aa_avg: Dict[str, float] = {}
50
+ for aa, syns in AA_TO_CODONS.items():
51
+ if aa in ("*", "Stop"):
52
+ continue
53
+ ws = [max(table.get(c, 0.0), 0.0) for c in syns]
54
+ tot = sum(ws)
55
+ fs = [w / tot if tot > 0 else 0.0 for w in ws]
56
+ for c, f in zip(syns, fs):
57
+ freq[c] = f
58
+ aa_max[aa] = max(fs) if fs else 0.0
59
+ aa_min[aa] = min(fs) if fs else 0.0
60
+ aa_avg[aa] = (sum(fs) / len(fs)) if fs else 0.0
61
+ return freq, aa_max, aa_min, aa_avg
62
+
63
+
64
+ def per_codon_optimality(cds: str, organism: str = "human") -> List[float]:
65
+ """Relative adaptiveness (0–1) per non-stop codon."""
66
+ table = CODON_TABLES[resolve_organism(organism)]
67
+ # max synonymous weight per AA
68
+ aa_maxw = {aa: max((table.get(c, 0.0) for c in syns), default=0.0)
69
+ for aa, syns in AA_TO_CODONS.items()}
70
+ out: List[float] = []
71
+ for c in _codons(cds):
72
+ aa = CODON_TABLE.get(c)
73
+ if aa is None or aa in ("*", "Stop") or c in _STOP:
74
+ continue
75
+ mx = aa_maxw.get(aa, 0.0)
76
+ out.append((table.get(c, 0.0) / mx) if mx > 0 else 0.0)
77
+ return out
78
+
79
+
80
+ def min_max_profile(cds: str, organism: str = "human",
81
+ window: int = DEFAULT_WINDOW) -> Tuple[List[int], List[float]]:
82
+ """%MinMax per sliding window; x positions are codon indices (window centres)."""
83
+ table = CODON_TABLES[resolve_organism(organism)]
84
+ freq, aa_max, aa_min, aa_avg = _freq_stats(table)
85
+ codons = [c for c in _codons(cds) if CODON_TABLE.get(c) not in (None, "*", "Stop")]
86
+ positions: List[int] = []
87
+ values: List[float] = []
88
+ n = len(codons)
89
+ if n < window:
90
+ return positions, values
91
+ for i in range(n - window + 1):
92
+ win = codons[i:i + window]
93
+ actual = sum(freq.get(c, 0.0) for c in win)
94
+ mx = sum(aa_max.get(CODON_TABLE.get(c, ""), 0.0) for c in win)
95
+ mn = sum(aa_min.get(CODON_TABLE.get(c, ""), 0.0) for c in win)
96
+ av = sum(aa_avg.get(CODON_TABLE.get(c, ""), 0.0) for c in win)
97
+ if actual >= av:
98
+ pmm = ((actual - av) / (mx - av) * 100.0) if mx > av else 0.0
99
+ else:
100
+ pmm = (-(av - actual) / (av - mn) * 100.0) if av > mn else 0.0
101
+ positions.append(i + window // 2)
102
+ values.append(pmm)
103
+ return positions, values
104
+
105
+
106
+ @dataclass
107
+ class CodonAnalysis:
108
+ organism: str
109
+ cai: Optional[float]
110
+ n_codons: int
111
+ rare_count: int
112
+ rare_fraction: float
113
+ rare_positions: List[int] = field(default_factory=list)
114
+ rare_clusters: List[Tuple[int, int]] = field(default_factory=list) # (start, end) codon idx
115
+ minmax_positions: List[int] = field(default_factory=list)
116
+ minmax_values: List[float] = field(default_factory=list)
117
+ optimality: List[float] = field(default_factory=list)
118
+ # original-vs-optimized projection
119
+ optimized_cai: Optional[float] = None
120
+ optimized_rare_count: Optional[int] = None
121
+ codons_changed: Optional[int] = None
122
+
123
+
124
+ def _clusters(rare_positions: List[int], min_len: int = RARE_CLUSTER_MIN) -> List[Tuple[int, int]]:
125
+ if not rare_positions:
126
+ return []
127
+ runs = []
128
+ start = prev = rare_positions[0]
129
+ for p in rare_positions[1:]:
130
+ if p == prev + 1:
131
+ prev = p
132
+ else:
133
+ if prev - start + 1 >= min_len:
134
+ runs.append((start, prev))
135
+ start = prev = p
136
+ if prev - start + 1 >= min_len:
137
+ runs.append((start, prev))
138
+ return runs
139
+
140
+
141
+ def analyze_codons(cds: str, organism: str = "human",
142
+ window: int = DEFAULT_WINDOW,
143
+ include_optimized: bool = True) -> CodonAnalysis:
144
+ """Full codon analysis for a CDS."""
145
+ org = resolve_organism(organism)
146
+ opt = per_codon_optimality(cds, org)
147
+ n = len(opt)
148
+ rare_positions = [i for i, w in enumerate(opt) if w < RARE_THRESHOLD]
149
+ mm_pos, mm_val = min_max_profile(cds, org, window)
150
+
151
+ try:
152
+ cai = calculate_cai(cds, org)
153
+ except Exception:
154
+ cai = None
155
+
156
+ result = CodonAnalysis(
157
+ organism=org, cai=cai, n_codons=n,
158
+ rare_count=len(rare_positions),
159
+ rare_fraction=(len(rare_positions) / n) if n else 0.0,
160
+ rare_positions=rare_positions,
161
+ rare_clusters=_clusters(rare_positions),
162
+ minmax_positions=mm_pos, minmax_values=mm_val,
163
+ optimality=opt,
164
+ )
165
+
166
+ if include_optimized and n:
167
+ try:
168
+ from core.sequence_tools.codon_optimizer import optimize_codons
169
+ res = optimize_codons(cds, org)
170
+ result.optimized_cai = res.optimized_cai
171
+ result.codons_changed = res.codons_changed
172
+ result.optimized_rare_count = len(
173
+ [w for w in per_codon_optimality(res.optimized_cds, org) if w < RARE_THRESHOLD]
174
+ )
175
+ except Exception:
176
+ pass
177
+ return result
tests/test_codon_analysis.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for codon-optimization analysis."""
2
+ import pytest
3
+
4
+ from core.analysis.codon_analysis import (
5
+ resolve_organism, per_codon_optimality, min_max_profile, analyze_codons,
6
+ RARE_THRESHOLD,
7
+ )
8
+
9
+ # Human top-synonym codons (relative adaptiveness 1.00)
10
+ OPTIMAL = "ATG" + "CTG" + "ATC" + "GTG" + "GCC" + "ACC" # M L I V A T, all = 1.00
11
+ # Rare Leu codon TTA = 0.07 in the human table
12
+ RARE = "ATG" + "TTA" * 6 # M then 6 rare Leu
13
+
14
+
15
+ class TestResolveOrganism:
16
+ def test_human_default(self):
17
+ assert resolve_organism(None) == "human"
18
+ assert resolve_organism("Human") == "human"
19
+
20
+ def test_ecoli_variants(self):
21
+ assert resolve_organism("E. coli") == "ecoli"
22
+ assert resolve_organism("ecoli") == "ecoli"
23
+
24
+ def test_unknown_falls_back_to_human(self):
25
+ assert resolve_organism("martian") == "human"
26
+
27
+
28
+ class TestOptimality:
29
+ def test_optimal_codons_score_one(self):
30
+ opt = per_codon_optimality(OPTIMAL, "human")
31
+ assert len(opt) == 6
32
+ assert all(w == pytest.approx(1.0) for w in opt)
33
+
34
+ def test_rare_codon_below_threshold(self):
35
+ opt = per_codon_optimality(RARE, "human")
36
+ # first is ATG (1.0), rest are rare Leu
37
+ assert opt[0] == pytest.approx(1.0)
38
+ assert all(w < RARE_THRESHOLD for w in opt[1:])
39
+
40
+ def test_stop_codons_excluded(self):
41
+ opt = per_codon_optimality("ATG" + "TAA", "human") # M + stop
42
+ assert len(opt) == 1
43
+
44
+
45
+ class TestMinMax:
46
+ def test_short_sequence_returns_empty(self):
47
+ pos, vals = min_max_profile("ATGCTGATC", "human", window=17)
48
+ assert pos == [] and vals == []
49
+
50
+ def test_optimal_run_is_positive(self):
51
+ pos, vals = min_max_profile(OPTIMAL * 5, "human", window=10)
52
+ assert vals and all(v > 0 for v in vals)
53
+
54
+ def test_rare_run_is_negative(self):
55
+ pos, vals = min_max_profile(RARE * 4, "human", window=10)
56
+ assert vals and min(vals) < 0
57
+
58
+
59
+ class TestAnalyzeCodons:
60
+ def test_counts_and_cai(self):
61
+ a = analyze_codons(OPTIMAL, "human", include_optimized=False)
62
+ assert a.n_codons == 6
63
+ assert a.rare_count == 0
64
+ assert a.cai is not None and a.cai > 0.9
65
+
66
+ def test_rare_clusters_detected(self):
67
+ a = analyze_codons(RARE, "human", include_optimized=False)
68
+ assert a.rare_count == 6
69
+ assert a.rare_clusters and a.rare_clusters[0] == (1, 6)
70
+
71
+ def test_optimization_improves_rare_heavy_cds(self):
72
+ a = analyze_codons(RARE, "human", include_optimized=True)
73
+ assert a.optimized_cai is not None
74
+ assert a.optimized_cai >= (a.cai or 0)
75
+ assert a.optimized_rare_count is not None
76
+ assert a.optimized_rare_count <= a.rare_count
ui/components/candidate_view.py CHANGED
@@ -194,9 +194,55 @@ class CandidateView(param.Parameterized):
194
  return pn.Column(
195
  pn.pane.Plotly(fig, sizing_mode="stretch_width"),
196
  render_liability_panel(report),
 
197
  sizing_mode="stretch_width",
198
  )
199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  # ── panel ─────────────────────────────────────────────────────────────────
201
  @param.depends("_state.worklist")
202
  def panel(self) -> pn.Column:
 
194
  return pn.Column(
195
  pn.pane.Plotly(fig, sizing_mode="stretch_width"),
196
  render_liability_panel(report),
197
+ self._codon_panel(seq),
198
  sizing_mode="stretch_width",
199
  )
200
 
201
+ def _codon_panel(self, seq) -> pn.viewable.Viewable:
202
+ """Codon-optimization analysis: %MinMax profile + rare codons + optimize projection."""
203
+ if not seq.cds:
204
+ return pn.pane.HTML(
205
+ '<div style="color:#64748B;font-size:12px;margin-top:10px;">'
206
+ 'No CDS available for codon analysis.</div>')
207
+ from core.analysis.codon_analysis import analyze_codons
208
+ ca = analyze_codons(seq.cds, organism="human")
209
+
210
+ fig = go.Figure()
211
+ if ca.minmax_positions:
212
+ fig.add_trace(go.Scatter(
213
+ x=ca.minmax_positions, y=ca.minmax_values, mode="lines",
214
+ line={"color": "#0F766E", "width": 1.4}, fill="tozeroy",
215
+ fillcolor="rgba(15,118,110,0.10)", name="%MinMax",
216
+ hovertemplate="codon %{x}<br>%MinMax %{y:.0f}<extra></extra>"))
217
+ fig.add_hline(y=0, line_color="#94A3B8", opacity=0.6)
218
+ for (s, e) in ca.rare_clusters:
219
+ fig.add_vrect(x0=s, x1=e, fillcolor="#DC2626", opacity=0.12, line_width=0)
220
+ fig.update_layout(
221
+ title={"text": "Codon usage (%MinMax: + common / βˆ’ rare)", "font": {"size": 13}},
222
+ xaxis_title="codon position", yaxis_title="%MinMax",
223
+ height=260, margin={"l": 55, "r": 20, "t": 40, "b": 40},
224
+ plot_bgcolor="#F8FAFC", paper_bgcolor="white", showlegend=False)
225
+
226
+ cai_str = f"{ca.cai:.3f}" if ca.cai is not None else "β€”"
227
+ proj = ""
228
+ if ca.optimized_cai is not None:
229
+ d = ca.optimized_cai - (ca.cai or 0)
230
+ proj = (
231
+ f'<div style="font-size:12px;color:#475569;margin-top:6px;">'
232
+ f'If codon-optimized for human: CAI <b>{cai_str} β†’ {ca.optimized_cai:.3f}</b> '
233
+ f'(<span style="color:{"#059669" if d>=0 else "#DC2626"};">{d:+.3f}</span>), '
234
+ f'rare codons <b>{ca.rare_count} β†’ {ca.optimized_rare_count}</b>, '
235
+ f'{ca.codons_changed} codon(s) changed.</div>'
236
+ )
237
+ summary = pn.pane.HTML(
238
+ f'<div style="font-size:12px;color:#334155;margin-top:8px;">'
239
+ f'<b>CAI</b> {cai_str} Β· <b>{ca.rare_count}</b> rare codons '
240
+ f'({ca.rare_fraction*100:.0f}%) Β· <b>{len(ca.rare_clusters)}</b> rare cluster(s) '
241
+ f'over {ca.n_codons} codons.</div>{proj}'
242
+ )
243
+ return pn.Column(summary, pn.pane.Plotly(fig, sizing_mode="stretch_width"),
244
+ sizing_mode="stretch_width")
245
+
246
  # ── panel ─────────────────────────────────────────────────────────────────
247
  @param.depends("_state.worklist")
248
  def panel(self) -> pn.Column: