File size: 13,329 Bytes
aa752f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
"""Base editor and prime editor suitability analysis for plants.



Implements:

1. CBE (Cytosine Base Editor) — C→T conversion, window 4-8 from PAM-distal end

2. ABE (Adenine Base Editor) — A→G conversion, window 4-7

3. Prime Editing pegRNA designer β€” PBS + RT template scoring

4. Cas12a (TTTV) guide efficiency β€” nucleotide position preference model

5. Nucleosome occupancy approximation β€” TA/AA periodicity for CRISPR accessibility



Sources:

- CBE/ABE windows: Komor 2016, Gaudelli 2017; plant validation IJMS 2025

- Cas12a plant efficiency: PMC 2025 Random Forest features

- Nucleosome occupancy: Nature Methods 2025 (TDAC-seq); TF/AT periodicity model

- Prime editing in plants: MDPI Genes 2025 (TwinPE 44.2% efficiency)

"""
import math
import re

try:
    import RNA as _RNA
    _VIENNA_OK = True
except ImportError:
    _VIENNA_OK = False

from .codons import CODON_TO_AMINO_ACID
from Bio.SeqUtils import MeltingTemp as mt
from Bio.Seq import Seq


# ── 1. CBE Suitability Scanner ────────────────────────────────────────────────

# CBE deaminase window: positions 4-8 counting from the 5' end of the spacer
# (PAM-distal = position 1). Cytosines in this window are converted to T.
# Context preferences (highest to lowest efficiency in plants):
#   TC context: high (TCΒ·target is optimal for BE3 and evoBE4)
#   CC context: medium
#   AC/GC context: lower
# Source: Komor 2016 Nature; Jin 2019 Plant Biotechnology Journal.

CBE_WINDOW = range(3, 8)   # 0-indexed positions 3-7 = spacer positions 4-8

_CBE_CONTEXT_SCORE = {
    'TC': 1.0, 'CC': 0.75, 'AC': 0.55, 'GC': 0.45,
}


def cbe_sites(dna: str, pam_type: str = "NGG") -> list:
    """Find all cytosines editable by CBE within their PAM-proximal spacer windows.



    Returns list of dicts: {position, spacer, C_positions_in_window, efficiency_score,

    pam, context_scores}

    """
    dna = dna.upper()
    pam_seqs = {"NGG": ["AGG", "TGG", "CGG", "GGG"],
                "TTTV": ["TTTA", "TTTC", "TTTG"]}
    pams = pam_seqs.get(pam_type, pam_seqs["NGG"])
    pam_len = len(pams[0])
    sites = []

    for i in range(len(dna) - 20 - pam_len + 1):
        pam = dna[i + 20:i + 20 + pam_len]
        if pam not in pams:
            continue
        spacer = dna[i:i + 20]
        c_positions = [p for p in CBE_WINDOW if p < len(spacer) and spacer[p] == 'C']
        if not c_positions:
            continue

        eff_scores = []
        for p in c_positions:
            context = spacer[p - 1:p + 1] if p > 0 else 'NC'
            eff_scores.append(_CBE_CONTEXT_SCORE.get(context, 0.35))

        sites.append({
            "type": "CBE",
            "pam_type": pam_type,
            "spacer_start": i,
            "spacer": spacer,
            "pam": pam,
            "editable_positions": c_positions,      # 0-indexed in spacer
            "efficiency_score": round(max(eff_scores), 3),
            "context_scores": eff_scores,
        })

    return sorted(sites, key=lambda x: x["efficiency_score"], reverse=True)


# ── 2. ABE Suitability Scanner ────────────────────────────────────────────────

# ABE deaminase window: positions 4-7 (0-indexed 3-6).
# Adenines in this window are converted A→G (creates G on the non-edited strand).
# Context preferences: TA and AA contexts best (Gaudelli 2017, Nature).

ABE_WINDOW = range(3, 7)   # 0-indexed positions 3-6 = spacer positions 4-7

_ABE_CONTEXT_SCORE = {
    'TA': 1.0, 'AA': 0.90, 'GA': 0.65, 'CA': 0.55,
}


def abe_sites(dna: str, pam_type: str = "NGG") -> list:
    """Find all adenines editable by ABE within their PAM-proximal spacer windows."""
    dna = dna.upper()
    pams_map = {"NGG": ["AGG", "TGG", "CGG", "GGG"],
                "TTTV": ["TTTA", "TTTC", "TTTG"]}
    pams = pams_map.get(pam_type, pams_map["NGG"])
    pam_len = len(pams[0])
    sites = []

    for i in range(len(dna) - 20 - pam_len + 1):
        pam = dna[i + 20:i + 20 + pam_len]
        if pam not in pams:
            continue
        spacer = dna[i:i + 20]
        a_positions = [p for p in ABE_WINDOW if p < len(spacer) and spacer[p] == 'A']
        if not a_positions:
            continue

        eff_scores = []
        for p in a_positions:
            context = spacer[p - 1:p + 1] if p > 0 else 'NA'
            eff_scores.append(_ABE_CONTEXT_SCORE.get(context, 0.40))

        sites.append({
            "type": "ABE",
            "pam_type": pam_type,
            "spacer_start": i,
            "spacer": spacer,
            "pam": pam,
            "editable_positions": a_positions,
            "efficiency_score": round(max(eff_scores), 3),
            "context_scores": eff_scores,
        })

    return sorted(sites, key=lambda x: x["efficiency_score"], reverse=True)


