File size: 10,807 Bytes
dd2d535
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
"""Base-editing design β€” a first-class CRISPR mode (Phase 3, M1).

Where the knockout pipeline asks "where will Cas9 cut and how cleanly?",
base editing asks "which single-base change does this guide install, and
what does it do to the protein?". Most competing tools only *flag* that a
guide is base-editable; here we predict the actual edit(s), warn about
bystanders, score editability, and β€” when a reading frame is known β€”
report the codon β†’ amino-acid consequence, including premature stops for
DSB-free knockout (CRISPR-STOP / iSTOP).

Two editor families:
  * CBE (cytosine base editors): deaminate C→T on the protospacer
    (non-target) strand within an editor-specific activity window.
  * ABE (adenine base editors): deaminate A→G in a similar window.

Coordinate convention (matches dee/core/crispr.py and _scan_base_editor):
  Spacer positions are 1-indexed from the 5' / PAM-DISTAL end. For SpCas9
  the 20-nt protospacer sits 5'β†’[1..20]β†’3' with the NGG PAM at 21-23. The
  canonical editing window is reported in these coordinates.

Honesty: the windows + per-position activity profiles below are the
published literature consensus, NOT a trained model. They rank-order
editable sites well and match each editor's reported window, but exact
per-base efficiencies vary by sequence context and cell type. The UI says
so.

References:
  * Komor AC et al. (2016). Programmable editing of a target base in
    genomic DNA without double-stranded DNA cleavage. Nature 533, 420.
    [BE3, CBE window 4-8]
  * Koblan LW et al. (2018). Improving cytidine and adenine base editors
    ... Nat Biotechnol 36, 843. [BE4max]
  * Thuronyi BW et al. (2019). Continuous evolution of base editors with
    expanded target compatibility. Nat Biotechnol 37, 1070. [evoCDA β€” wide]
  * Gaudelli NM et al. (2017). Programmable base editing of Aβ€’T to Gβ€’C ...
    Nature 551, 464. [ABE7.10, window 4-7]
  * Richter MF et al. (2020). Phage-assisted evolution of an adenine base
    editor with improved Cas domain compatibility and activity. Nat
    Biotechnol 38, 883. [ABE8e β€” wide, processive]
  * Kuscu C et al. (2017). CRISPR-STOP: gene silencing via base editing
    -induced nonsense mutations. Nat Methods 14, 710.
  * Billon P et al. (2017). CRISPR-mediated base editing enables efficient
    disruption of eukaryotic genes through induction of STOP codons. Mol
    Cell 67, 1068. [iSTOP]
"""
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple

# ─── Standard genetic code (DNA codons β†’ 1-letter AA; '*' = stop) ─────
CODON_TABLE: Dict[str, str] = {
    "TTT": "F", "TTC": "F", "TTA": "L", "TTG": "L",
    "CTT": "L", "CTC": "L", "CTA": "L", "CTG": "L",
    "ATT": "I", "ATC": "I", "ATA": "I", "ATG": "M",
    "GTT": "V", "GTC": "V", "GTA": "V", "GTG": "V",
    "TCT": "S", "TCC": "S", "TCA": "S", "TCG": "S",
    "CCT": "P", "CCC": "P", "CCA": "P", "CCG": "P",
    "ACT": "T", "ACC": "T", "ACA": "T", "ACG": "T",
    "GCT": "A", "GCC": "A", "GCA": "A", "GCG": "A",
    "TAT": "Y", "TAC": "Y", "TAA": "*", "TAG": "*",
    "CAT": "H", "CAC": "H", "CAA": "Q", "CAG": "Q",
    "AAT": "N", "AAC": "N", "AAA": "K", "AAG": "K",
    "GAT": "D", "GAC": "D", "GAA": "E", "GAG": "E",
    "TGT": "C", "TGC": "C", "TGA": "*", "TGG": "W",
    "CGT": "R", "CGC": "R", "CGA": "R", "CGG": "R",
    "AGT": "S", "AGC": "S", "AGA": "R", "AGG": "R",
    "GGT": "G", "GGC": "G", "GGA": "G", "GGG": "G",
}


