dnakit 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dnakit/__init__.py +108 -0
- dnakit/_version.py +5 -0
- dnakit/alignment/__init__.py +6 -0
- dnakit/alignment/pairwise.py +512 -0
- dnakit/alignment/results.py +188 -0
- dnakit/backends/__init__.py +80 -0
- dnakit/backends/external.py +517 -0
- dnakit/backends/registry.py +207 -0
- dnakit/batch.py +452 -0
- dnakit/cache/__init__.py +5 -0
- dnakit/cache/store.py +424 -0
- dnakit/chunking.py +920 -0
- dnakit/cli/__init__.py +5 -0
- dnakit/cli/__main__.py +6 -0
- dnakit/cli/app.py +814 -0
- dnakit/cli/workflow.py +94 -0
- dnakit/config/__init__.py +5 -0
- dnakit/config/loader.py +381 -0
- dnakit/core/__init__.py +80 -0
- dnakit/core/_json.py +206 -0
- dnakit/core/backend_info.py +70 -0
- dnakit/core/collection.py +163 -0
- dnakit/core/coordinates.py +295 -0
- dnakit/core/enums.py +95 -0
- dnakit/core/facade.py +719 -0
- dnakit/core/feature.py +90 -0
- dnakit/core/gap.py +82 -0
- dnakit/core/issues.py +62 -0
- dnakit/core/provenance.py +305 -0
- dnakit/core/record.py +161 -0
- dnakit/core/results.py +134 -0
- dnakit/core/sequence.py +327 -0
- dnakit/datasets/__init__.py +100 -0
- dnakit/datasets/_advanced_shared.py +198 -0
- dnakit/datasets/_metadata.py +33 -0
- dnakit/datasets/advanced_deduplicate.py +183 -0
- dnakit/datasets/advanced_split.py +302 -0
- dnakit/datasets/clustering.py +286 -0
- dnakit/datasets/config.py +531 -0
- dnakit/datasets/deduplicate.py +303 -0
- dnakit/datasets/evaluation.py +244 -0
- dnakit/datasets/filter.py +135 -0
- dnakit/datasets/neural_clustering.py +221 -0
- dnakit/datasets/results.py +520 -0
- dnakit/datasets/split.py +450 -0
- dnakit/descriptors/__init__.py +102 -0
- dnakit/descriptors/_dinucleotide.py +345 -0
- dnakit/descriptors/_shared.py +180 -0
- dnakit/descriptors/all.py +917 -0
- dnakit/descriptors/basic.py +193 -0
- dnakit/descriptors/codon.py +195 -0
- dnakit/descriptors/complexity.py +201 -0
- dnakit/descriptors/entropy.py +101 -0
- dnakit/descriptors/homopolymer.py +105 -0
- dnakit/descriptors/kmer.py +88 -0
- dnakit/descriptors/results.py +286 -0
- dnakit/descriptors/schema.py +414 -0
- dnakit/descriptors/window.py +203 -0
- dnakit/download/__init__.py +54 -0
- dnakit/download/catalogs.py +166 -0
- dnakit/download/ena.py +131 -0
- dnakit/download/encode.py +103 -0
- dnakit/download/files.py +469 -0
- dnakit/download/indexes.py +233 -0
- dnakit/download/metadata.py +164 -0
- dnakit/download/models.py +227 -0
- dnakit/download/ncbi.py +333 -0
- dnakit/download/sequence.py +133 -0
- dnakit/download/ucsc.py +99 -0
- dnakit/evaluation/__init__.py +81 -0
- dnakit/evaluation/_shared.py +288 -0
- dnakit/evaluation/collection.py +270 -0
- dnakit/evaluation/config.py +591 -0
- dnakit/evaluation/distribution.py +287 -0
- dnakit/evaluation/frechet.py +223 -0
- dnakit/evaluation/generative.py +381 -0
- dnakit/evaluation/reference.py +409 -0
- dnakit/evaluation/results.py +137 -0
- dnakit/evaluation/scorecard.py +211 -0
- dnakit/evaluation/sequence.py +403 -0
- dnakit/evaluation/synthesis.py +249 -0
- dnakit/exceptions.py +189 -0
- dnakit/fingerprints/__init__.py +109 -0
- dnakit/fingerprints/_shared.py +116 -0
- dnakit/fingerprints/advanced.py +829 -0
- dnakit/fingerprints/bit.py +229 -0
- dnakit/fingerprints/encoding.py +351 -0
- dnakit/fingerprints/kmer.py +170 -0
- dnakit/fingerprints/results.py +531 -0
- dnakit/fingerprints/sketch.py +246 -0
- dnakit/io/__init__.py +112 -0
- dnakit/io/_advanced_common.py +105 -0
- dnakit/io/_formats.py +994 -0
- dnakit/io/annotations.py +1179 -0
- dnakit/io/api.py +759 -0
- dnakit/io/config.py +315 -0
- dnakit/io/genbank.py +523 -0
- dnakit/io/indexed.py +1146 -0
- dnakit/io/metadata.py +452 -0
- dnakit/io/parquet.py +56 -0
- dnakit/io/results.py +131 -0
- dnakit/io/source.py +138 -0
- dnakit/io/tables.py +1237 -0
- dnakit/molbio/__init__.py +101 -0
- dnakit/molbio/_shared.py +266 -0
- dnakit/molbio/assembly.py +279 -0
- dnakit/molbio/crispr.py +181 -0
- dnakit/molbio/optimization.py +897 -0
- dnakit/molbio/primer3_design.py +667 -0
- dnakit/molbio/primers.py +899 -0
- dnakit/molbio/restriction.py +573 -0
- dnakit/molbio/results.py +583 -0
- dnakit/ops/__init__.py +588 -0
- dnakit/ops/_common.py +201 -0
- dnakit/ops/circular.py +193 -0
- dnakit/ops/concat.py +204 -0
- dnakit/ops/direction.py +27 -0
- dnakit/ops/edit.py +443 -0
- dnakit/ops/evolution.py +991 -0
- dnakit/ops/mutation.py +151 -0
- dnakit/ops/records.py +1012 -0
- dnakit/ops/translation.py +257 -0
- dnakit/patterns/__init__.py +81 -0
- dnakit/patterns/_shared.py +282 -0
- dnakit/patterns/coding.py +360 -0
- dnakit/patterns/crispr.py +337 -0
- dnakit/patterns/motif.py +721 -0
- dnakit/patterns/regions.py +341 -0
- dnakit/patterns/repeats.py +480 -0
- dnakit/patterns/restriction.py +281 -0
- dnakit/patterns/results.py +411 -0
- dnakit/py.typed +1 -0
- dnakit/references/__init__.py +27 -0
- dnakit/references/models.py +155 -0
- dnakit/references/ncbi.py +785 -0
- dnakit/representations/__init__.py +29 -0
- dnakit/representations/backends.py +928 -0
- dnakit/representations/checkpoints.py +356 -0
- dnakit/representations/extraction.py +220 -0
- dnakit/representations/models.py +395 -0
- dnakit/representations/results.py +79 -0
- dnakit/search/__init__.py +132 -0
- dnakit/search/_http.py +271 -0
- dnakit/search/_shared.py +108 -0
- dnakit/search/blast.py +516 -0
- dnakit/search/ena.py +245 -0
- dnakit/search/encode.py +102 -0
- dnakit/search/ensembl.py +874 -0
- dnakit/search/models.py +159 -0
- dnakit/search/ncbi.py +756 -0
- dnakit/search/ucsc.py +388 -0
- dnakit/secondary_structure/__init__.py +38 -0
- dnakit/secondary_structure/dotbracket.py +410 -0
- dnakit/secondary_structure/nupack.py +589 -0
- dnakit/secondary_structure/results.py +142 -0
- dnakit/similarity/__init__.py +125 -0
- dnakit/similarity/_shared.py +152 -0
- dnakit/similarity/approximate.py +258 -0
- dnakit/similarity/compare.py +162 -0
- dnakit/similarity/dashing.py +936 -0
- dnakit/similarity/distance.py +279 -0
- dnakit/similarity/index.py +365 -0
- dnakit/similarity/matrix.py +254 -0
- dnakit/similarity/results.py +676 -0
- dnakit/similarity/search.py +286 -0
- dnakit/similarity/sketch.py +96 -0
- dnakit/similarity/vector.py +662 -0
- dnakit/standardize/__init__.py +77 -0
- dnakit/standardize/_shared.py +100 -0
- dnakit/standardize/config.py +411 -0
- dnakit/standardize/gaps.py +1060 -0
- dnakit/standardize/normalize.py +565 -0
- dnakit/standardize/results.py +247 -0
- dnakit/standardize/validate.py +487 -0
- dnakit/structure3d/__init__.py +47 -0
- dnakit/structure3d/_shared.py +47 -0
- dnakit/structure3d/geometry.py +773 -0
- dnakit/structure3d/pdb.py +253 -0
- dnakit/structure3d/results.py +233 -0
- dnakit/structure3d/threedna.py +174 -0
- dnakit/thermodynamics/__init__.py +112 -0
- dnakit/thermodynamics/_shared.py +92 -0
- dnakit/thermodynamics/backends.py +653 -0
- dnakit/thermodynamics/calculations.py +837 -0
- dnakit/thermodynamics/config.py +175 -0
- dnakit/thermodynamics/equilibrium.py +340 -0
- dnakit/thermodynamics/optics.py +385 -0
- dnakit/thermodynamics/parameters.py +79 -0
- dnakit/thermodynamics/results.py +445 -0
- dnakit/visualization/__init__.py +43 -0
- dnakit/visualization/_svg.py +185 -0
- dnakit/visualization/advanced.py +314 -0
- dnakit/visualization/config.py +255 -0
- dnakit/visualization/export.py +349 -0
- dnakit/visualization/matrix.py +279 -0
- dnakit/visualization/report.py +431 -0
- dnakit/visualization/results.py +236 -0
- dnakit/visualization/sequence.py +459 -0
- dnakit/workflows/__init__.py +45 -0
- dnakit/workflows/manifest.py +365 -0
- dnakit/workflows/runner.py +1548 -0
- dnakit/workflows/schema.py +898 -0
- dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
- dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
- dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
- dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
- dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
- dnakit-0.1.0.dev0.dist-info/top_level.txt +1 -0
dnakit/molbio/primers.py
ADDED
|
@@ -0,0 +1,899 @@
|
|
|
1
|
+
"""Primer matching, PCR simulation, properties, and design request contracts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from itertools import islice
|
|
7
|
+
from typing import Protocol, cast
|
|
8
|
+
|
|
9
|
+
from dnakit.core import DNAAlphabet, DNASequence, Issue, IssueSeverity, Strand, Topology
|
|
10
|
+
from dnakit.exceptions import BackendExecutionError, ConfigurationError
|
|
11
|
+
from dnakit.thermodynamics import (
|
|
12
|
+
MeltingTemperatureResult,
|
|
13
|
+
NearestNeighborConfig,
|
|
14
|
+
Primer3ThermodynamicResult,
|
|
15
|
+
ThermodynamicConditions,
|
|
16
|
+
melting_temperature,
|
|
17
|
+
validate_primer3_result,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from ._shared import (
|
|
21
|
+
adapter_provenance,
|
|
22
|
+
circular_slice,
|
|
23
|
+
finite_fraction,
|
|
24
|
+
freeze_parameters,
|
|
25
|
+
iupac_compatible,
|
|
26
|
+
native_provenance,
|
|
27
|
+
require_sequence,
|
|
28
|
+
reverse_complement_text,
|
|
29
|
+
validate_positive_int,
|
|
30
|
+
)
|
|
31
|
+
from .results import (
|
|
32
|
+
Amplicon,
|
|
33
|
+
ConditionalAnalysis,
|
|
34
|
+
PCRResult,
|
|
35
|
+
PrimerBindingHit,
|
|
36
|
+
PrimerDesignInterfaceResult,
|
|
37
|
+
PrimerDesignRequest,
|
|
38
|
+
PrimerMatchResult,
|
|
39
|
+
PrimerPropertiesResult,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PrimerStructureAdapter(Protocol):
|
|
44
|
+
"""Explicit structure adapter accepted by :func:`primer_properties`."""
|
|
45
|
+
|
|
46
|
+
def hairpin(
|
|
47
|
+
self,
|
|
48
|
+
sequence: DNASequence,
|
|
49
|
+
*,
|
|
50
|
+
conditions: ThermodynamicConditions | None = None,
|
|
51
|
+
max_loop: int = 30,
|
|
52
|
+
output_structure: bool = False,
|
|
53
|
+
) -> Primer3ThermodynamicResult: ...
|
|
54
|
+
|
|
55
|
+
def self_dimer(
|
|
56
|
+
self,
|
|
57
|
+
sequence: DNASequence,
|
|
58
|
+
*,
|
|
59
|
+
conditions: ThermodynamicConditions | None = None,
|
|
60
|
+
max_loop: int = 30,
|
|
61
|
+
output_structure: bool = False,
|
|
62
|
+
) -> Primer3ThermodynamicResult: ...
|
|
63
|
+
|
|
64
|
+
def heterodimer(
|
|
65
|
+
self,
|
|
66
|
+
sequence_a: DNASequence,
|
|
67
|
+
sequence_b: DNASequence,
|
|
68
|
+
*,
|
|
69
|
+
conditions: ThermodynamicConditions | None = None,
|
|
70
|
+
max_loop: int = 30,
|
|
71
|
+
output_structure: bool = False,
|
|
72
|
+
) -> Primer3ThermodynamicResult: ...
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _PrimerTmAdapter(Protocol):
|
|
76
|
+
def tm(
|
|
77
|
+
self,
|
|
78
|
+
sequence: DNASequence,
|
|
79
|
+
*,
|
|
80
|
+
conditions: ThermodynamicConditions | None = None,
|
|
81
|
+
) -> Primer3ThermodynamicResult: ...
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _coerce_strands(value: Strand | str) -> tuple[Strand, ...]:
|
|
85
|
+
try:
|
|
86
|
+
resolved = value if isinstance(value, Strand) else Strand(value)
|
|
87
|
+
except (TypeError, ValueError) as exc:
|
|
88
|
+
raise ConfigurationError("strand must be forward, reverse, or both.") from exc
|
|
89
|
+
if resolved is Strand.BOTH:
|
|
90
|
+
return (Strand.FORWARD, Strand.REVERSE)
|
|
91
|
+
if resolved not in (Strand.FORWARD, Strand.REVERSE):
|
|
92
|
+
raise ConfigurationError("strand must be forward, reverse, or both.")
|
|
93
|
+
return (resolved,)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _comparison_positions(
|
|
97
|
+
primer: str,
|
|
98
|
+
template_window: str,
|
|
99
|
+
strand: Strand,
|
|
100
|
+
*,
|
|
101
|
+
allow_iupac: bool,
|
|
102
|
+
) -> tuple[int, ...]:
|
|
103
|
+
oriented = primer if strand is Strand.FORWARD else reverse_complement_text(primer)
|
|
104
|
+
mismatches: list[int] = []
|
|
105
|
+
for index, (query, target) in enumerate(zip(oriented, template_window, strict=True)):
|
|
106
|
+
matches = iupac_compatible(query, target) if allow_iupac else query == target
|
|
107
|
+
if not matches:
|
|
108
|
+
primer_index = index if strand is Strand.FORWARD else len(primer) - index - 1
|
|
109
|
+
mismatches.append(primer_index)
|
|
110
|
+
return tuple(sorted(mismatches))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def match_primer(
|
|
114
|
+
primer: DNASequence,
|
|
115
|
+
template: DNASequence,
|
|
116
|
+
*,
|
|
117
|
+
primer_name: str = "primer",
|
|
118
|
+
strand: Strand | str = Strand.BOTH,
|
|
119
|
+
max_mismatches: int = 0,
|
|
120
|
+
strict_three_prime_bases: int = 0,
|
|
121
|
+
allow_iupac: bool = False,
|
|
122
|
+
max_hits: int = 100_000,
|
|
123
|
+
max_template_length: int = 10_000_000,
|
|
124
|
+
max_comparison_cells: int = 100_000_000,
|
|
125
|
+
) -> PrimerMatchResult:
|
|
126
|
+
"""Find ungapped primer matches and report mismatches in primer 5'-to-3' order."""
|
|
127
|
+
|
|
128
|
+
primer_symbols = require_sequence(
|
|
129
|
+
primer,
|
|
130
|
+
operation="primer matching",
|
|
131
|
+
max_length=1_000,
|
|
132
|
+
canonical=not allow_iupac,
|
|
133
|
+
allow_circular=False,
|
|
134
|
+
)
|
|
135
|
+
template_symbols = require_sequence(
|
|
136
|
+
template,
|
|
137
|
+
operation="primer matching",
|
|
138
|
+
max_length=max_template_length,
|
|
139
|
+
canonical=not allow_iupac,
|
|
140
|
+
)
|
|
141
|
+
if not isinstance(primer_name, str) or not primer_name.strip():
|
|
142
|
+
raise ConfigurationError("primer_name must be non-empty.")
|
|
143
|
+
if not isinstance(allow_iupac, bool):
|
|
144
|
+
raise ConfigurationError("allow_iupac must be boolean.")
|
|
145
|
+
if isinstance(max_mismatches, bool) or not isinstance(max_mismatches, int):
|
|
146
|
+
raise ConfigurationError("max_mismatches must be a non-negative integer.")
|
|
147
|
+
if not 0 <= max_mismatches <= len(primer_symbols):
|
|
148
|
+
raise ConfigurationError("max_mismatches lies outside the primer length.")
|
|
149
|
+
if isinstance(strict_three_prime_bases, bool) or not isinstance(strict_three_prime_bases, int):
|
|
150
|
+
raise ConfigurationError("strict_three_prime_bases must be a non-negative integer.")
|
|
151
|
+
if not 0 <= strict_three_prime_bases <= len(primer_symbols):
|
|
152
|
+
raise ConfigurationError("strict_three_prime_bases lies outside the primer length.")
|
|
153
|
+
validate_positive_int(max_hits, "max_hits", maximum=1_000_000)
|
|
154
|
+
validate_positive_int(max_comparison_cells, "max_comparison_cells", maximum=1_000_000_000)
|
|
155
|
+
strands = _coerce_strands(strand)
|
|
156
|
+
circular = template.topology is Topology.CIRCULAR
|
|
157
|
+
if len(primer_symbols) > len(template_symbols):
|
|
158
|
+
window_count = 0
|
|
159
|
+
else:
|
|
160
|
+
window_count = (
|
|
161
|
+
len(template_symbols) if circular else len(template_symbols) - len(primer_symbols) + 1
|
|
162
|
+
)
|
|
163
|
+
estimated_cells = window_count * len(primer_symbols) * len(strands)
|
|
164
|
+
if estimated_cells > max_comparison_cells:
|
|
165
|
+
raise ConfigurationError(
|
|
166
|
+
"Primer scan exceeds max_comparison_cells.",
|
|
167
|
+
code="PRIMER_MATCH_LIMIT_EXCEEDED",
|
|
168
|
+
context={
|
|
169
|
+
"estimated_cells": estimated_cells,
|
|
170
|
+
"max_comparison_cells": max_comparison_cells,
|
|
171
|
+
},
|
|
172
|
+
)
|
|
173
|
+
hits: list[PrimerBindingHit] = []
|
|
174
|
+
truncated = False
|
|
175
|
+
for resolved_strand in strands:
|
|
176
|
+
for start in range(window_count):
|
|
177
|
+
window = (
|
|
178
|
+
circular_slice(template_symbols, start, len(primer_symbols))
|
|
179
|
+
if circular
|
|
180
|
+
else template_symbols[start : start + len(primer_symbols)]
|
|
181
|
+
)
|
|
182
|
+
mismatches = _comparison_positions(
|
|
183
|
+
primer_symbols,
|
|
184
|
+
window,
|
|
185
|
+
resolved_strand,
|
|
186
|
+
allow_iupac=allow_iupac,
|
|
187
|
+
)
|
|
188
|
+
three_prime = sum(
|
|
189
|
+
position >= len(primer_symbols) - strict_three_prime_bases
|
|
190
|
+
for position in mismatches
|
|
191
|
+
)
|
|
192
|
+
if len(mismatches) > max_mismatches or three_prime:
|
|
193
|
+
continue
|
|
194
|
+
if len(hits) >= max_hits:
|
|
195
|
+
truncated = True
|
|
196
|
+
break
|
|
197
|
+
raw_end = start + len(primer_symbols)
|
|
198
|
+
hits.append(
|
|
199
|
+
PrimerBindingHit(
|
|
200
|
+
primer_name=primer_name,
|
|
201
|
+
strand=resolved_strand,
|
|
202
|
+
start=start,
|
|
203
|
+
end=(
|
|
204
|
+
raw_end - len(template_symbols)
|
|
205
|
+
if circular and raw_end > len(template_symbols)
|
|
206
|
+
else raw_end
|
|
207
|
+
),
|
|
208
|
+
matched_template=window,
|
|
209
|
+
mismatch_positions_5to3=mismatches,
|
|
210
|
+
three_prime_mismatch_count=three_prime,
|
|
211
|
+
wraps_origin=circular and raw_end > len(template_symbols),
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
if truncated:
|
|
215
|
+
break
|
|
216
|
+
return PrimerMatchResult(
|
|
217
|
+
primer_sequence=primer_symbols,
|
|
218
|
+
template_length=len(template_symbols),
|
|
219
|
+
template_topology=template.topology.value,
|
|
220
|
+
hits=tuple(hits),
|
|
221
|
+
truncated=truncated,
|
|
222
|
+
method="bounded_ungapped_hamming_primer_match",
|
|
223
|
+
algorithm_version="dnakit-primer-match-v1",
|
|
224
|
+
parameters=freeze_parameters(
|
|
225
|
+
{
|
|
226
|
+
"strand": str(strand),
|
|
227
|
+
"max_mismatches": max_mismatches,
|
|
228
|
+
"strict_three_prime_bases": strict_three_prime_bases,
|
|
229
|
+
"allow_indels": False,
|
|
230
|
+
"allow_iupac": allow_iupac,
|
|
231
|
+
"max_hits": max_hits,
|
|
232
|
+
"max_template_length": max_template_length,
|
|
233
|
+
"estimated_comparison_cells": estimated_cells,
|
|
234
|
+
"max_comparison_cells": max_comparison_cells,
|
|
235
|
+
}
|
|
236
|
+
),
|
|
237
|
+
provenance=native_provenance(),
|
|
238
|
+
issues=(),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def simulate_pcr(
|
|
243
|
+
template: DNASequence,
|
|
244
|
+
forward_primer: DNASequence,
|
|
245
|
+
reverse_primer: DNASequence,
|
|
246
|
+
*,
|
|
247
|
+
max_mismatches: int = 0,
|
|
248
|
+
strict_three_prime_bases: int = 1,
|
|
249
|
+
min_product_length: int = 1,
|
|
250
|
+
max_product_length: int = 100_000,
|
|
251
|
+
allow_iupac: bool = False,
|
|
252
|
+
max_binding_hits: int = 10_000,
|
|
253
|
+
max_pair_checks: int = 1_000_000,
|
|
254
|
+
max_products: int = 10_000,
|
|
255
|
+
max_total_product_bases: int = 100_000_000,
|
|
256
|
+
max_template_length: int = 10_000_000,
|
|
257
|
+
) -> PCRResult:
|
|
258
|
+
"""Predict inward-facing linear or single-traversal circular amplicons."""
|
|
259
|
+
|
|
260
|
+
template_symbols = require_sequence(
|
|
261
|
+
template,
|
|
262
|
+
operation="PCR simulation",
|
|
263
|
+
max_length=max_template_length,
|
|
264
|
+
canonical=not allow_iupac,
|
|
265
|
+
)
|
|
266
|
+
forward_symbols = require_sequence(
|
|
267
|
+
forward_primer,
|
|
268
|
+
operation="PCR simulation",
|
|
269
|
+
max_length=1_000,
|
|
270
|
+
canonical=not allow_iupac,
|
|
271
|
+
allow_circular=False,
|
|
272
|
+
)
|
|
273
|
+
reverse_symbols = require_sequence(
|
|
274
|
+
reverse_primer,
|
|
275
|
+
operation="PCR simulation",
|
|
276
|
+
max_length=1_000,
|
|
277
|
+
canonical=not allow_iupac,
|
|
278
|
+
allow_circular=False,
|
|
279
|
+
)
|
|
280
|
+
validate_positive_int(min_product_length, "min_product_length")
|
|
281
|
+
validate_positive_int(max_product_length, "max_product_length", maximum=100_000_000)
|
|
282
|
+
if min_product_length > max_product_length:
|
|
283
|
+
raise ConfigurationError("min_product_length cannot exceed max_product_length.")
|
|
284
|
+
validate_positive_int(max_pair_checks, "max_pair_checks", maximum=100_000_000)
|
|
285
|
+
validate_positive_int(max_products, "max_products", maximum=1_000_000)
|
|
286
|
+
validate_positive_int(
|
|
287
|
+
max_total_product_bases,
|
|
288
|
+
"max_total_product_bases",
|
|
289
|
+
maximum=10_000_000_000,
|
|
290
|
+
)
|
|
291
|
+
forward_matches = match_primer(
|
|
292
|
+
forward_primer,
|
|
293
|
+
template,
|
|
294
|
+
primer_name="forward",
|
|
295
|
+
strand=Strand.FORWARD,
|
|
296
|
+
max_mismatches=max_mismatches,
|
|
297
|
+
strict_three_prime_bases=strict_three_prime_bases,
|
|
298
|
+
allow_iupac=allow_iupac,
|
|
299
|
+
max_hits=max_binding_hits,
|
|
300
|
+
max_template_length=max_template_length,
|
|
301
|
+
)
|
|
302
|
+
reverse_matches = match_primer(
|
|
303
|
+
reverse_primer,
|
|
304
|
+
template,
|
|
305
|
+
primer_name="reverse",
|
|
306
|
+
strand=Strand.REVERSE,
|
|
307
|
+
max_mismatches=max_mismatches,
|
|
308
|
+
strict_three_prime_bases=strict_three_prime_bases,
|
|
309
|
+
allow_iupac=allow_iupac,
|
|
310
|
+
max_hits=max_binding_hits,
|
|
311
|
+
max_template_length=max_template_length,
|
|
312
|
+
)
|
|
313
|
+
if forward_matches.truncated or reverse_matches.truncated:
|
|
314
|
+
raise ConfigurationError(
|
|
315
|
+
"Primer binding hits were truncated; increase max_binding_hits before PCR pairing.",
|
|
316
|
+
code="PCR_BINDING_HITS_TRUNCATED",
|
|
317
|
+
)
|
|
318
|
+
pair_checks = len(forward_matches.hits) * len(reverse_matches.hits)
|
|
319
|
+
if pair_checks > max_pair_checks:
|
|
320
|
+
raise ConfigurationError(
|
|
321
|
+
"PCR primer pairing exceeds max_pair_checks.",
|
|
322
|
+
code="PCR_PAIR_LIMIT_EXCEEDED",
|
|
323
|
+
context={"pair_checks": pair_checks, "max_pair_checks": max_pair_checks},
|
|
324
|
+
)
|
|
325
|
+
circular = template.topology is Topology.CIRCULAR
|
|
326
|
+
reverse_product_end = reverse_complement_text(reverse_symbols)
|
|
327
|
+
products: list[Amplicon] = []
|
|
328
|
+
total_product_bases = 0
|
|
329
|
+
truncated = False
|
|
330
|
+
for forward_hit in forward_matches.hits:
|
|
331
|
+
for reverse_hit in reverse_matches.hits:
|
|
332
|
+
if circular:
|
|
333
|
+
product_length = (
|
|
334
|
+
reverse_hit.start + len(reverse_symbols) - forward_hit.start
|
|
335
|
+
) % len(template_symbols)
|
|
336
|
+
if product_length == 0:
|
|
337
|
+
product_length = len(template_symbols)
|
|
338
|
+
if product_length < len(forward_symbols) + len(reverse_symbols):
|
|
339
|
+
continue
|
|
340
|
+
interior_length = product_length - len(forward_symbols) - len(reverse_symbols)
|
|
341
|
+
interior = circular_slice(
|
|
342
|
+
template_symbols,
|
|
343
|
+
forward_hit.start + len(forward_symbols),
|
|
344
|
+
interior_length,
|
|
345
|
+
)
|
|
346
|
+
template_end = (forward_hit.start + product_length) % len(template_symbols)
|
|
347
|
+
wraps = forward_hit.start + product_length > len(template_symbols)
|
|
348
|
+
else:
|
|
349
|
+
if forward_hit.end > reverse_hit.start:
|
|
350
|
+
continue
|
|
351
|
+
product_length = reverse_hit.end - forward_hit.start
|
|
352
|
+
interior = template_symbols[forward_hit.end : reverse_hit.start]
|
|
353
|
+
template_end = reverse_hit.end
|
|
354
|
+
wraps = False
|
|
355
|
+
if not min_product_length <= product_length <= max_product_length:
|
|
356
|
+
continue
|
|
357
|
+
if len(products) >= max_products:
|
|
358
|
+
truncated = True
|
|
359
|
+
break
|
|
360
|
+
if total_product_bases + product_length > max_total_product_bases:
|
|
361
|
+
truncated = True
|
|
362
|
+
break
|
|
363
|
+
product_symbols = forward_symbols + interior + reverse_product_end
|
|
364
|
+
alphabet = (
|
|
365
|
+
DNAAlphabet.IUPAC if set(product_symbols) - set("ACGT") else DNAAlphabet.STRICT
|
|
366
|
+
)
|
|
367
|
+
products.append(
|
|
368
|
+
Amplicon(
|
|
369
|
+
sequence=DNASequence(
|
|
370
|
+
product_symbols,
|
|
371
|
+
alphabet=alphabet,
|
|
372
|
+
topology=Topology.LINEAR,
|
|
373
|
+
strandedness=template.strandedness,
|
|
374
|
+
),
|
|
375
|
+
template_start=forward_hit.start,
|
|
376
|
+
template_end=template_end,
|
|
377
|
+
wraps_origin=wraps,
|
|
378
|
+
forward_binding=forward_hit,
|
|
379
|
+
reverse_binding=reverse_hit,
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
total_product_bases += product_length
|
|
383
|
+
if truncated:
|
|
384
|
+
break
|
|
385
|
+
return PCRResult(
|
|
386
|
+
template_length=len(template_symbols),
|
|
387
|
+
template_topology=template.topology.value,
|
|
388
|
+
amplicons=tuple(products),
|
|
389
|
+
truncated=truncated,
|
|
390
|
+
method="inward_facing_ungapped_primer_pairing",
|
|
391
|
+
algorithm_version="dnakit-pcr-simulation-v1",
|
|
392
|
+
parameters=freeze_parameters(
|
|
393
|
+
{
|
|
394
|
+
"max_mismatches": max_mismatches,
|
|
395
|
+
"strict_three_prime_bases": strict_three_prime_bases,
|
|
396
|
+
"min_product_length": min_product_length,
|
|
397
|
+
"max_product_length": max_product_length,
|
|
398
|
+
"allow_iupac": allow_iupac,
|
|
399
|
+
"allow_indels": False,
|
|
400
|
+
"circular_traversals": 1 if circular else 0,
|
|
401
|
+
"pair_checks": pair_checks,
|
|
402
|
+
"max_pair_checks": max_pair_checks,
|
|
403
|
+
"max_products": max_products,
|
|
404
|
+
"total_product_bases": total_product_bases,
|
|
405
|
+
"max_total_product_bases": max_total_product_bases,
|
|
406
|
+
}
|
|
407
|
+
),
|
|
408
|
+
provenance=native_provenance(),
|
|
409
|
+
issues=(),
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _conditional_structure(capability: str) -> ConditionalAnalysis:
|
|
414
|
+
return ConditionalAnalysis(
|
|
415
|
+
capability=capability,
|
|
416
|
+
status="conditional-unavailable",
|
|
417
|
+
available=False,
|
|
418
|
+
backend_required="Primer3-compatible user-installed adapter",
|
|
419
|
+
automatic_probe=False,
|
|
420
|
+
automatic_execution=False,
|
|
421
|
+
reason=(
|
|
422
|
+
"The native module does not claim a validated secondary-structure model; "
|
|
423
|
+
"prepare a backend request and execute it explicitly outside this function."
|
|
424
|
+
),
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _completed_structure(
|
|
429
|
+
result: object,
|
|
430
|
+
*,
|
|
431
|
+
capability: str,
|
|
432
|
+
expected_sequences: tuple[str, ...],
|
|
433
|
+
conditions: ThermodynamicConditions,
|
|
434
|
+
max_loop: int,
|
|
435
|
+
output_structure: bool,
|
|
436
|
+
) -> tuple[ConditionalAnalysis, Primer3ThermodynamicResult]:
|
|
437
|
+
validated = validate_primer3_result(
|
|
438
|
+
result,
|
|
439
|
+
capability=capability,
|
|
440
|
+
sequences_5to3=expected_sequences,
|
|
441
|
+
conditions=conditions,
|
|
442
|
+
max_loop=max_loop,
|
|
443
|
+
output_structure=output_structure,
|
|
444
|
+
error_code="MISMATCHED_PRIMER_STRUCTURE_RESULT",
|
|
445
|
+
)
|
|
446
|
+
return (
|
|
447
|
+
ConditionalAnalysis(
|
|
448
|
+
capability=capability,
|
|
449
|
+
status="execution-complete",
|
|
450
|
+
available=True,
|
|
451
|
+
backend_required=validated.backend.name,
|
|
452
|
+
automatic_probe=False,
|
|
453
|
+
automatic_execution=False,
|
|
454
|
+
execution_performed=True,
|
|
455
|
+
reason="The user explicitly supplied and executed a structure adapter.",
|
|
456
|
+
structure_found=validated.structure_found,
|
|
457
|
+
tm_celsius=validated.tm_celsius,
|
|
458
|
+
delta_g_kcal_per_mol=validated.delta_g_kcal_per_mol,
|
|
459
|
+
delta_h_kcal_per_mol=validated.delta_h_kcal_per_mol,
|
|
460
|
+
delta_s_cal_per_k_mol=validated.delta_s_cal_per_k_mol,
|
|
461
|
+
ascii_structure=validated.ascii_structure,
|
|
462
|
+
backend_name=validated.backend.name,
|
|
463
|
+
backend_version=validated.backend.version,
|
|
464
|
+
),
|
|
465
|
+
validated,
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _validate_consistent_adapter_results(
|
|
470
|
+
results: Iterable[Primer3ThermodynamicResult],
|
|
471
|
+
) -> None:
|
|
472
|
+
resolved = tuple(results)
|
|
473
|
+
if not resolved:
|
|
474
|
+
return
|
|
475
|
+
first = resolved[0]
|
|
476
|
+
if any(
|
|
477
|
+
result.backend != first.backend or result.provenance != first.provenance
|
|
478
|
+
for result in resolved[1:]
|
|
479
|
+
):
|
|
480
|
+
raise BackendExecutionError(
|
|
481
|
+
"Primer adapter returned inconsistent backend or provenance metadata.",
|
|
482
|
+
code="INCONSISTENT_PRIMER_ADAPTER_RESULTS",
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def primer_properties(
|
|
487
|
+
primer: DNASequence,
|
|
488
|
+
*,
|
|
489
|
+
paired_primer: DNASequence | None = None,
|
|
490
|
+
tm_method: str = "nearest_neighbor",
|
|
491
|
+
conditions: ThermodynamicConditions | None = None,
|
|
492
|
+
nn_config: NearestNeighborConfig | None = None,
|
|
493
|
+
structure_adapter: PrimerStructureAdapter | None = None,
|
|
494
|
+
max_loop: int = 30,
|
|
495
|
+
output_structure: bool = False,
|
|
496
|
+
) -> PrimerPropertiesResult:
|
|
497
|
+
"""Calculate GC/Tm and explicitly execute an optional structure adapter."""
|
|
498
|
+
|
|
499
|
+
symbols = require_sequence(
|
|
500
|
+
primer,
|
|
501
|
+
operation="primer property calculation",
|
|
502
|
+
max_length=60,
|
|
503
|
+
canonical=True,
|
|
504
|
+
allow_circular=False,
|
|
505
|
+
)
|
|
506
|
+
paired_symbols: str | None = None
|
|
507
|
+
paired_tm: float | None = None
|
|
508
|
+
paired_gc: float | None = None
|
|
509
|
+
if paired_primer is not None:
|
|
510
|
+
paired_symbols = require_sequence(
|
|
511
|
+
paired_primer,
|
|
512
|
+
operation="primer pair property calculation",
|
|
513
|
+
max_length=60,
|
|
514
|
+
canonical=True,
|
|
515
|
+
allow_circular=False,
|
|
516
|
+
)
|
|
517
|
+
if tm_method not in ("wallace", "nearest_neighbor", "primer3-cli"):
|
|
518
|
+
raise ConfigurationError("tm_method must be wallace, nearest_neighbor, or primer3-cli.")
|
|
519
|
+
if isinstance(max_loop, bool) or not isinstance(max_loop, int) or not 1 <= max_loop <= 30:
|
|
520
|
+
raise ConfigurationError("max_loop must be an integer in [1, 30].")
|
|
521
|
+
if not isinstance(output_structure, bool):
|
|
522
|
+
raise ConfigurationError("output_structure must be boolean.")
|
|
523
|
+
if structure_adapter is not None and any(
|
|
524
|
+
not callable(getattr(structure_adapter, name, None))
|
|
525
|
+
for name in ("hairpin", "self_dimer", "heterodimer")
|
|
526
|
+
):
|
|
527
|
+
raise ConfigurationError(
|
|
528
|
+
"structure_adapter must implement hairpin, self_dimer, and heterodimer.",
|
|
529
|
+
code="INVALID_PRIMER_STRUCTURE_ADAPTER",
|
|
530
|
+
)
|
|
531
|
+
resolved_conditions = ThermodynamicConditions() if conditions is None else conditions
|
|
532
|
+
if not isinstance(resolved_conditions, ThermodynamicConditions):
|
|
533
|
+
raise ConfigurationError(
|
|
534
|
+
"conditions must be ThermodynamicConditions or None.",
|
|
535
|
+
code="INVALID_PRIMER_CONDITIONS",
|
|
536
|
+
)
|
|
537
|
+
tm: Primer3ThermodynamicResult | MeltingTemperatureResult
|
|
538
|
+
if tm_method == "primer3-cli":
|
|
539
|
+
if structure_adapter is None or not callable(getattr(structure_adapter, "tm", None)):
|
|
540
|
+
raise ConfigurationError(
|
|
541
|
+
"tm_method='primer3-cli' requires an explicit adapter with a callable tm method.",
|
|
542
|
+
code="PRIMER3_TM_ADAPTER_REQUIRED",
|
|
543
|
+
)
|
|
544
|
+
if nn_config is not None:
|
|
545
|
+
raise ConfigurationError(
|
|
546
|
+
"nn_config is not applicable to tm_method='primer3-cli'.",
|
|
547
|
+
code="UNUSED_PRIMER_NATIVE_TM_CONFIG",
|
|
548
|
+
)
|
|
549
|
+
tm_adapter = cast(_PrimerTmAdapter, structure_adapter)
|
|
550
|
+
tm = validate_primer3_result(
|
|
551
|
+
tm_adapter.tm(primer, conditions=resolved_conditions),
|
|
552
|
+
capability="tm",
|
|
553
|
+
sequences_5to3=(symbols,),
|
|
554
|
+
conditions=resolved_conditions,
|
|
555
|
+
max_loop=None,
|
|
556
|
+
output_structure=False,
|
|
557
|
+
error_code="MISMATCHED_PRIMER_TM_RESULT",
|
|
558
|
+
)
|
|
559
|
+
else:
|
|
560
|
+
tm = melting_temperature(
|
|
561
|
+
primer,
|
|
562
|
+
method=tm_method, # type: ignore[arg-type]
|
|
563
|
+
conditions=resolved_conditions,
|
|
564
|
+
config=nn_config,
|
|
565
|
+
)
|
|
566
|
+
if paired_primer is not None and paired_symbols is not None:
|
|
567
|
+
paired_result: Primer3ThermodynamicResult | MeltingTemperatureResult
|
|
568
|
+
if tm_method == "primer3-cli":
|
|
569
|
+
assert structure_adapter is not None
|
|
570
|
+
tm_adapter = cast(_PrimerTmAdapter, structure_adapter)
|
|
571
|
+
paired_result = validate_primer3_result(
|
|
572
|
+
tm_adapter.tm(paired_primer, conditions=resolved_conditions),
|
|
573
|
+
capability="tm",
|
|
574
|
+
sequences_5to3=(paired_symbols,),
|
|
575
|
+
conditions=resolved_conditions,
|
|
576
|
+
max_loop=None,
|
|
577
|
+
output_structure=False,
|
|
578
|
+
error_code="MISMATCHED_PRIMER_TM_RESULT",
|
|
579
|
+
)
|
|
580
|
+
else:
|
|
581
|
+
paired_result = melting_temperature(
|
|
582
|
+
paired_primer,
|
|
583
|
+
method=tm_method, # type: ignore[arg-type]
|
|
584
|
+
conditions=resolved_conditions,
|
|
585
|
+
config=nn_config,
|
|
586
|
+
)
|
|
587
|
+
paired_tm = paired_result.tm_celsius
|
|
588
|
+
paired_gc = sum(base in "GC" for base in paired_symbols) / len(paired_symbols)
|
|
589
|
+
issues: tuple[Issue, ...]
|
|
590
|
+
if structure_adapter is None:
|
|
591
|
+
hairpin = _conditional_structure("hairpin")
|
|
592
|
+
self_dimer = _conditional_structure("self_dimer")
|
|
593
|
+
paired_hairpin = _conditional_structure("hairpin") if paired_primer is not None else None
|
|
594
|
+
paired_self_dimer = (
|
|
595
|
+
_conditional_structure("self_dimer") if paired_primer is not None else None
|
|
596
|
+
)
|
|
597
|
+
heterodimer = _conditional_structure("heterodimer") if paired_primer is not None else None
|
|
598
|
+
issues = (
|
|
599
|
+
Issue(
|
|
600
|
+
"PRIMER_STRUCTURE_BACKEND_REQUIRED",
|
|
601
|
+
IssueSeverity.INFO,
|
|
602
|
+
"Hairpin and dimer values require an explicitly supplied validated adapter.",
|
|
603
|
+
),
|
|
604
|
+
)
|
|
605
|
+
provenance = native_provenance()
|
|
606
|
+
else:
|
|
607
|
+
primary_hairpin_raw = structure_adapter.hairpin(
|
|
608
|
+
primer,
|
|
609
|
+
conditions=resolved_conditions,
|
|
610
|
+
max_loop=max_loop,
|
|
611
|
+
output_structure=output_structure,
|
|
612
|
+
)
|
|
613
|
+
primary_self_raw = structure_adapter.self_dimer(
|
|
614
|
+
primer,
|
|
615
|
+
conditions=resolved_conditions,
|
|
616
|
+
max_loop=max_loop,
|
|
617
|
+
output_structure=output_structure,
|
|
618
|
+
)
|
|
619
|
+
hairpin, primary_hairpin_result = _completed_structure(
|
|
620
|
+
primary_hairpin_raw,
|
|
621
|
+
capability="hairpin",
|
|
622
|
+
expected_sequences=(symbols,),
|
|
623
|
+
conditions=resolved_conditions,
|
|
624
|
+
max_loop=max_loop,
|
|
625
|
+
output_structure=output_structure,
|
|
626
|
+
)
|
|
627
|
+
self_dimer, primary_self_result = _completed_structure(
|
|
628
|
+
primary_self_raw,
|
|
629
|
+
capability="self_dimer",
|
|
630
|
+
expected_sequences=(symbols,),
|
|
631
|
+
conditions=resolved_conditions,
|
|
632
|
+
max_loop=max_loop,
|
|
633
|
+
output_structure=output_structure,
|
|
634
|
+
)
|
|
635
|
+
paired_hairpin = None
|
|
636
|
+
paired_self_dimer = None
|
|
637
|
+
heterodimer = None
|
|
638
|
+
completed_results = [primary_hairpin_result, primary_self_result]
|
|
639
|
+
if paired_primer is not None and paired_symbols is not None:
|
|
640
|
+
paired_hairpin, paired_hairpin_result = _completed_structure(
|
|
641
|
+
structure_adapter.hairpin(
|
|
642
|
+
paired_primer,
|
|
643
|
+
conditions=resolved_conditions,
|
|
644
|
+
max_loop=max_loop,
|
|
645
|
+
output_structure=output_structure,
|
|
646
|
+
),
|
|
647
|
+
capability="hairpin",
|
|
648
|
+
expected_sequences=(paired_symbols,),
|
|
649
|
+
conditions=resolved_conditions,
|
|
650
|
+
max_loop=max_loop,
|
|
651
|
+
output_structure=output_structure,
|
|
652
|
+
)
|
|
653
|
+
paired_self_dimer, paired_self_result = _completed_structure(
|
|
654
|
+
structure_adapter.self_dimer(
|
|
655
|
+
paired_primer,
|
|
656
|
+
conditions=resolved_conditions,
|
|
657
|
+
max_loop=max_loop,
|
|
658
|
+
output_structure=output_structure,
|
|
659
|
+
),
|
|
660
|
+
capability="self_dimer",
|
|
661
|
+
expected_sequences=(paired_symbols,),
|
|
662
|
+
conditions=resolved_conditions,
|
|
663
|
+
max_loop=max_loop,
|
|
664
|
+
output_structure=output_structure,
|
|
665
|
+
)
|
|
666
|
+
heterodimer, heterodimer_result = _completed_structure(
|
|
667
|
+
structure_adapter.heterodimer(
|
|
668
|
+
primer,
|
|
669
|
+
paired_primer,
|
|
670
|
+
conditions=resolved_conditions,
|
|
671
|
+
max_loop=max_loop,
|
|
672
|
+
output_structure=output_structure,
|
|
673
|
+
),
|
|
674
|
+
capability="heterodimer",
|
|
675
|
+
expected_sequences=(symbols, paired_symbols),
|
|
676
|
+
conditions=resolved_conditions,
|
|
677
|
+
max_loop=max_loop,
|
|
678
|
+
output_structure=output_structure,
|
|
679
|
+
)
|
|
680
|
+
completed_results.extend(
|
|
681
|
+
(paired_hairpin_result, paired_self_result, heterodimer_result)
|
|
682
|
+
)
|
|
683
|
+
if tm_method == "primer3-cli":
|
|
684
|
+
if not isinstance(tm, Primer3ThermodynamicResult):
|
|
685
|
+
raise AssertionError("Primer3 Tm branch must produce a Primer3 result.")
|
|
686
|
+
completed_results.append(tm)
|
|
687
|
+
if paired_primer is not None and paired_symbols is not None:
|
|
688
|
+
if not isinstance(paired_result, Primer3ThermodynamicResult):
|
|
689
|
+
raise AssertionError("Primer3 paired Tm must produce a Primer3 result.")
|
|
690
|
+
completed_results.append(paired_result)
|
|
691
|
+
_validate_consistent_adapter_results(completed_results)
|
|
692
|
+
issues = ()
|
|
693
|
+
provenance = primary_hairpin_raw.provenance
|
|
694
|
+
return PrimerPropertiesResult(
|
|
695
|
+
primer_sequence=symbols,
|
|
696
|
+
gc_fraction=sum(base in "GC" for base in symbols) / len(symbols),
|
|
697
|
+
tm_celsius=tm.tm_celsius,
|
|
698
|
+
tm_method=tm.method,
|
|
699
|
+
hairpin=hairpin,
|
|
700
|
+
self_dimer=self_dimer,
|
|
701
|
+
heterodimer=heterodimer,
|
|
702
|
+
paired_primer_sequence=paired_symbols,
|
|
703
|
+
paired_gc_fraction=paired_gc,
|
|
704
|
+
paired_tm_celsius=paired_tm,
|
|
705
|
+
paired_hairpin=paired_hairpin,
|
|
706
|
+
paired_self_dimer=paired_self_dimer,
|
|
707
|
+
method="native_gc_tm_with_explicit_structure_adapter",
|
|
708
|
+
algorithm_version="dnakit-primer-properties-v2",
|
|
709
|
+
parameters=freeze_parameters(
|
|
710
|
+
{
|
|
711
|
+
"tm_method": tm_method,
|
|
712
|
+
"conditions": tm.conditions.to_dict()
|
|
713
|
+
if hasattr(tm.conditions, "to_dict")
|
|
714
|
+
else {
|
|
715
|
+
"temperature_celsius": tm.conditions.temperature_celsius,
|
|
716
|
+
"sodium_molar": tm.conditions.sodium_molar,
|
|
717
|
+
"potassium_molar": tm.conditions.potassium_molar,
|
|
718
|
+
"magnesium_molar": tm.conditions.magnesium_molar,
|
|
719
|
+
"dntp_molar": tm.conditions.dntp_molar,
|
|
720
|
+
"strand_concentration_molar": tm.conditions.strand_concentration_molar,
|
|
721
|
+
"dmso_percent": tm.conditions.dmso_percent,
|
|
722
|
+
"dmso_factor_celsius_per_percent": (
|
|
723
|
+
tm.conditions.dmso_factor_celsius_per_percent
|
|
724
|
+
),
|
|
725
|
+
"formamide_molar": tm.conditions.formamide_molar,
|
|
726
|
+
"salt_model": tm.conditions.salt_model,
|
|
727
|
+
},
|
|
728
|
+
"hairpin_backend_executed": structure_adapter is not None,
|
|
729
|
+
"dimer_backend_executed": structure_adapter is not None,
|
|
730
|
+
"structure_adapter_supplied": structure_adapter is not None,
|
|
731
|
+
"automatic_backend_probe": False,
|
|
732
|
+
"automatic_backend_execution": False,
|
|
733
|
+
"max_loop": max_loop,
|
|
734
|
+
"output_structure": output_structure,
|
|
735
|
+
}
|
|
736
|
+
),
|
|
737
|
+
provenance=provenance,
|
|
738
|
+
issues=issues,
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def prepare_primer_design(
|
|
743
|
+
template: DNASequence,
|
|
744
|
+
*,
|
|
745
|
+
target_start: int,
|
|
746
|
+
target_end: int,
|
|
747
|
+
primer_length_range: tuple[int, int] = (18, 25),
|
|
748
|
+
tm_range_celsius: tuple[float, float] = (57.0, 63.0),
|
|
749
|
+
gc_range: tuple[float, float] = (0.4, 0.6),
|
|
750
|
+
product_length_range: tuple[int, int] = (100, 1_000),
|
|
751
|
+
excluded_regions: object = (),
|
|
752
|
+
candidate_count: int = 5,
|
|
753
|
+
backend_name: str = "primer3",
|
|
754
|
+
conditions: ThermodynamicConditions | None = None,
|
|
755
|
+
max_excluded_regions: int = 10_000,
|
|
756
|
+
max_template_length: int = 10_000_000,
|
|
757
|
+
) -> PrimerDesignInterfaceResult:
|
|
758
|
+
"""Validate and serialize a backend-neutral primer-design request only."""
|
|
759
|
+
|
|
760
|
+
symbols = require_sequence(
|
|
761
|
+
template,
|
|
762
|
+
operation="primer design request",
|
|
763
|
+
max_length=max_template_length,
|
|
764
|
+
canonical=True,
|
|
765
|
+
allow_circular=False,
|
|
766
|
+
)
|
|
767
|
+
if (
|
|
768
|
+
isinstance(target_start, bool)
|
|
769
|
+
or not isinstance(target_start, int)
|
|
770
|
+
or isinstance(target_end, bool)
|
|
771
|
+
or not isinstance(target_end, int)
|
|
772
|
+
or not 0 <= target_start < target_end <= len(symbols)
|
|
773
|
+
):
|
|
774
|
+
raise ConfigurationError("Target must be a valid 0-based half-open template interval.")
|
|
775
|
+
|
|
776
|
+
def pair(value: object, name: str) -> tuple[object, object]:
|
|
777
|
+
if isinstance(value, (str, bytes)) or not isinstance(value, Iterable):
|
|
778
|
+
raise ConfigurationError(f"{name} must contain exactly two values.")
|
|
779
|
+
items = tuple(islice(iter(value), 3))
|
|
780
|
+
if len(items) != 2:
|
|
781
|
+
raise ConfigurationError(f"{name} must contain exactly two values.")
|
|
782
|
+
return items[0], items[1]
|
|
783
|
+
|
|
784
|
+
raw_primer_min, raw_primer_max = pair(primer_length_range, "primer_length_range")
|
|
785
|
+
raw_product_min, raw_product_max = pair(product_length_range, "product_length_range")
|
|
786
|
+
primer_min = validate_positive_int(raw_primer_min, "minimum primer length", maximum=1_000)
|
|
787
|
+
primer_max = validate_positive_int(raw_primer_max, "maximum primer length", maximum=1_000)
|
|
788
|
+
product_min = validate_positive_int(
|
|
789
|
+
raw_product_min, "minimum product length", maximum=100_000_000
|
|
790
|
+
)
|
|
791
|
+
product_max = validate_positive_int(
|
|
792
|
+
raw_product_max, "maximum product length", maximum=100_000_000
|
|
793
|
+
)
|
|
794
|
+
if primer_min > primer_max or product_min > product_max:
|
|
795
|
+
raise ConfigurationError("Length range lower bounds cannot exceed upper bounds.")
|
|
796
|
+
tm_min, tm_max = pair(tm_range_celsius, "tm_range_celsius")
|
|
797
|
+
if (
|
|
798
|
+
isinstance(tm_min, bool)
|
|
799
|
+
or not isinstance(tm_min, (int, float))
|
|
800
|
+
or isinstance(tm_max, bool)
|
|
801
|
+
or not isinstance(tm_max, (int, float))
|
|
802
|
+
):
|
|
803
|
+
raise ConfigurationError("Tm range bounds must be numeric.")
|
|
804
|
+
resolved_tm_min, resolved_tm_max = float(tm_min), float(tm_max)
|
|
805
|
+
if not -100.0 <= resolved_tm_min <= resolved_tm_max <= 200.0:
|
|
806
|
+
raise ConfigurationError("Tm range is invalid or outside the request boundary.")
|
|
807
|
+
raw_gc_min, raw_gc_max = pair(gc_range, "gc_range")
|
|
808
|
+
gc_min, gc_max = (
|
|
809
|
+
finite_fraction(raw_gc_min, "minimum GC"),
|
|
810
|
+
finite_fraction(raw_gc_max, "maximum GC"),
|
|
811
|
+
)
|
|
812
|
+
if gc_min > gc_max:
|
|
813
|
+
raise ConfigurationError("Minimum GC cannot exceed maximum GC.")
|
|
814
|
+
validate_positive_int(candidate_count, "candidate_count", maximum=10_000)
|
|
815
|
+
validate_positive_int(max_excluded_regions, "max_excluded_regions", maximum=1_000_000)
|
|
816
|
+
if not isinstance(backend_name, str) or not backend_name.strip():
|
|
817
|
+
raise ConfigurationError("backend_name must be non-empty.")
|
|
818
|
+
if isinstance(excluded_regions, (str, bytes)) or not isinstance(excluded_regions, Iterable):
|
|
819
|
+
raise ConfigurationError("excluded_regions must be an iterable of intervals.")
|
|
820
|
+
region_values = tuple(islice(iter(excluded_regions), max_excluded_regions + 1))
|
|
821
|
+
if len(region_values) > max_excluded_regions:
|
|
822
|
+
raise ConfigurationError("excluded_regions exceeds max_excluded_regions.")
|
|
823
|
+
regions: list[tuple[int, int]] = []
|
|
824
|
+
for region in region_values:
|
|
825
|
+
if (
|
|
826
|
+
not isinstance(region, tuple)
|
|
827
|
+
or len(region) != 2
|
|
828
|
+
or any(isinstance(item, bool) or not isinstance(item, int) for item in region)
|
|
829
|
+
or not 0 <= region[0] < region[1] <= len(symbols)
|
|
830
|
+
):
|
|
831
|
+
raise ConfigurationError("Each excluded region must be a valid template interval.")
|
|
832
|
+
regions.append(region)
|
|
833
|
+
resolved_conditions = ThermodynamicConditions() if conditions is None else conditions
|
|
834
|
+
if not isinstance(resolved_conditions, ThermodynamicConditions):
|
|
835
|
+
raise ConfigurationError(
|
|
836
|
+
"conditions must be ThermodynamicConditions or None.",
|
|
837
|
+
code="INVALID_PRIMER_CONDITIONS",
|
|
838
|
+
)
|
|
839
|
+
condition_values = freeze_parameters(
|
|
840
|
+
{
|
|
841
|
+
"temperature_celsius": resolved_conditions.temperature_celsius,
|
|
842
|
+
"sodium_molar": resolved_conditions.sodium_molar,
|
|
843
|
+
"potassium_molar": resolved_conditions.potassium_molar,
|
|
844
|
+
"magnesium_molar": resolved_conditions.magnesium_molar,
|
|
845
|
+
"dntp_molar": resolved_conditions.dntp_molar,
|
|
846
|
+
"strand_concentration_molar": resolved_conditions.strand_concentration_molar,
|
|
847
|
+
"dmso_percent": resolved_conditions.dmso_percent,
|
|
848
|
+
"dmso_factor_celsius_per_percent": (
|
|
849
|
+
resolved_conditions.dmso_factor_celsius_per_percent
|
|
850
|
+
),
|
|
851
|
+
"formamide_molar": resolved_conditions.formamide_molar,
|
|
852
|
+
"salt_model": resolved_conditions.salt_model,
|
|
853
|
+
}
|
|
854
|
+
)
|
|
855
|
+
request = PrimerDesignRequest(
|
|
856
|
+
template=template,
|
|
857
|
+
target_start=target_start,
|
|
858
|
+
target_end=target_end,
|
|
859
|
+
primer_length_range=(primer_min, primer_max),
|
|
860
|
+
tm_range_celsius=(resolved_tm_min, resolved_tm_max),
|
|
861
|
+
gc_range=(gc_min, gc_max),
|
|
862
|
+
product_length_range=(product_min, product_max),
|
|
863
|
+
excluded_regions=tuple(regions),
|
|
864
|
+
candidate_count=candidate_count,
|
|
865
|
+
thermodynamic_conditions=condition_values,
|
|
866
|
+
)
|
|
867
|
+
return PrimerDesignInterfaceResult(
|
|
868
|
+
request=request,
|
|
869
|
+
backend_name=backend_name,
|
|
870
|
+
status="request-ready-execution-not-performed",
|
|
871
|
+
execution_performed=False,
|
|
872
|
+
candidates=(),
|
|
873
|
+
reason=(
|
|
874
|
+
"DNAKit validates a neutral request but does not install, probe, or execute "
|
|
875
|
+
"an external primer-design backend in this interface."
|
|
876
|
+
),
|
|
877
|
+
method="backend_neutral_primer_design_request",
|
|
878
|
+
algorithm_version="dnakit-primer-design-interface-v1",
|
|
879
|
+
parameters=freeze_parameters(
|
|
880
|
+
{
|
|
881
|
+
"backend_name": backend_name,
|
|
882
|
+
"automatic_install": False,
|
|
883
|
+
"automatic_probe": False,
|
|
884
|
+
"automatic_execution": False,
|
|
885
|
+
"max_excluded_regions": max_excluded_regions,
|
|
886
|
+
"max_template_length": max_template_length,
|
|
887
|
+
}
|
|
888
|
+
),
|
|
889
|
+
provenance=adapter_provenance(reference_name="Primer3-compatible interface"),
|
|
890
|
+
issues=(),
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
__all__ = [
|
|
895
|
+
"match_primer",
|
|
896
|
+
"prepare_primer_design",
|
|
897
|
+
"primer_properties",
|
|
898
|
+
"simulate_pcr",
|
|
899
|
+
]
|