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
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
"""Strict native physicochemical and thermodynamic calculations for canonical DNA."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from dnakit.core import (
|
|
9
|
+
Citation,
|
|
10
|
+
DNASequence,
|
|
11
|
+
ExecutionMode,
|
|
12
|
+
ImplementationInfo,
|
|
13
|
+
ImplementationLabel,
|
|
14
|
+
OriginClass,
|
|
15
|
+
Provenance,
|
|
16
|
+
Strandedness,
|
|
17
|
+
)
|
|
18
|
+
from dnakit.core._json import FrozenDict
|
|
19
|
+
from dnakit.exceptions import ConfigurationError
|
|
20
|
+
|
|
21
|
+
from ._shared import canonical_linear_symbols, native_provenance
|
|
22
|
+
from .backends import Primer3CLIAdapter, validate_primer3_result
|
|
23
|
+
from .config import NearestNeighborConfig, ThermodynamicConditions, TmMethod
|
|
24
|
+
from .parameters import PARAMETER_SETS, NearestNeighborParameterSet
|
|
25
|
+
from .results import (
|
|
26
|
+
DuplexStabilityResult,
|
|
27
|
+
ExtinctionCoefficientResult,
|
|
28
|
+
MeltingTemperatureResult,
|
|
29
|
+
MolecularWeightResult,
|
|
30
|
+
NearestNeighborResult,
|
|
31
|
+
SaltCorrectionResult,
|
|
32
|
+
StackingResult,
|
|
33
|
+
StackingStep,
|
|
34
|
+
WindowTmPoint,
|
|
35
|
+
WindowTmResult,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_BASE_MASSES = {"A": 313.21, "C": 289.18, "G": 329.21, "T": 304.20}
|
|
39
|
+
_BASE_MASSES_DALTON = FrozenDict(_BASE_MASSES)
|
|
40
|
+
_UNPHOSPHORYLATED_TERMINAL_CORRECTION_DALTON = -61.96
|
|
41
|
+
_FIVE_PRIME_PHOSPHATE_ADDITION_DALTON = 79.0
|
|
42
|
+
_INDIVIDUAL_BASE_EXTINCTION_260_VALUES = {
|
|
43
|
+
"A": 15_400.0,
|
|
44
|
+
"C": 7_400.0,
|
|
45
|
+
"G": 11_500.0,
|
|
46
|
+
"T": 8_700.0,
|
|
47
|
+
}
|
|
48
|
+
_INDIVIDUAL_BASE_EXTINCTION_260 = FrozenDict(_INDIVIDUAL_BASE_EXTINCTION_260_VALUES)
|
|
49
|
+
_NEAREST_NEIGHBOR_EXTINCTION_260_VALUES = {
|
|
50
|
+
"AA": 27_400.0,
|
|
51
|
+
"AC": 21_200.0,
|
|
52
|
+
"AG": 25_000.0,
|
|
53
|
+
"AT": 22_800.0,
|
|
54
|
+
"CA": 21_200.0,
|
|
55
|
+
"CC": 14_600.0,
|
|
56
|
+
"CG": 18_000.0,
|
|
57
|
+
"CT": 15_200.0,
|
|
58
|
+
"GA": 25_200.0,
|
|
59
|
+
"GC": 17_600.0,
|
|
60
|
+
"GG": 21_600.0,
|
|
61
|
+
"GT": 20_000.0,
|
|
62
|
+
"TA": 23_400.0,
|
|
63
|
+
"TC": 16_200.0,
|
|
64
|
+
"TG": 19_000.0,
|
|
65
|
+
"TT": 16_800.0,
|
|
66
|
+
}
|
|
67
|
+
_NEAREST_NEIGHBOR_EXTINCTION_260 = FrozenDict(_NEAREST_NEIGHBOR_EXTINCTION_260_VALUES)
|
|
68
|
+
_GAS_CONSTANT_CAL_PER_K_MOL = 1.9872
|
|
69
|
+
_COMPLEMENT = str.maketrans("ACGT", "TGCA")
|
|
70
|
+
_NATIVE_APPLICABILITY = (
|
|
71
|
+
"Linear, ungapped, canonical DNA; fully complementary duplex; 2-60 nt; "
|
|
72
|
+
"SantaLucia 1998 unified DNA/DNA parameters; total monovalent Na+ plus K+; "
|
|
73
|
+
"no mismatches, dangling ends, Mg2+, dNTP, cosolvent, or modification model."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _conditions(value: ThermodynamicConditions | None) -> ThermodynamicConditions:
|
|
78
|
+
resolved = ThermodynamicConditions() if value is None else value
|
|
79
|
+
if not isinstance(resolved, ThermodynamicConditions):
|
|
80
|
+
raise ConfigurationError(
|
|
81
|
+
"conditions must be ThermodynamicConditions or None.",
|
|
82
|
+
code="INVALID_THERMODYNAMIC_CONDITIONS",
|
|
83
|
+
)
|
|
84
|
+
return resolved
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _nn_config(value: NearestNeighborConfig | None) -> NearestNeighborConfig:
|
|
88
|
+
resolved = NearestNeighborConfig() if value is None else value
|
|
89
|
+
if not isinstance(resolved, NearestNeighborConfig):
|
|
90
|
+
raise ConfigurationError(
|
|
91
|
+
"config must be NearestNeighborConfig or None.",
|
|
92
|
+
code="INVALID_NEAREST_NEIGHBOR_CONFIG",
|
|
93
|
+
)
|
|
94
|
+
return resolved
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _require_native_ions(conditions: ThermodynamicConditions) -> None:
|
|
98
|
+
if (
|
|
99
|
+
conditions.magnesium_molar != 0.0
|
|
100
|
+
or conditions.dntp_molar != 0.0
|
|
101
|
+
or conditions.dmso_percent != 0.0
|
|
102
|
+
or conditions.formamide_molar != 0.0
|
|
103
|
+
):
|
|
104
|
+
raise ConfigurationError(
|
|
105
|
+
"The native model does not combine Mg2+, dNTP, DMSO, or formamide with its "
|
|
106
|
+
"monovalent correction.",
|
|
107
|
+
code="UNSUPPORTED_NATIVE_ION_MODEL",
|
|
108
|
+
context={
|
|
109
|
+
"magnesium_molar": conditions.magnesium_molar,
|
|
110
|
+
"dntp_molar": conditions.dntp_molar,
|
|
111
|
+
"dmso_percent": conditions.dmso_percent,
|
|
112
|
+
"formamide_molar": conditions.formamide_molar,
|
|
113
|
+
},
|
|
114
|
+
hint=(
|
|
115
|
+
"Use zero Mg2+/dNTP/cosolvents, an explicit empirical correction, "
|
|
116
|
+
"or a validated external backend."
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _reverse_complement(symbols: str) -> str:
|
|
122
|
+
return symbols.translate(_COMPLEMENT)[::-1]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _complement_3to5(symbols: str) -> str:
|
|
126
|
+
return symbols.translate(_COMPLEMENT)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def molecular_weight(
|
|
130
|
+
sequence: DNASequence,
|
|
131
|
+
*,
|
|
132
|
+
strand: Literal["single", "double"] = "single",
|
|
133
|
+
five_prime_phosphorylated: bool = False,
|
|
134
|
+
max_sequence_length: int = 1_000_000,
|
|
135
|
+
) -> MolecularWeightResult:
|
|
136
|
+
"""Calculate anhydrous unmodified DNA oligonucleotide molecular weight."""
|
|
137
|
+
|
|
138
|
+
if strand not in {"single", "double"}:
|
|
139
|
+
raise ConfigurationError(
|
|
140
|
+
"strand must be 'single' or 'double'.",
|
|
141
|
+
code="INVALID_MOLECULAR_WEIGHT_STRAND",
|
|
142
|
+
)
|
|
143
|
+
if not isinstance(five_prime_phosphorylated, bool):
|
|
144
|
+
raise ConfigurationError(
|
|
145
|
+
"five_prime_phosphorylated must be a boolean.",
|
|
146
|
+
code="INVALID_TERMINAL_MODIFICATION",
|
|
147
|
+
)
|
|
148
|
+
if (
|
|
149
|
+
isinstance(max_sequence_length, bool)
|
|
150
|
+
or not isinstance(max_sequence_length, int)
|
|
151
|
+
or not 1 <= max_sequence_length <= 10_000_000
|
|
152
|
+
):
|
|
153
|
+
raise ConfigurationError(
|
|
154
|
+
"max_sequence_length must be an integer in [1, 10000000].",
|
|
155
|
+
code="INVALID_THERMODYNAMIC_LIMIT",
|
|
156
|
+
)
|
|
157
|
+
symbols = canonical_linear_symbols(
|
|
158
|
+
sequence,
|
|
159
|
+
operation="molecular_weight",
|
|
160
|
+
min_length=1,
|
|
161
|
+
max_length=max_sequence_length,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def strand_mass(strand_symbols: str) -> float:
|
|
165
|
+
value = math.fsum(_BASE_MASSES[base] for base in strand_symbols)
|
|
166
|
+
value += _UNPHOSPHORYLATED_TERMINAL_CORRECTION_DALTON
|
|
167
|
+
if five_prime_phosphorylated:
|
|
168
|
+
value += _FIVE_PRIME_PHOSPHATE_ADDITION_DALTON
|
|
169
|
+
return value
|
|
170
|
+
|
|
171
|
+
value = strand_mass(symbols)
|
|
172
|
+
strand_count = 1 if strand == "single" else 2
|
|
173
|
+
if strand == "double":
|
|
174
|
+
value += strand_mass(_reverse_complement(symbols))
|
|
175
|
+
return MolecularWeightResult(
|
|
176
|
+
value_dalton=value,
|
|
177
|
+
value_kilodalton=value / 1000.0,
|
|
178
|
+
strand_count=strand_count,
|
|
179
|
+
sequence_length=len(symbols),
|
|
180
|
+
five_prime_phosphorylated=five_prime_phosphorylated,
|
|
181
|
+
method="anhydrous-deoxynucleotide-residue-sum",
|
|
182
|
+
algorithm_version="dnakit-molecular-weight-v1",
|
|
183
|
+
applicability=(
|
|
184
|
+
"Linear, ungapped, canonical, unmodified DNA with hydroxyl termini; "
|
|
185
|
+
"optional 5-prime monophosphate. Double strand adds the complete "
|
|
186
|
+
"Watson-Crick reverse-complement strand."
|
|
187
|
+
),
|
|
188
|
+
parameters=FrozenDict(
|
|
189
|
+
{
|
|
190
|
+
"base_masses_dalton": _BASE_MASSES_DALTON,
|
|
191
|
+
"unphosphorylated_terminal_correction_dalton": (
|
|
192
|
+
_UNPHOSPHORYLATED_TERMINAL_CORRECTION_DALTON
|
|
193
|
+
),
|
|
194
|
+
"five_prime_phosphate_addition_dalton": (_FIVE_PRIME_PHOSPHATE_ADDITION_DALTON),
|
|
195
|
+
"mass_table_version": "anhydrous-dna-oligo-residues-v1",
|
|
196
|
+
"mass_table_units": "Da per residue",
|
|
197
|
+
"mass_table_source": (
|
|
198
|
+
"Standard anhydrous DNA oligonucleotide residue formula: "
|
|
199
|
+
"sum(dA,dC,dG,dT) - 61.96 Da"
|
|
200
|
+
),
|
|
201
|
+
}
|
|
202
|
+
),
|
|
203
|
+
provenance=native_provenance(citation=False),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def extinction_coefficient_260nm(
|
|
208
|
+
sequence: DNASequence,
|
|
209
|
+
*,
|
|
210
|
+
max_sequence_length: int = 1_000_000,
|
|
211
|
+
) -> ExtinctionCoefficientResult:
|
|
212
|
+
"""Calculate the theoretical molar extinction coefficient of an ssDNA oligo at 260 nm."""
|
|
213
|
+
|
|
214
|
+
if (
|
|
215
|
+
isinstance(max_sequence_length, bool)
|
|
216
|
+
or not isinstance(max_sequence_length, int)
|
|
217
|
+
or not 1 <= max_sequence_length <= 10_000_000
|
|
218
|
+
):
|
|
219
|
+
raise ConfigurationError(
|
|
220
|
+
"max_sequence_length must be an integer in [1, 10000000].",
|
|
221
|
+
code="INVALID_THERMODYNAMIC_LIMIT",
|
|
222
|
+
)
|
|
223
|
+
symbols = canonical_linear_symbols(
|
|
224
|
+
sequence,
|
|
225
|
+
operation="extinction_coefficient_260nm",
|
|
226
|
+
min_length=1,
|
|
227
|
+
max_length=max_sequence_length,
|
|
228
|
+
)
|
|
229
|
+
if sequence.strandedness is not Strandedness.SINGLE:
|
|
230
|
+
raise ConfigurationError(
|
|
231
|
+
"extinction_coefficient_260nm requires a single-stranded DNA sequence.",
|
|
232
|
+
code="EXTINCTION_COEFFICIENT_SINGLE_STRAND_ONLY",
|
|
233
|
+
hint=(
|
|
234
|
+
"Calculate each unhybridized oligonucleotide strand separately; "
|
|
235
|
+
"duplex absorbance requires a duplex-specific hypochromicity model."
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
if len(symbols) == 1:
|
|
240
|
+
value = _INDIVIDUAL_BASE_EXTINCTION_260_VALUES[symbols]
|
|
241
|
+
else:
|
|
242
|
+
dinucleotide_sum = math.fsum(
|
|
243
|
+
_NEAREST_NEIGHBOR_EXTINCTION_260_VALUES[symbols[index : index + 2]]
|
|
244
|
+
for index in range(len(symbols) - 1)
|
|
245
|
+
)
|
|
246
|
+
internal_base_sum = math.fsum(
|
|
247
|
+
_INDIVIDUAL_BASE_EXTINCTION_260_VALUES[base] for base in symbols[1:-1]
|
|
248
|
+
)
|
|
249
|
+
value = dinucleotide_sum - internal_base_sum
|
|
250
|
+
|
|
251
|
+
provenance = Provenance(
|
|
252
|
+
implementation=ImplementationInfo(
|
|
253
|
+
label=ImplementationLabel.REIMPLEMENTATION,
|
|
254
|
+
execution_mode=ExecutionMode.INTERNAL,
|
|
255
|
+
origin_class=OriginClass.PUBLISHED_ALGORITHM,
|
|
256
|
+
citations=(
|
|
257
|
+
Citation(
|
|
258
|
+
"warshaw-tinoco1966",
|
|
259
|
+
title="Optical properties of sixteen dinucleoside phosphates",
|
|
260
|
+
doi="10.1016/0022-2836(66)90115-X",
|
|
261
|
+
),
|
|
262
|
+
Citation(
|
|
263
|
+
"cantor-warshaw-shapiro1970",
|
|
264
|
+
title=(
|
|
265
|
+
"Oligonucleotide interactions. III. Circular dichroism studies "
|
|
266
|
+
"of the conformation of deoxyoligonucleotides"
|
|
267
|
+
),
|
|
268
|
+
doi="10.1002/bip.1970.360090909",
|
|
269
|
+
),
|
|
270
|
+
),
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
return ExtinctionCoefficientResult(
|
|
274
|
+
value_m_inverse_cm_inverse=value,
|
|
275
|
+
wavelength_nm=260,
|
|
276
|
+
sequence_length=len(symbols),
|
|
277
|
+
method="nearest-neighbor-hypochromicity",
|
|
278
|
+
algorithm_version="dnakit-extinction-coefficient-260nm-v1",
|
|
279
|
+
applicability=(
|
|
280
|
+
"Linear, ungapped, canonical, single-stranded, unmodified DNA at 260 nm. "
|
|
281
|
+
"The result is theoretical and does not replace an experimental A260 measurement; "
|
|
282
|
+
"fluorophores and other modifications require separate correction."
|
|
283
|
+
),
|
|
284
|
+
parameters=FrozenDict(
|
|
285
|
+
{
|
|
286
|
+
"parameter_set": "warshaw-tinoco-cantor-dna-260nm-v1",
|
|
287
|
+
"formula": "sum(pair_i, i=1..N-1) - sum(base_i, i=2..N-1)",
|
|
288
|
+
"reference_temperature_celsius": 25.0,
|
|
289
|
+
"reference_ph": 7.0,
|
|
290
|
+
"individual_base_m_inverse_cm_inverse": (_INDIVIDUAL_BASE_EXTINCTION_260),
|
|
291
|
+
"nearest_neighbor_m_inverse_cm_inverse": (_NEAREST_NEIGHBOR_EXTINCTION_260),
|
|
292
|
+
"single_nucleotide_rule": "use individual-base coefficient",
|
|
293
|
+
}
|
|
294
|
+
),
|
|
295
|
+
provenance=provenance,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def salt_correction(
|
|
300
|
+
sequence_length: int,
|
|
301
|
+
*,
|
|
302
|
+
conditions: ThermodynamicConditions | None = None,
|
|
303
|
+
) -> SaltCorrectionResult:
|
|
304
|
+
"""Return the SantaLucia 1998 monovalent entropy correction."""
|
|
305
|
+
|
|
306
|
+
resolved = _conditions(conditions)
|
|
307
|
+
_require_native_ions(resolved)
|
|
308
|
+
if isinstance(sequence_length, bool) or not isinstance(sequence_length, int):
|
|
309
|
+
raise ConfigurationError(
|
|
310
|
+
"sequence_length must be an integer.",
|
|
311
|
+
code="INVALID_THERMODYNAMIC_SEQUENCE_LENGTH",
|
|
312
|
+
)
|
|
313
|
+
if not 2 <= sequence_length <= 60:
|
|
314
|
+
raise ConfigurationError(
|
|
315
|
+
"Native salt correction is limited to sequence lengths from 2 to 60 nt.",
|
|
316
|
+
code="THERMODYNAMIC_SEQUENCE_LENGTH_OUT_OF_DOMAIN",
|
|
317
|
+
context={"sequence_length": sequence_length},
|
|
318
|
+
)
|
|
319
|
+
correction = 0.368 * (sequence_length - 1) * math.log(resolved.monovalent_molar)
|
|
320
|
+
return SaltCorrectionResult(
|
|
321
|
+
delta_s_cal_per_k_mol=correction,
|
|
322
|
+
sequence_length=sequence_length,
|
|
323
|
+
model=resolved.salt_model,
|
|
324
|
+
model_version="santalucia1998-equation-v1",
|
|
325
|
+
conditions=resolved,
|
|
326
|
+
applicability=(
|
|
327
|
+
"Total monovalent Na+ plus K+ correction; length 2-60 nt; "
|
|
328
|
+
"Mg2+/dNTP/cosolvents excluded."
|
|
329
|
+
),
|
|
330
|
+
provenance=native_provenance(),
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _stacking_steps(
|
|
335
|
+
symbols: str,
|
|
336
|
+
*,
|
|
337
|
+
temperature_celsius: float,
|
|
338
|
+
parameter_set: NearestNeighborParameterSet,
|
|
339
|
+
) -> tuple[StackingStep, ...]:
|
|
340
|
+
temperature_kelvin = temperature_celsius + 273.15
|
|
341
|
+
steps: list[StackingStep] = []
|
|
342
|
+
complement = _complement_3to5(symbols)
|
|
343
|
+
for index in range(len(symbols) - 1):
|
|
344
|
+
top = symbols[index : index + 2]
|
|
345
|
+
parameter = parameter_set.stacking[top]
|
|
346
|
+
delta_g = parameter.delta_h_kcal_per_mol - (
|
|
347
|
+
temperature_kelvin * parameter.delta_s_cal_per_k_mol / 1000.0
|
|
348
|
+
)
|
|
349
|
+
steps.append(
|
|
350
|
+
StackingStep(
|
|
351
|
+
index=index,
|
|
352
|
+
top_5to3=top,
|
|
353
|
+
bottom_3to5=complement[index : index + 2],
|
|
354
|
+
delta_h_kcal_per_mol=parameter.delta_h_kcal_per_mol,
|
|
355
|
+
delta_s_cal_per_k_mol=parameter.delta_s_cal_per_k_mol,
|
|
356
|
+
delta_g_kcal_per_mol=delta_g,
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
return tuple(steps)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def stacking_interactions(
|
|
363
|
+
sequence: DNASequence,
|
|
364
|
+
*,
|
|
365
|
+
temperature_celsius: float = 37.0,
|
|
366
|
+
config: NearestNeighborConfig | None = None,
|
|
367
|
+
) -> StackingResult:
|
|
368
|
+
"""Return each canonical nearest-neighbor stacking contribution."""
|
|
369
|
+
|
|
370
|
+
resolved_config = _nn_config(config)
|
|
371
|
+
temperature = ThermodynamicConditions(
|
|
372
|
+
temperature_celsius=temperature_celsius,
|
|
373
|
+
sodium_molar=1.0,
|
|
374
|
+
).temperature_celsius
|
|
375
|
+
symbols = canonical_linear_symbols(
|
|
376
|
+
sequence,
|
|
377
|
+
operation="stacking_interactions",
|
|
378
|
+
min_length=2,
|
|
379
|
+
max_length=resolved_config.max_sequence_length,
|
|
380
|
+
)
|
|
381
|
+
parameter_set = PARAMETER_SETS[resolved_config.parameter_set]
|
|
382
|
+
steps = _stacking_steps(
|
|
383
|
+
symbols,
|
|
384
|
+
temperature_celsius=temperature,
|
|
385
|
+
parameter_set=parameter_set,
|
|
386
|
+
)
|
|
387
|
+
delta_h = math.fsum(step.delta_h_kcal_per_mol for step in steps)
|
|
388
|
+
delta_s = math.fsum(step.delta_s_cal_per_k_mol for step in steps)
|
|
389
|
+
return StackingResult(
|
|
390
|
+
sequence=symbols,
|
|
391
|
+
complement_3to5=_complement_3to5(symbols),
|
|
392
|
+
temperature_celsius=temperature,
|
|
393
|
+
parameter_set=parameter_set.version,
|
|
394
|
+
steps=steps,
|
|
395
|
+
total_delta_h_kcal_per_mol=delta_h,
|
|
396
|
+
total_delta_s_cal_per_k_mol=delta_s,
|
|
397
|
+
total_delta_g_kcal_per_mol=(delta_h - (temperature + 273.15) * delta_s / 1000.0),
|
|
398
|
+
applicability=(
|
|
399
|
+
"Canonical DNA/DNA stacking steps only; excludes initiation, symmetry, and salt."
|
|
400
|
+
),
|
|
401
|
+
provenance=native_provenance(),
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _nearest_neighbor_symbols(
|
|
406
|
+
symbols: str,
|
|
407
|
+
*,
|
|
408
|
+
conditions: ThermodynamicConditions,
|
|
409
|
+
parameter_set: NearestNeighborParameterSet,
|
|
410
|
+
) -> NearestNeighborResult:
|
|
411
|
+
steps = _stacking_steps(
|
|
412
|
+
symbols,
|
|
413
|
+
temperature_celsius=conditions.temperature_celsius,
|
|
414
|
+
parameter_set=parameter_set,
|
|
415
|
+
)
|
|
416
|
+
stacking_h = math.fsum(step.delta_h_kcal_per_mol for step in steps)
|
|
417
|
+
stacking_s = math.fsum(step.delta_s_cal_per_k_mol for step in steps)
|
|
418
|
+
terminal_parameters = (
|
|
419
|
+
parameter_set.terminal_at if base in "AT" else parameter_set.terminal_gc
|
|
420
|
+
for base in (symbols[0], symbols[-1])
|
|
421
|
+
)
|
|
422
|
+
terminal_tuple = tuple(terminal_parameters)
|
|
423
|
+
initiation_h = math.fsum(item.delta_h_kcal_per_mol for item in terminal_tuple)
|
|
424
|
+
initiation_s = math.fsum(item.delta_s_cal_per_k_mol for item in terminal_tuple)
|
|
425
|
+
self_complementary = symbols == _reverse_complement(symbols)
|
|
426
|
+
symmetry_h = parameter_set.symmetry.delta_h_kcal_per_mol if self_complementary else 0.0
|
|
427
|
+
symmetry_s = parameter_set.symmetry.delta_s_cal_per_k_mol if self_complementary else 0.0
|
|
428
|
+
salt_s = 0.368 * (len(symbols) - 1) * math.log(conditions.monovalent_molar)
|
|
429
|
+
delta_h = math.fsum((stacking_h, initiation_h, symmetry_h))
|
|
430
|
+
delta_s = math.fsum((stacking_s, initiation_s, symmetry_s, salt_s))
|
|
431
|
+
temperature_kelvin = conditions.temperature_celsius + 273.15
|
|
432
|
+
delta_g = delta_h - temperature_kelvin * delta_s / 1000.0
|
|
433
|
+
divisor = 1 if self_complementary else 4
|
|
434
|
+
concentration_term = _GAS_CONSTANT_CAL_PER_K_MOL * math.log(
|
|
435
|
+
conditions.strand_concentration_molar / divisor
|
|
436
|
+
)
|
|
437
|
+
denominator = delta_s + concentration_term
|
|
438
|
+
if denominator == 0.0:
|
|
439
|
+
raise ConfigurationError(
|
|
440
|
+
"The selected conditions produce a singular Tm denominator.",
|
|
441
|
+
code="SINGULAR_TM_CONDITIONS",
|
|
442
|
+
)
|
|
443
|
+
tm_celsius = (1000.0 * delta_h / denominator) - 273.15
|
|
444
|
+
return NearestNeighborResult(
|
|
445
|
+
sequence=symbols,
|
|
446
|
+
complement_5to3=_reverse_complement(symbols),
|
|
447
|
+
sequence_length=len(symbols),
|
|
448
|
+
parameter_set=parameter_set.version,
|
|
449
|
+
conditions=conditions,
|
|
450
|
+
stacking_steps=steps,
|
|
451
|
+
stacking_delta_h_kcal_per_mol=stacking_h,
|
|
452
|
+
stacking_delta_s_cal_per_k_mol=stacking_s,
|
|
453
|
+
initiation_delta_h_kcal_per_mol=initiation_h,
|
|
454
|
+
initiation_delta_s_cal_per_k_mol=initiation_s,
|
|
455
|
+
symmetry_delta_h_kcal_per_mol=symmetry_h,
|
|
456
|
+
symmetry_delta_s_cal_per_k_mol=symmetry_s,
|
|
457
|
+
salt_delta_s_cal_per_k_mol=salt_s,
|
|
458
|
+
delta_h_kcal_per_mol=delta_h,
|
|
459
|
+
delta_s_cal_per_k_mol=delta_s,
|
|
460
|
+
delta_g_kcal_per_mol=delta_g,
|
|
461
|
+
tm_celsius=tm_celsius,
|
|
462
|
+
self_complementary=self_complementary,
|
|
463
|
+
concentration_divisor=divisor,
|
|
464
|
+
gas_constant_cal_per_k_mol=_GAS_CONSTANT_CAL_PER_K_MOL,
|
|
465
|
+
reference_sodium_molar=parameter_set.reference_sodium_molar,
|
|
466
|
+
tm_equation="1000*dH/(dS+R*ln(Ct/divisor))-273.15",
|
|
467
|
+
method="nearest-neighbor-perfect-duplex",
|
|
468
|
+
algorithm_version="dnakit-santalucia1998-v1",
|
|
469
|
+
applicability=_NATIVE_APPLICABILITY,
|
|
470
|
+
provenance=native_provenance(),
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def nearest_neighbor(
|
|
475
|
+
sequence: DNASequence,
|
|
476
|
+
*,
|
|
477
|
+
complement: DNASequence | None = None,
|
|
478
|
+
conditions: ThermodynamicConditions | None = None,
|
|
479
|
+
config: NearestNeighborConfig | None = None,
|
|
480
|
+
) -> NearestNeighborResult:
|
|
481
|
+
"""Calculate complete Watson-Crick duplex ΔH, ΔS, ΔG, and Tm."""
|
|
482
|
+
|
|
483
|
+
resolved_conditions = _conditions(conditions)
|
|
484
|
+
_require_native_ions(resolved_conditions)
|
|
485
|
+
resolved_config = _nn_config(config)
|
|
486
|
+
symbols = canonical_linear_symbols(
|
|
487
|
+
sequence,
|
|
488
|
+
operation="nearest_neighbor",
|
|
489
|
+
min_length=2,
|
|
490
|
+
max_length=resolved_config.max_sequence_length,
|
|
491
|
+
)
|
|
492
|
+
if complement is not None:
|
|
493
|
+
complement_symbols = canonical_linear_symbols(
|
|
494
|
+
complement,
|
|
495
|
+
operation="nearest_neighbor complement",
|
|
496
|
+
min_length=2,
|
|
497
|
+
max_length=resolved_config.max_sequence_length,
|
|
498
|
+
)
|
|
499
|
+
expected = _reverse_complement(symbols)
|
|
500
|
+
if complement_symbols != expected:
|
|
501
|
+
raise ConfigurationError(
|
|
502
|
+
"The native nearest-neighbor model supports only a full reverse complement.",
|
|
503
|
+
code="MISMATCHED_DUPLEX_UNSUPPORTED",
|
|
504
|
+
context={"expected_complement_5to3": expected},
|
|
505
|
+
hint="Use a validated mismatch-capable external backend.",
|
|
506
|
+
)
|
|
507
|
+
return _nearest_neighbor_symbols(
|
|
508
|
+
symbols,
|
|
509
|
+
conditions=resolved_conditions,
|
|
510
|
+
parameter_set=PARAMETER_SETS[resolved_config.parameter_set],
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def melting_temperature(
|
|
515
|
+
sequence: DNASequence,
|
|
516
|
+
*,
|
|
517
|
+
method: TmMethod = "nearest_neighbor",
|
|
518
|
+
conditions: ThermodynamicConditions | None = None,
|
|
519
|
+
config: NearestNeighborConfig | None = None,
|
|
520
|
+
) -> MeltingTemperatureResult:
|
|
521
|
+
"""Calculate Tm using either the bounded Wallace rule or native NN model."""
|
|
522
|
+
|
|
523
|
+
resolved_conditions = _conditions(conditions)
|
|
524
|
+
if method == "wallace":
|
|
525
|
+
if config is not None:
|
|
526
|
+
raise ConfigurationError(
|
|
527
|
+
"NearestNeighborConfig is not applicable to the Wallace rule.",
|
|
528
|
+
code="TM_CONFIG_NOT_APPLICABLE",
|
|
529
|
+
)
|
|
530
|
+
symbols = canonical_linear_symbols(
|
|
531
|
+
sequence,
|
|
532
|
+
operation="Wallace melting_temperature",
|
|
533
|
+
min_length=2,
|
|
534
|
+
max_length=13,
|
|
535
|
+
)
|
|
536
|
+
tm = 2.0 * sum(symbol in "AT" for symbol in symbols) + 4.0 * sum(
|
|
537
|
+
symbol in "GC" for symbol in symbols
|
|
538
|
+
)
|
|
539
|
+
return MeltingTemperatureResult(
|
|
540
|
+
tm_celsius=tm,
|
|
541
|
+
sequence_length=len(symbols),
|
|
542
|
+
method="wallace-2at-4gc",
|
|
543
|
+
algorithm_version="wallace-short-oligo-v1",
|
|
544
|
+
conditions=resolved_conditions,
|
|
545
|
+
parameter_set=None,
|
|
546
|
+
applicability=(
|
|
547
|
+
"Empirical 2(A+T)+4(G+C) rule restricted here to canonical 2-13 nt DNA; "
|
|
548
|
+
"recorded ionic and concentration conditions are not modeled."
|
|
549
|
+
),
|
|
550
|
+
provenance=native_provenance(citation=False),
|
|
551
|
+
)
|
|
552
|
+
if method != "nearest_neighbor":
|
|
553
|
+
raise ConfigurationError(
|
|
554
|
+
"method must be 'wallace' or 'nearest_neighbor'.",
|
|
555
|
+
code="UNKNOWN_TM_METHOD",
|
|
556
|
+
context={"method": method},
|
|
557
|
+
)
|
|
558
|
+
result = nearest_neighbor(
|
|
559
|
+
sequence,
|
|
560
|
+
conditions=resolved_conditions,
|
|
561
|
+
config=config,
|
|
562
|
+
)
|
|
563
|
+
return MeltingTemperatureResult(
|
|
564
|
+
tm_celsius=result.tm_celsius,
|
|
565
|
+
sequence_length=result.sequence_length,
|
|
566
|
+
method=result.method,
|
|
567
|
+
algorithm_version=result.algorithm_version,
|
|
568
|
+
conditions=result.conditions,
|
|
569
|
+
parameter_set=result.parameter_set,
|
|
570
|
+
applicability=result.applicability,
|
|
571
|
+
provenance=result.provenance,
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def duplex_stability(
|
|
576
|
+
sequence_a: DNASequence,
|
|
577
|
+
sequence_b: DNASequence,
|
|
578
|
+
*,
|
|
579
|
+
conditions: ThermodynamicConditions | None = None,
|
|
580
|
+
config: NearestNeighborConfig | None = None,
|
|
581
|
+
backend: Literal["native", "primer3-cli"] = "native",
|
|
582
|
+
adapter: Primer3CLIAdapter | None = None,
|
|
583
|
+
max_loop: int = 30,
|
|
584
|
+
output_structure: bool = False,
|
|
585
|
+
) -> DuplexStabilityResult:
|
|
586
|
+
"""Evaluate duplex stability with a native or explicit Primer3 backend.
|
|
587
|
+
|
|
588
|
+
The default native path is restricted to a complete Watson-Crick reverse
|
|
589
|
+
complement. ``backend="primer3-cli"`` is an explicit opt-in path for
|
|
590
|
+
mismatches and dangling ends; it requires a caller-configured CLI adapter,
|
|
591
|
+
never searches, installs, or downloads Primer3, and records the resolved
|
|
592
|
+
executable in result provenance.
|
|
593
|
+
"""
|
|
594
|
+
|
|
595
|
+
if not isinstance(backend, str) or backend not in {"native", "primer3-cli"}:
|
|
596
|
+
raise ConfigurationError(
|
|
597
|
+
"backend must be 'native' or 'primer3-cli'.",
|
|
598
|
+
code="UNKNOWN_DUPLEX_STABILITY_BACKEND",
|
|
599
|
+
context={"backend": backend},
|
|
600
|
+
)
|
|
601
|
+
if adapter is not None and not isinstance(adapter, Primer3CLIAdapter):
|
|
602
|
+
raise ConfigurationError(
|
|
603
|
+
"adapter must be Primer3CLIAdapter or None.",
|
|
604
|
+
code="INVALID_DUPLEX_STABILITY_ADAPTER",
|
|
605
|
+
)
|
|
606
|
+
if isinstance(max_loop, bool) or not isinstance(max_loop, int) or not 1 <= max_loop <= 30:
|
|
607
|
+
raise ConfigurationError(
|
|
608
|
+
"max_loop must be an integer in [1, 30].",
|
|
609
|
+
code="INVALID_DUPLEX_STABILITY_MAX_LOOP",
|
|
610
|
+
)
|
|
611
|
+
if not isinstance(output_structure, bool):
|
|
612
|
+
raise ConfigurationError(
|
|
613
|
+
"output_structure must be boolean.",
|
|
614
|
+
code="INVALID_DUPLEX_STABILITY_STRUCTURE_OPTION",
|
|
615
|
+
)
|
|
616
|
+
if backend == "native" and adapter is not None:
|
|
617
|
+
raise ConfigurationError(
|
|
618
|
+
"adapter is only valid when backend='primer3-cli'.",
|
|
619
|
+
code="UNUSED_DUPLEX_STABILITY_ADAPTER",
|
|
620
|
+
)
|
|
621
|
+
if backend == "native" and (max_loop != 30 or output_structure):
|
|
622
|
+
raise ConfigurationError(
|
|
623
|
+
"max_loop and output_structure are only valid with backend='primer3-cli'.",
|
|
624
|
+
code="UNUSED_DUPLEX_STABILITY_STRUCTURE_OPTION",
|
|
625
|
+
)
|
|
626
|
+
if backend == "primer3-cli" and config is not None:
|
|
627
|
+
raise ConfigurationError(
|
|
628
|
+
"NearestNeighborConfig is only valid with backend='native'.",
|
|
629
|
+
code="UNUSED_DUPLEX_STABILITY_NATIVE_CONFIG",
|
|
630
|
+
)
|
|
631
|
+
if backend == "primer3-cli":
|
|
632
|
+
resolved_adapter = Primer3CLIAdapter() if adapter is None else adapter
|
|
633
|
+
resolved_conditions = _conditions(conditions)
|
|
634
|
+
requested_a = canonical_linear_symbols(
|
|
635
|
+
sequence_a,
|
|
636
|
+
operation="Primer3 duplex stability sequence A",
|
|
637
|
+
min_length=1,
|
|
638
|
+
max_length=60,
|
|
639
|
+
)
|
|
640
|
+
requested_b = canonical_linear_symbols(
|
|
641
|
+
sequence_b,
|
|
642
|
+
operation="Primer3 duplex stability sequence B",
|
|
643
|
+
min_length=1,
|
|
644
|
+
max_length=60,
|
|
645
|
+
)
|
|
646
|
+
raw_primer3_result = resolved_adapter.heterodimer(
|
|
647
|
+
sequence_a,
|
|
648
|
+
sequence_b,
|
|
649
|
+
conditions=resolved_conditions,
|
|
650
|
+
max_loop=max_loop,
|
|
651
|
+
output_structure=output_structure,
|
|
652
|
+
)
|
|
653
|
+
primer3_result = validate_primer3_result(
|
|
654
|
+
raw_primer3_result,
|
|
655
|
+
capability="heterodimer",
|
|
656
|
+
sequences_5to3=(requested_a, requested_b),
|
|
657
|
+
conditions=resolved_conditions,
|
|
658
|
+
max_loop=max_loop,
|
|
659
|
+
output_structure=output_structure,
|
|
660
|
+
error_code="MISMATCHED_PRIMER3_DUPLEX_RESULT",
|
|
661
|
+
)
|
|
662
|
+
delta_g = primer3_result.delta_g_kcal_per_mol
|
|
663
|
+
if delta_g is None: # Defensive guard for third-party adapter contract drift.
|
|
664
|
+
raise ConfigurationError(
|
|
665
|
+
"Primer3 heterodimer result omitted delta_g_kcal_per_mol.",
|
|
666
|
+
code="INVALID_PRIMER3_DUPLEX_RESULT",
|
|
667
|
+
)
|
|
668
|
+
symbols_a, symbols_b = primer3_result.sequences_5to3
|
|
669
|
+
fully_complementary = symbols_b == _reverse_complement(symbols_a)
|
|
670
|
+
return DuplexStabilityResult(
|
|
671
|
+
sequence_a_5to3=symbols_a,
|
|
672
|
+
sequence_b_5to3=symbols_b,
|
|
673
|
+
fully_complementary=fully_complementary,
|
|
674
|
+
stable_at_temperature=(
|
|
675
|
+
bool(primer3_result.structure_found)
|
|
676
|
+
and primer3_result.tm_celsius > primer3_result.conditions.temperature_celsius
|
|
677
|
+
),
|
|
678
|
+
stability_criterion=(
|
|
679
|
+
"primer3_structure_found and "
|
|
680
|
+
"heterodimer_tm_celsius > configured_temperature_celsius"
|
|
681
|
+
),
|
|
682
|
+
delta_g_kcal_per_mol=delta_g,
|
|
683
|
+
tm_celsius=primer3_result.tm_celsius,
|
|
684
|
+
conditions=primer3_result.conditions,
|
|
685
|
+
model=primer3_result.algorithm_version,
|
|
686
|
+
applicability=(
|
|
687
|
+
"Canonical linear DNA of 1-60 nt per strand; Primer3 heterodimer "
|
|
688
|
+
"model permits mismatch and dangling-end structures selected by the "
|
|
689
|
+
"backend. User-supplied alignments and chemical modifications are excluded."
|
|
690
|
+
),
|
|
691
|
+
thermodynamics=primer3_result,
|
|
692
|
+
provenance=primer3_result.provenance,
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
result = nearest_neighbor(
|
|
696
|
+
sequence_a,
|
|
697
|
+
complement=sequence_b,
|
|
698
|
+
conditions=conditions,
|
|
699
|
+
config=config,
|
|
700
|
+
)
|
|
701
|
+
return DuplexStabilityResult(
|
|
702
|
+
sequence_a_5to3=result.sequence,
|
|
703
|
+
sequence_b_5to3=result.complement_5to3,
|
|
704
|
+
fully_complementary=True,
|
|
705
|
+
stable_at_temperature=result.tm_celsius > result.conditions.temperature_celsius,
|
|
706
|
+
stability_criterion="tm_celsius > configured_temperature_celsius",
|
|
707
|
+
delta_g_kcal_per_mol=result.delta_g_kcal_per_mol,
|
|
708
|
+
tm_celsius=result.tm_celsius,
|
|
709
|
+
conditions=result.conditions,
|
|
710
|
+
model=result.algorithm_version,
|
|
711
|
+
applicability=_NATIVE_APPLICABILITY,
|
|
712
|
+
thermodynamics=result,
|
|
713
|
+
provenance=result.provenance,
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def window_tm(
|
|
718
|
+
sequence: DNASequence,
|
|
719
|
+
window_size: int,
|
|
720
|
+
*,
|
|
721
|
+
step: int = 1,
|
|
722
|
+
method: TmMethod = "nearest_neighbor",
|
|
723
|
+
conditions: ThermodynamicConditions | None = None,
|
|
724
|
+
config: NearestNeighborConfig | None = None,
|
|
725
|
+
max_windows: int = 100_000,
|
|
726
|
+
) -> WindowTmResult:
|
|
727
|
+
"""Calculate a bounded local Tm profile in 0-based half-open coordinates."""
|
|
728
|
+
|
|
729
|
+
for name, value in (
|
|
730
|
+
("window_size", window_size),
|
|
731
|
+
("step", step),
|
|
732
|
+
("max_windows", max_windows),
|
|
733
|
+
):
|
|
734
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
735
|
+
raise ConfigurationError(
|
|
736
|
+
f"{name} must be a positive integer.",
|
|
737
|
+
code="INVALID_WINDOW_TM_CONFIG",
|
|
738
|
+
context={"field": name, "value": value},
|
|
739
|
+
)
|
|
740
|
+
if method not in {"wallace", "nearest_neighbor"}:
|
|
741
|
+
raise ConfigurationError(
|
|
742
|
+
"method must be 'wallace' or 'nearest_neighbor'.",
|
|
743
|
+
code="UNKNOWN_TM_METHOD",
|
|
744
|
+
)
|
|
745
|
+
if method == "wallace" and config is not None:
|
|
746
|
+
raise ConfigurationError(
|
|
747
|
+
"NearestNeighborConfig is not applicable to a Wallace-rule Tm profile.",
|
|
748
|
+
code="TM_CONFIG_NOT_APPLICABLE",
|
|
749
|
+
)
|
|
750
|
+
if max_windows > 1_000_000:
|
|
751
|
+
raise ConfigurationError(
|
|
752
|
+
"max_windows cannot exceed the hard safety ceiling of 1000000.",
|
|
753
|
+
code="INVALID_WINDOW_TM_CONFIG",
|
|
754
|
+
)
|
|
755
|
+
resolved_config: NearestNeighborConfig | None = None
|
|
756
|
+
if method == "wallace":
|
|
757
|
+
method_max = 13
|
|
758
|
+
else:
|
|
759
|
+
resolved_config = _nn_config(config)
|
|
760
|
+
method_max = resolved_config.max_sequence_length
|
|
761
|
+
if not 2 <= window_size <= method_max:
|
|
762
|
+
raise ConfigurationError(
|
|
763
|
+
f"window_size must be in [2, {method_max}] for method={method!r}.",
|
|
764
|
+
code="WINDOW_TM_SIZE_OUT_OF_DOMAIN",
|
|
765
|
+
)
|
|
766
|
+
symbols = canonical_linear_symbols(
|
|
767
|
+
sequence,
|
|
768
|
+
operation="window_tm",
|
|
769
|
+
min_length=window_size,
|
|
770
|
+
max_length=10_000_000,
|
|
771
|
+
)
|
|
772
|
+
count = ((len(symbols) - window_size) // step) + 1
|
|
773
|
+
if count > max_windows:
|
|
774
|
+
raise ConfigurationError(
|
|
775
|
+
"Requested local Tm profile exceeds max_windows.",
|
|
776
|
+
code="WINDOW_TM_LIMIT_EXCEEDED",
|
|
777
|
+
context={"window_count": count, "max_windows": max_windows},
|
|
778
|
+
hint="Increase step, decrease input length, or explicitly raise max_windows.",
|
|
779
|
+
)
|
|
780
|
+
resolved_conditions = _conditions(conditions)
|
|
781
|
+
if method == "nearest_neighbor":
|
|
782
|
+
_require_native_ions(resolved_conditions)
|
|
783
|
+
assert resolved_config is not None
|
|
784
|
+
parameter_set = PARAMETER_SETS[resolved_config.parameter_set]
|
|
785
|
+
else:
|
|
786
|
+
parameter_set = PARAMETER_SETS["santalucia1998-v1"]
|
|
787
|
+
points: list[WindowTmPoint] = []
|
|
788
|
+
for start in range(0, len(symbols) - window_size + 1, step):
|
|
789
|
+
subsequence = symbols[start : start + window_size]
|
|
790
|
+
if method == "wallace":
|
|
791
|
+
tm = float(
|
|
792
|
+
2 * sum(symbol in "AT" for symbol in subsequence)
|
|
793
|
+
+ 4 * sum(symbol in "GC" for symbol in subsequence)
|
|
794
|
+
)
|
|
795
|
+
else:
|
|
796
|
+
tm = _nearest_neighbor_symbols(
|
|
797
|
+
subsequence,
|
|
798
|
+
conditions=resolved_conditions,
|
|
799
|
+
parameter_set=parameter_set,
|
|
800
|
+
).tm_celsius
|
|
801
|
+
points.append(WindowTmPoint(start, start + window_size, subsequence, tm))
|
|
802
|
+
tm_values = tuple(point.tm_celsius for point in points)
|
|
803
|
+
return WindowTmResult(
|
|
804
|
+
sequence_length=len(symbols),
|
|
805
|
+
window_size=window_size,
|
|
806
|
+
step=step,
|
|
807
|
+
method=method,
|
|
808
|
+
conditions=resolved_conditions,
|
|
809
|
+
windows=tuple(points),
|
|
810
|
+
min_tm_celsius=min(tm_values, default=None),
|
|
811
|
+
max_tm_celsius=max(tm_values, default=None),
|
|
812
|
+
max_windows=max_windows,
|
|
813
|
+
coordinate_system="0-based-half-open-symbol",
|
|
814
|
+
applicability=(
|
|
815
|
+
"Local fixed-window computational Tm profile; not an experimental melting curve. "
|
|
816
|
+
+ (
|
|
817
|
+
"Wallace rule conditions are recorded but not modeled."
|
|
818
|
+
if method == "wallace"
|
|
819
|
+
else _NATIVE_APPLICABILITY
|
|
820
|
+
)
|
|
821
|
+
),
|
|
822
|
+
provenance=(
|
|
823
|
+
native_provenance(citation=False) if method == "wallace" else native_provenance()
|
|
824
|
+
),
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
__all__ = [
|
|
829
|
+
"duplex_stability",
|
|
830
|
+
"extinction_coefficient_260nm",
|
|
831
|
+
"melting_temperature",
|
|
832
|
+
"molecular_weight",
|
|
833
|
+
"nearest_neighbor",
|
|
834
|
+
"salt_correction",
|
|
835
|
+
"stacking_interactions",
|
|
836
|
+
"window_tm",
|
|
837
|
+
]
|