@dataclass
class BaseEditor:
    id:           str
    name:         str
    kind:         str            # "CBE" | "ABE"
    target_base:  str            # base edited ON THE PROTOSPACER strand
    result_base:  str            # what it becomes
    window:       Tuple[int, int]  # 1-indexed inclusive spacer positions
    # Per-position relative deamination activity in [0,1]. Positions
    # outside the window are absent (treated as 0 β€” no editing).
    activity:     Dict[int, float]
    citation:     str
    note:         str = ""


def _profile(window: Tuple[int, int], peak: int, peak_val: float = 1.0,
             edge_val: float = 0.35) -> Dict[int, float]:
    """Triangular activity profile across `window`, maximal at `peak`."""
    lo, hi = window
    prof: Dict[int, float] = {}
    span = max(peak - lo, hi - peak, 1)
    for p in range(lo, hi + 1):
        frac = abs(p - peak) / span
        prof[p] = round(peak_val - (peak_val - edge_val) * frac, 3)
    return prof


# ─── Curated editor library ──────────────────────────────────────────
# Five editors covering the cases real users reach for: a standard +
# an optimized + a wide-window CBE, and a standard + wide ABE.
BASE_EDITORS: Dict[str, BaseEditor] = {
    "be4max": BaseEditor(
        id="be4max", name="BE4max (CBE)", kind="CBE",
        target_base="C", result_base="T", window=(4, 8),
        activity=_profile((4, 8), peak=6),
        citation="Koblan 2018", note="Optimized cytosine base editor β€” the common default.",
    ),
    "be3": BaseEditor(
        id="be3", name="BE3 (CBE)", kind="CBE",
        target_base="C", result_base="T", window=(4, 8),
        activity=_profile((4, 8), peak=6),
        citation="Komor 2016", note="Original cytosine base editor.",
    ),
    "evocda_be4max": BaseEditor(
        id="evocda_be4max", name="evoCDA1-BE4max (wide CBE)", kind="CBE",
        target_base="C", result_base="T", window=(2, 10),
        activity=_profile((2, 10), peak=6, edge_val=0.45),
        citation="Thuronyi 2019",
        note="Wide-window CBE β€” more reach, more bystander risk.",
    ),
    "abe8e": BaseEditor(
        id="abe8e", name="ABE8e (ABE)", kind="ABE",
        target_base="A", result_base="G", window=(3, 9),
        activity=_profile((3, 9), peak=6, edge_val=0.45),
        citation="Richter 2020",
        note="Highly processive adenine base editor β€” wide window.",
    ),
    "abe7.10": BaseEditor(
        id="abe7.10", name="ABE7.10 (ABE)", kind="ABE",
        target_base="A", result_base="G", window=(4, 7),
        activity=_profile((4, 7), peak=6),
        citation="Gaudelli 2017", note="Standard adenine base editor β€” narrow window.",
    ),
}

DEFAULT_BASE_EDITOR = "be4max"


def list_base_editors(kind: str = "") -> List[BaseEditor]:
    out = list(BASE_EDITORS.values())
    if kind:
        out = [e for e in out if e.kind.upper() == kind.upper()]
    return out


def get_base_editor(editor_id: str) -> Optional[BaseEditor]:
    return BASE_EDITORS.get((editor_id or "").lower())


# ─── Per-guide edit prediction (sequence-only) ───────────────────────

@dataclass
class BaseEditPrediction:
    editor_id:     str
    # Each edit: (spacer_position_1indexed, from_base, to_base, activity)
    edits:         List[Tuple[int, str, str, float]] = field(default_factory=list)
    editability:   float = 0.0      # strongest in-window target activity [0,1]
    has_bystander: bool = False     # >1 target base in the window
    n_targets:     int = 0
    summary:       str = ""         # short label for the table


