Plant-DNA-Designer / cool /core /base_editor.py
Dinesh4311's picture
Add application code
aa752f5 verified
Raw
History Blame Contribute Delete
13.3 kB
"""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)