# ── 3. Prime Editing pegRNA Designer ─────────────────────────────────────────
# Prime editing requires:
#   (a) A spacer (20 nt, same design as Cas9 gRNA)
#   (b) Primer Binding Site (PBS): reverse complement of 3'-flap, 8-15 nt,
#       Tm 55-65Β°C
#   (c) RT template: encodes the desired edit + downstream sequence, 10-30 nt
#
# TwinPE in plants (MDPI 2025) achieved 44.2% efficiency with PBS Tm 58-63Β°C,
# RT template 20-25 nt, minimal secondary structure in the extension.

PBS_TM_OPTIMAL = (58, 63)   # Β°C
RT_TEMPLATE_OPTIMAL = (20, 25)  # nt


def design_pegrna(spacer: str, edit_site_offset: int, edit_seq: str,

                  flap_3prime: str) -> dict:
    """Design a pegRNA for prime editing.



    Parameters

    ----------

    spacer          : 20-nt protospacer (PAM-distal to PAM-proximal).

    edit_site_offset: number of nt from the nick site to the edit position.

    edit_seq        : the sequence change to encode in the RT template.

    flap_3prime     : 3'-flap sequence on the non-template strand (used to

                      compute PBS = reverse complement of the 3' end of this).



    Returns

    -------

    dict with PBS sequence, Tm, RT template, structure score, and overall score.

    """
    # PBS: reverse complement of last 13 nt of the 3'-flap (adjustable 8-15 nt)
    for pbs_len in range(13, 7, -1):
        pbs = str(Seq(flap_3prime[-pbs_len:]).reverse_complement())
        pbs_tm = float(mt.Tm_NN(Seq(pbs)))
        if PBS_TM_OPTIMAL[0] <= pbs_tm <= PBS_TM_OPTIMAL[1]:
            break
    else:
        pbs_tm = float(mt.Tm_NN(Seq(pbs)))

    # RT template: edit + downstream context
    rt = edit_seq + flap_3prime[:edit_site_offset]
    rt_len = len(rt)

    # Structure of the 3' extension (PBS + RT): should be low MFE
    extension = pbs + rt
    struct_score = 1.0
    if _VIENNA_OK and len(extension) >= 8:
        _ss, mfe = _RNA.fold(extension.replace('T', 'U'))
        struct_score = max(0.0, min(1.0, 1.0 + mfe / 20.0))

    # Overall pegRNA score
    tm_score = 1.0 if PBS_TM_OPTIMAL[0] <= pbs_tm <= PBS_TM_OPTIMAL[1] else \
        max(0.0, 1.0 - abs(pbs_tm - 60.0) / 10.0)
    len_score = 1.0 if RT_TEMPLATE_OPTIMAL[0] <= rt_len <= RT_TEMPLATE_OPTIMAL[1] else \
        max(0.0, 1.0 - abs(rt_len - 22.0) / 15.0)
    overall = round((tm_score * 0.4 + len_score * 0.3 + struct_score * 0.3), 3)

    return {
        "pbs": pbs,
        "pbs_tm": round(pbs_tm, 1),
        "rt_template": rt,
        "rt_length": rt_len,
        "extension_structure_score": round(struct_score, 3),
        "overall_score": overall,
    }


# ── 4. Cas12a Guide Efficiency Scorer ─────────────────────────────────────────
# Random Forest features from PMC 2025 (+15% over existing algorithms):
# - PAM: TTTV (TTTA > TTTC > TTTG, in order of efficiency)
# - Position 1 (PAM-proximal): A preferred (+0.15)
# - Position 1-5: A/T preferred, G/C penalised at 1-3
# - Seed region (13-20, PAM-proximal): positions 14-20 most critical
# - Homopolymer T-runs penalised (premature U6 termination)
# - GC content 40-60% optimal

_PAM_EFFICIENCY = {"TTTA": 1.00, "TTTC": 0.90, "TTTG": 0.80}

