""" Candidate Analysis. Two authentic mRNA-design views over the current worklist: A. **Comparison scorecard** — every candidate scored on the four objectives a designer trades off (Expression, Stability, Immunogenicity, Manufacturability) plus an overall, ranked, with a top-N shortlist. B. **Sequence/structure track** — for a selected candidate, a per-position map: region bands (5'UTR/Kozak/CDS/3'UTR/polyA), GC sliding window, and markers for restriction sites, homopolymers, and liability motifs — i.e. *where* the problems are. """ from __future__ import annotations from typing import TYPE_CHECKING, List, Tuple import panel as pn import param import plotly.graph_objects as go from core.analysis.candidate_score import score_objectives, ObjectiveScores if TYPE_CHECKING: from ui.state import AppState _OBJECTIVES = ["Expression", "Stability", "Immunogenicity", "Manufacturability", "Overall"] _REGION_COLORS = { "5'UTR": "#3B82F6", "Kozak": "#D97706", "CDS": "#10B981", "3'UTR": "#8B5CF6", "PolyA": "#EF4444", } def _empty(msg: str) -> pn.pane.HTML: return pn.pane.HTML(f'
{msg}
') def _score_color(v: float) -> Tuple[str, str]: """(background, text) for a 0–100 score.""" if v >= 80: return "#DCFCE7", "#166534" if v >= 60: return "#FEF9C3", "#854D0E" if v >= 40: return "#FFEDD5", "#9A3412" return "#FEE2E2", "#991B1B" class CandidateView(param.Parameterized): """Multi-objective candidate comparison + per-candidate track.""" def __init__(self, state: "AppState", **params: object) -> None: super().__init__(**params) self._state = state self._shortlist = pn.widgets.IntSlider( name="Shortlist top N", start=1, end=10, value=3, width=220, margin=(4, 10)) self._candidate = pn.widgets.Select(name="Inspect candidate", width=340, margin=(4, 10)) # ── analysis ────────────────────────────────────────────────────────────── def _analyzed(self) -> List[tuple]: """Return [(item, report, ObjectiveScores), …] for worklist items with content.""" from core.analysis.analyzer import SequenceAnalyzer az = SequenceAnalyzer() out = [] for item in self._state.worklist.items: try: rep = az.run_full_analysis(item.sequence) out.append((item, rep, score_objectives(rep))) except Exception: continue return out # ── A. comparison scorecard ─────────────────────────────────────────────── def _comparison_table(self, analyzed: List[tuple], top_n: int) -> pn.pane.HTML: if not analyzed: return _empty("No analyzable sequences in the worklist.") ranked = sorted(analyzed, key=lambda t: t[2].overall, reverse=True) head = ( '' '#Candidate' + "".join(f'{o}' for o in _OBJECTIVES) + '' ) rows = "" for i, (item, _rep, s) in enumerate(ranked, 1): shortlisted = i <= top_n mark = '★' if shortlisted else '' name_bg = "background:#F0FDFA;" if shortlisted else "" cells = "" for obj in _OBJECTIVES: val = s.as_row()[obj] bg, fg = _score_color(val) weight = "800" if obj == "Overall" else "600" cells += ( f'' f'{val}' ) rows += ( f'' f'{i}' f'{mark} {item.sequence.name}' f'{cells}' ) legend = ( '
' 'Higher is better (0–100). ★ = shortlisted. ' 'Overall = weighted blend (Expression 30% · Stability 25% · ' 'Immunogenicity 20% · Manufacturability 25%). Heuristic scores from computed metrics.' '
' ) return pn.pane.HTML( f'{head}{rows}
{legend}' ) # ── B. per-candidate sequence/structure track ────────────────────────────── def _track(self, analyzed: List[tuple], candidate_name: str) -> pn.viewable.Viewable: match = next((t for t in analyzed if t[0].sequence.name == candidate_name), None) if match is None: return _empty("Select a candidate to inspect.") item, report, _scores = match seq = item.sequence # region bands from component lengths (offsets line up with assembled_sequence) comps = [("5'UTR", seq.five_prime_utr), ("Kozak", seq.kozak), ("CDS", seq.cds), ("3'UTR", seq.three_prime_utr), ("PolyA", seq.poly_a)] bands: List[Tuple[str, int, int]] = [] region_off = {} offset = 0 for nm, s in comps: if s: bands.append((nm, offset, offset + len(s))) region_off[nm] = offset offset += len(s) polya_start = region_off.get("PolyA", 10 ** 12) fig = go.Figure() for nm, a, b in bands: fig.add_vrect(x0=a, x1=b, fillcolor=_REGION_COLORS.get(nm, "#94A3B8"), opacity=0.12, line_width=0, annotation_text=nm, annotation_position="top left", annotation_font_size=10) pos = report.gc_sliding_positions vals = report.gc_sliding_values if pos is not None and len(pos): fig.add_trace(go.Scatter(x=list(pos), y=list(vals), mode="lines", line={"color": "#0F766E", "width": 1.4}, name="GC%", hovertemplate="pos %{x}
GC %{y:.0f}%")) fig.add_hline(y=50, line_dash="dot", line_color="#CBD5E1", opacity=0.7) # restriction sites rx, rt = [], [] for enz, hits in (report.restriction_hits or {}).items(): for h in hits: rx.append(h.position); rt.append(enz) if rx: fig.add_trace(go.Scatter(x=rx, y=[96] * len(rx), mode="markers", marker={"symbol": "triangle-down", "size": 10, "color": "#DC2626"}, name="Restriction site", text=rt, hovertemplate="%{text}
pos %{x}")) # homopolymers (exclude the legitimate poly-A tail) hx = [r.start for r in report.homopolymer_runs if r.start < polya_start] ht = [f"{r.nucleotide}×{r.length}" for r in report.homopolymer_runs if r.start < polya_start] if hx: fig.add_trace(go.Scatter(x=hx, y=[89] * len(hx), mode="markers", marker={"symbol": "square", "size": 9, "color": "#D97706"}, name="Homopolymer", text=ht, hovertemplate="%{text}
pos %{x}")) # liability motifs mx, mt = [], [] for h in (report.motif_hits or []): mx.append(region_off.get(h.region, 0) + h.start); mt.append(h.label) if mx: fig.add_trace(go.Scatter(x=mx, y=[82] * len(mx), mode="markers", marker={"symbol": "diamond", "size": 9, "color": "#7C3AED"}, name="Liability motif", text=mt, hovertemplate="%{text}
pos %{x}")) fig.update_layout( title={"text": f"{seq.name} — sequence map", "font": {"size": 13}}, xaxis_title="position (nt)", yaxis={"title": "GC %", "range": [0, 100]}, height=340, margin={"l": 55, "r": 20, "t": 40, "b": 45}, plot_bgcolor="#F8FAFC", paper_bgcolor="white", legend={"orientation": "h", "y": -0.3, "font": {"size": 10}}, ) # reuse the liability scorecard for this candidate from ui.components.analysis_dashboard import render_liability_panel return pn.Column( pn.pane.Plotly(fig, sizing_mode="stretch_width"), render_liability_panel(report), self._codon_panel(seq), sizing_mode="stretch_width", ) def _codon_panel(self, seq) -> pn.viewable.Viewable: """Codon-optimization analysis: %MinMax profile + rare codons + optimize projection.""" if not seq.cds: return pn.pane.HTML( '
' 'No CDS available for codon analysis.
') from core.analysis.codon_analysis import analyze_codons ca = analyze_codons(seq.cds, organism="human") fig = go.Figure() if ca.minmax_positions: fig.add_trace(go.Scatter( x=ca.minmax_positions, y=ca.minmax_values, mode="lines", line={"color": "#0F766E", "width": 1.4}, fill="tozeroy", fillcolor="rgba(15,118,110,0.10)", name="%MinMax", hovertemplate="codon %{x}
%MinMax %{y:.0f}")) fig.add_hline(y=0, line_color="#94A3B8", opacity=0.6) for (s, e) in ca.rare_clusters: fig.add_vrect(x0=s, x1=e, fillcolor="#DC2626", opacity=0.12, line_width=0) fig.update_layout( title={"text": "Codon usage (%MinMax: + common / − rare)", "font": {"size": 13}}, xaxis_title="codon position", yaxis_title="%MinMax", height=260, margin={"l": 55, "r": 20, "t": 40, "b": 40}, plot_bgcolor="#F8FAFC", paper_bgcolor="white", showlegend=False) cai_str = f"{ca.cai:.3f}" if ca.cai is not None else "—" proj = "" if ca.optimized_cai is not None: d = ca.optimized_cai - (ca.cai or 0) proj = ( f'
' f'If codon-optimized for human: CAI {cai_str} → {ca.optimized_cai:.3f} ' f'(=0 else "#DC2626"};">{d:+.3f}), ' f'rare codons {ca.rare_count} → {ca.optimized_rare_count}, ' f'{ca.codons_changed} codon(s) changed.
' ) summary = pn.pane.HTML( f'
' f'CAI {cai_str} · {ca.rare_count} rare codons ' f'({ca.rare_fraction*100:.0f}%) · {len(ca.rare_clusters)} rare cluster(s) ' f'over {ca.n_codons} codons.
{proj}' ) return pn.Column(summary, pn.pane.Plotly(fig, sizing_mode="stretch_width"), sizing_mode="stretch_width") # ── panel ───────────────────────────────────────────────────────────────── @param.depends("_state.worklist") def panel(self) -> pn.Column: wl = self._state.worklist if wl is None or wl.count == 0: return pn.Column( pn.pane.HTML('
' 'Candidate Analysis
'), _empty("Worklist is empty. Import sequences to compare candidates."), styles={"padding": "8px 16px"}, ) analyzed = self._analyzed() names = [t[0].sequence.name for t in analyzed] self._candidate.options = names if names and (self._candidate.value not in names): self._candidate.value = names[0] self._shortlist.end = max(1, len(analyzed)) table = pn.bind(lambda n: self._comparison_table(analyzed, n), self._shortlist) track = pn.bind(lambda nm: self._track(analyzed, nm), self._candidate) def card(title, body, controls=None): inner = [pn.pane.HTML(f'
{title}
')] if controls is not None: inner.append(controls) inner.append(body) return pn.Column(*inner, styles={"background": "white", "border": "1px solid #CBD5E1", "border-radius": "8px", "padding": "12px 14px"}, margin=(0, 0, 12, 0), sizing_mode="stretch_width") return pn.Column( pn.pane.HTML( '
' f'Candidate Analysis ' f'({wl.count} sequences)
' '
' 'Rank candidates across the four mRNA design objectives, then inspect where ' 'a candidate's features and liabilities sit along the molecule.
' ), card("Comparison scorecard", pn.panel(table), self._shortlist), card("Sequence / structure map", pn.panel(track), self._candidate), sizing_mode="stretch_width", styles={"padding": "8px 16px", "max-height": "82vh", "overflow-y": "auto"}, )