Spaces:
Sleeping
Sleeping
File size: 9,828 Bytes
5d5e978 | 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 | """Oligo QC and in-silico PCR helpers for primer design verification.
Implements the checks a wet-lab scientist runs on candidate primers before
ordering them (the IDT OligoAnalyzer + Primer-BLAST + in-silico PCR steps of
the canonical primer design workflow):
* GC content and salt-adjusted melting temperature
* Hairpin (intra-molecular stem-loop) prediction
* Self-dimer / hetero-dimer prediction (complementary annealing)
* In-silico PCR: where each primer binds in the template, expected amplicon
length, and specificity (number of binding sites).
These are heuristic approximations suitable for screening. A delta-G is
estimated with a simple nearest-neighbour-style pair score so primers can be
ranked; it is not a substitute for a full NN thermodynamics calculation.
"""
from __future__ import annotations
import math
import re
from typing import Dict, List, Optional, Tuple
_COMP = {ord('A'): 'T', ord('T'): 'A', ord('G'): 'C', ord('C'): 'G',
ord('N'): 'N', ord('U'): 'A',
ord('a'): 't', ord('t'): 'a', ord('g'): 'c', ord('c'): 'g',
ord('n'): 'n', ord('u'): 'a'}
# Per-base-pair stacking energies (kcal/mol) for the complementary stem.
# Only canonical Watson-Crick pairs count towards a stem; wobble pairs are
# ignored so short weak "stems" don't turn every oligo into a dimer/hairpin.
_PAIR_DG = {
('A', 'T'): -1.0, ('T', 'A'): -1.0,
('G', 'C'): -2.0, ('C', 'G'): -2.0,
}
def clean(seq: str) -> str:
"""Upper-case and strip whitespace (and any non-letter junk)."""
return re.sub(r"[^A-Za-z]", "", seq).upper().replace("U", "T")
def is_dna(seq: str) -> bool:
s = clean(seq)
return bool(s) and all(c in "ATGCN" for c in s)
def reverse_complement(seq: str) -> str:
return seq.upper().translate(_COMP)[::-1]
def gc_content(seq: str) -> float:
s = clean(seq)
if not s:
return 0.0
return round((s.count("G") + s.count("C")) / len(s) * 100.0, 1)
def _pair_energy(a: str, b: str) -> float:
return _PAIR_DG.get((a, b), _PAIR_DG.get((b, a), 0.0))
def salt_adjusted_tm(seq: str, salt_mm: float = 50.0) -> float:
"""IDT-style melting temperature.
Uses the classic NN-approximation formula Tm = 64.9 + 41*(nGC - 16.4)/N
(valid at ~50 mM monovalent salt) plus the SantaLucia salt correction.
"""
s = clean(seq)
n = len(s)
if n < 1:
return 0.0
n_gc = s.count("G") + s.count("C")
tm = 64.9 + 41.0 * (n_gc - 16.4) / n
if salt_mm != 50.0:
tm += 16.6 * math.log10(max(salt_mm, 0.01) / 50.0)
return round(tm, 1)
def _risk_from_dg(dg: float) -> Tuple[str, str]:
if dg <= -7.0:
return "high", "Strongly stabilizing — high risk of mis-priming."
if dg <= -4.5:
return "medium", "Moderately stabilizing — keep an eye on it."
if dg <= -3.0:
return "low", "Weakly stabilizing — unlikely to matter in practice."
return "none", "No significant structure predicted."
def hairpin_analysis(seq: str, min_stem: int = 4, min_loop: int = 3) -> Dict:
"""Predict the strongest intra-molecular hairpin.
A hairpin forms when the strand folds so that one arm is the reverse
complement of the other arm, separated by a loop of at least `min_loop`
bases: seq[i:i+L] must equal reverse_complement(seq[j:j+L]). Returns
delta-G, stem length, stem sequence, loop sequence and a risk level.
"""
s = clean(seq)
n = len(s)
best: Optional[Tuple[float, int, int, int]] = None # (dg, stem_len, i, j)
for i in range(n):
for j in range(i + min_stem + min_loop, n):
# loop = j - (i + L) must stay >= min_loop
lmax = min(j - i - min_loop, n - j, 10)
if lmax < min_stem:
continue
for L in range(min_stem, lmax + 1):
dg = 0.0
ok = True
for k in range(L):
e = _pair_energy(s[i + k], s[j + L - 1 - k])
if e == 0.0:
ok = False
break
dg += e
if ok and (best is None or dg < best[0]):
best = (dg, L, i, j)
if best is None:
return {"dg": 5.0, "stem_length": 0, "stem": "", "loop": "",
"risk": "none", "note": "No hairpin structure predicted."}
dg, stem_len, i, j = best
stem = s[i:i + stem_len]
loop = s[i + stem_len:j]
risk, note = _risk_from_dg(dg)
return {
"dg": round(dg, 1),
"stem_length": stem_len,
"stem": stem,
"loop": loop,
"risk": risk,
"note": note,
}
def dimer_analysis(a: str, b: str, min_stem: int = 4) -> Dict:
"""Predict the strongest dimer between two primers.
A dimer forms where primer `a` anneals antiparallel to primer `b`, i.e.
where a region of `a` is complementary to a region of reversed `b`. Reports
the most stabilizing complementary stem, whether the stem involves the 3'
end of either primer (extension-competent dimers are the dangerous ones
for PCR), and a risk level.
"""
pa = clean(a)
rev_b = clean(b)[::-1]
n, m = len(pa), len(rev_b)
best: Optional[Tuple[float, int, int, int]] = None # (dg, stem_len, i, j)
for i in range(n):
for j in range(m):
k = 0
dg = 0.0
while i + k < n and j + k < m:
e = _pair_energy(pa[i + k], rev_b[j + k])
if e == 0.0:
break
dg += e
k += 1
if k >= 12:
break
if k >= min_stem and (best is None or dg < best[0]):
best = (dg, k, i, j)
if best is None:
return {"dg": 5.0, "stem_length": 0, "stem": "", "involves_a3": False,
"involves_b3": False, "risk": "none",
"note": "No significant dimer predicted."}
dg, stem_len, i, j = best
stem = pa[i:i + stem_len]
a3 = (i + stem_len == n) # stem reaches the 3' end of primer a
b3 = (j == 0) # rev_b[0] complements b's 3' terminal base
risk, note = _risk_from_dg(dg)
if a3 or b3:
note = ("3' end involved — extension-competent, more likely to "
"interfere with PCR. ") + note
return {
"dg": round(dg, 1),
"stem_length": stem_len,
"stem": stem,
"involves_a3": a3,
"involves_b3": b3,
"risk": risk,
"note": note,
}
def find_binding_sites(template: str, primer: str) -> List[int]:
"""All 0-based start positions where `primer` matches `template`."""
t = clean(template)
p = clean(primer)
if not t or not p:
return []
return [m.start() for m in re.finditer(r"(?=" + re.escape(p) + r")", t)]
def in_silico_pcr(template: str, left: str, right: str,
expected_product: Optional[int] = None,
left_expected: Optional[int] = None,
right_expected: Optional[int] = None) -> Dict:
"""Simulate the PCR reaction against a template sequence.
The forward primer binds as-is; the reverse primer (given 5'->3' as
Primer3 returns it) is the reverse complement of the forward-strand region
it anneals to. Amplicons are [forward_site, reverse_site + len(right)).
"""
t = clean(template)
lseq = clean(left)
rseq = clean(right)
left_sites = find_binding_sites(t, lseq)
right_target = reverse_complement(rseq)
right_sites = find_binding_sites(t, right_target)
amplicons: List[Tuple[int, int, int]] = []
for f in left_sites:
for r in right_sites:
end = r + len(rseq)
if end > f:
amplicons.append((f, end, end - f))
amplicons.sort(key=lambda x: x[2])
specific = len(left_sites) == 1 and len(right_sites) == 1
primer3_consistent = False
if left_expected is not None and right_expected is not None and right_expected > 0:
right_bind = right_expected - len(rseq) + 1
primer3_consistent = (
left_expected in left_sites and right_bind in right_sites
)
result: Dict = {
"template_length": len(t),
"forward_binding_sites": len(left_sites),
"reverse_binding_sites": len(right_sites),
"forward_positions": left_sites[:20],
"reverse_positions": right_sites[:20],
"specific": specific,
"amplicons": [{"start": f, "end": e, "length": ln}
for f, e, ln in amplicons[:10]],
"primer3_consistent": primer3_consistent,
"note": None,
}
if expected_product is not None:
# Primer3 product size == right_pos - left_pos + 1; allow 1 bp slack.
match = any(abs(a[2] - expected_product) <= 1 for a in amplicons)
result["matches_product_size"] = match
else:
result["matches_product_size"] = None
if not left_sites and not right_sites:
result["note"] = "Neither primer binds the template."
elif not left_sites:
result["note"] = "Forward primer does not bind the template."
elif not right_sites:
result["note"] = "Reverse primer does not bind the template."
elif not specific:
result["note"] = ("Multiple binding sites detected — this primer pair "
"may amplify off-target products.")
else:
result["note"] = "Specific: each primer binds exactly once."
return result
def oligo_report(seq: str) -> Dict:
"""Full QC report for a single primer."""
s = clean(seq)
hp = hairpin_analysis(s)
sd = dimer_analysis(s, s)
return {
"sequence": s,
"length": len(s),
"gc": gc_content(s),
"tm_50mM": salt_adjusted_tm(s),
"hairpin": hp,
"self_dimer": sd,
}
|