def predict_base_edits(spacer: str, editor_id: str) -> BaseEditPrediction:
    """Which bases this editor would change in the guide's window.

    Sequence-only: needs no genome context. Reports every target base in
    the activity window, the strongest as the 'editability', and whether
    bystander edits are present.
    """
    ed = get_base_editor(editor_id)
    if ed is None or not spacer:
        return BaseEditPrediction(editor_id=editor_id or "")
    spacer = spacer.upper()
    lo, hi = ed.window
    edits: List[Tuple[int, str, str, float]] = []
    for pos in range(lo, hi + 1):
        idx = pos - 1
        if 0 <= idx < len(spacer) and spacer[idx] == ed.target_base:
            edits.append((pos, ed.target_base, ed.result_base,
                          ed.activity.get(pos, 0.0)))
    editability = max((a for _, _, _, a in edits), default=0.0)
    n = len(edits)
    if n == 0:
        summary = f"no {ed.target_base} in window"
    elif n == 1:
        p, fb, tb, _ = edits[0]
        summary = f"{fb}{p}β†’{tb}"
    else:
        summary = ", ".join(f"{fb}{p}β†’{tb}" for p, fb, tb, _ in edits) + " (bystander)"
    return BaseEditPrediction(
        editor_id=ed.id, edits=edits, editability=round(editability, 3),
        has_bystander=n > 1, n_targets=n, summary=summary,
    )


# ─── Codon-level consequence (frame-aware) ───────────────────────────

@dataclass
class CodonConsequence:
    cds_index:    int
    codon_before: str
    codon_after:  str
    aa_before:    str
    aa_after:     str
    kind:         str    # "silent" | "missense" | "nonsense" | "stop_loss"
    creates_stop: bool
    label:        str    # e.g. "Q→* (stop gained)" or "A123A (silent)"


def codon_consequence(cds: str, cds_index: int, ref_base: str,
                      alt_base: str) -> Optional[CodonConsequence]:
    """Translate the effect of changing coding-strand base `cds_index`
    from ref_base→alt_base.

    Returns None (no guess) if the index is out of range, the codon is
    incomplete, or the CDS base doesn't match ref_base (alignment slipped).
    `cds` must be the coding strand 5'β†’3'; cds_index 0-based.
    """
    if cds_index < 0 or cds_index >= len(cds):
        return None
    if cds[cds_index] != ref_base:
        return None  # alignment mismatch β€” refuse to fabricate a consequence
    codon_start = (cds_index // 3) * 3
    if codon_start + 3 > len(cds):
        return None
    within = cds_index - codon_start
    codon_before = cds[codon_start:codon_start + 3]
    codon_after = codon_before[:within] + alt_base + codon_before[within + 1:]
    aa_before = CODON_TABLE.get(codon_before, "?")
    aa_after = CODON_TABLE.get(codon_after, "?")
    creates_stop = aa_after == "*" and aa_before != "*"
    if aa_before == aa_after:
        kind = "silent"
    elif creates_stop:
        kind = "nonsense"
    elif aa_before == "*" and aa_after != "*":
        kind = "stop_loss"
    else:
        kind = "missense"
    aa_num = codon_start // 3 + 1  # 1-based residue number
    if kind == "silent":
        label = f"{aa_before}{aa_num}= (silent)"
    elif kind == "nonsense":
        label = f"{aa_before}{aa_num}* (stop gained)"
    elif kind == "stop_loss":
        label = f"*{aa_num}{aa_after} (stop lost)"
    else:
        label = f"{aa_before}{aa_num}{aa_after}"
    return CodonConsequence(
        cds_index=cds_index, codon_before=codon_before, codon_after=codon_after,
        aa_before=aa_before, aa_after=aa_after, kind=kind,
        creates_stop=creates_stop, label=label,
    )