# Position-specific nucleotide preferences for Cas12a (1-based, 1=PAM-proximal)
# Derived from pooled-screen data in PMC 2025 (Table S4 approximate values)
_CAS12A_POS_WEIGHTS = {
    1:  {'A': +0.15, 'T': +0.05, 'C': -0.10, 'G': -0.10},
    2:  {'A': +0.10, 'T': +0.08, 'C': -0.08, 'G': -0.10},
    3:  {'T': +0.12, 'A': +0.06, 'C': -0.08, 'G': -0.10},
    4:  {'T': +0.10, 'A': +0.08, 'C': -0.05, 'G': -0.08},
    5:  {'A': +0.08, 'T': +0.08, 'C': -0.04, 'G': -0.05},
    # Seed region (13-20, counting from 5' of spacer = positions 13-20):
    13: {'G': +0.08, 'C': +0.05, 'A': -0.04, 'T': -0.05},
    14: {'G': +0.10, 'C': +0.08, 'A': -0.06, 'T': -0.08},
    15: {'G': +0.12, 'C': +0.10, 'A': -0.08, 'T': -0.10},
    16: {'G': +0.12, 'C': +0.10, 'A': -0.08, 'T': -0.10},
    17: {'G': +0.15, 'C': +0.12, 'A': -0.10, 'T': -0.12},
    18: {'G': +0.15, 'C': +0.12, 'A': -0.10, 'T': -0.12},
    19: {'G': +0.18, 'C': +0.14, 'A': -0.12, 'T': -0.14},
    20: {'G': +0.20, 'C': +0.16, 'A': -0.14, 'T': -0.16},
}


def cas12a_guide_score(spacer: str, pam: str = "TTTA") -> float:
    """Score a 23-nt Cas12a guide (spacer only, 20nt) for plant efficiency.



    Returns [0, 1]; 0.8+ = high efficiency predicted.

    """
    spacer = spacer.upper()[:20]
    if len(spacer) < 20:
        return 0.0

    # PAM efficiency base
    score = _PAM_EFFICIENCY.get(pam.upper(), 0.70)

    # Position-specific preferences (1-indexed from 5' of spacer)
    for i, base in enumerate(spacer, start=1):
        score += _CAS12A_POS_WEIGHTS.get(i, {}).get(base, 0.0)

    # GC content penalty
    gc = sum(1 for b in spacer if b in 'GC') / 20
    if not (0.40 <= gc <= 0.60):
        score -= 0.15 * abs(gc - 0.50)

    # Poly-T run penalty
    if 'TTTT' in spacer:
        score -= 0.25

    return round(max(0.0, min(1.0, score)), 4)


def design_cas12a_guides(dna: str, guide_num: int = 3) -> list:
    """Find and score Cas12a (TTTV) guides in a DNA sequence."""
    dna = dna.upper()
    pam_seqs = {"TTTA": 0, "TTTC": 1, "TTTG": 2}
    guides = []
    for i in range(len(dna) - 24 + 1):
        pam = dna[i:i + 4]
        if pam not in pam_seqs:
            continue
        spacer = dna[i + 4:i + 24]
        if len(spacer) < 20:
            continue
        eff = cas12a_guide_score(spacer, pam)
        guides.append({"guide": spacer, "pam": pam, "position": i,
                       "efficiency": eff, "type": "Cas12a"})
    guides.sort(key=lambda g: g["efficiency"], reverse=True)
    return guides[:guide_num]


# ── 5. Nucleosome Occupancy Approximation ─────────────────────────────────────
# Nucleosome DNA wrapping is characterised by:
# - AA/TT dinucleotides every ~10 bp (in phase with the minor groove)
# - TA dinucleotides at specific positions within the nucleosome
# - GC-rich sequences resist nucleosome wrapping (more open chromatin)
# Source: Nature Methods 2025 (TDAC-seq single-nucleotide resolution).
# Accessible chromatin = better Cas9/Cas12a activity (order-of-magnitude effect).

def nucleosome_occupancy_score(dna: str) -> float:
    """Estimate nucleosome occupancy in [0, 1]; 0=open/accessible, 1=occluded.



    Based on AA/TT 10-bp periodicity and GC content.

    High score means the guide RNA target is likely wrapped in a nucleosome

    and will have REDUCED Cas9/Cas12a activity.

    """
    dna = dna.upper()
    n = len(dna)
    if n < 10:
        return 0.5

    # 1. AA/TT 10-bp periodicity β€” nucleosome signal
    # Count AA and TT at offsets that are multiples of 10 bp.
    aa_tt_periodic = 0
    total_dinucs = 0
    for start in range(0, n - 10, 10):
        window = dna[start:start + 10]
        for i in range(len(window) - 1):
            di = window[i:i + 2]
            if di in ('AA', 'TT', 'TA'):
                aa_tt_periodic += 1
            total_dinucs += 1
    # Fraction of scored dinucleotides that are AA/TT/TA (each 10-bp window
    # contributes 9 dinucleotides). Dividing by the actual dinucleotide count
    # keeps the score in [0, 1]; the previous /3 over-counted ~3Γ— and saturated.
    periodicity_score = aa_tt_periodic / total_dinucs if total_dinucs else 0

    # 2. GC content β€” inverse of occupancy
    gc = sum(1 for b in dna if b in 'GC') / n
    gc_accessibility = gc  # more GC = more accessible

    # Nucleosome occupancy = high periodicity + low GC
    occupancy = 0.5 * periodicity_score + 0.5 * (1.0 - gc_accessibility)
    return round(max(0.0, min(1.0, occupancy)), 4)


def crispr_chromatin_score(dna: str) -> float:
    """CRISPR accessibility score accounting for nucleosome occupancy.



    Returns [0, 1]; 1.0 = open chromatin, predicted highest Cas activity.

    """
    return round(1.0 - nucleosome_occupancy_score(dna), 4)