| """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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| CBE_WINDOW = range(3, 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,
|
| "efficiency_score": round(max(eff_scores), 3),
|
| "context_scores": eff_scores,
|
| })
|
|
|
| return sorted(sites, key=lambda x: x["efficiency_score"], reverse=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| ABE_WINDOW = range(3, 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| PBS_TM_OPTIMAL = (58, 63)
|
| RT_TEMPLATE_OPTIMAL = (20, 25)
|
|
|
|
|
| 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.
|
| """
|
|
|
| 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 = edit_seq + flap_3prime[:edit_site_offset]
|
| rt_len = len(rt)
|
|
|
|
|
| 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))
|
|
|
|
|
| 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,
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| _PAM_EFFICIENCY = {"TTTA": 1.00, "TTTC": 0.90, "TTTG": 0.80}
|
|
|
|
|
|
|
| _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},
|
|
|
| 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
|
|
|
|
|
| score = _PAM_EFFICIENCY.get(pam.upper(), 0.70)
|
|
|
|
|
| for i, base in enumerate(spacer, start=1):
|
| score += _CAS12A_POS_WEIGHTS.get(i, {}).get(base, 0.0)
|
|
|
|
|
| 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)
|
|
|
|
|
| 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]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| periodicity_score = aa_tt_periodic / total_dinucs if total_dinucs else 0
|
|
|
|
|
| gc = sum(1 for b in dna if b in 'GC') / n
|
| gc_accessibility = 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)
|
|
|