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,
    }