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.
Files changed (210) hide show
  1. dnakit/__init__.py +108 -0
  2. dnakit/_version.py +5 -0
  3. dnakit/alignment/__init__.py +6 -0
  4. dnakit/alignment/pairwise.py +512 -0
  5. dnakit/alignment/results.py +188 -0
  6. dnakit/backends/__init__.py +80 -0
  7. dnakit/backends/external.py +517 -0
  8. dnakit/backends/registry.py +207 -0
  9. dnakit/batch.py +452 -0
  10. dnakit/cache/__init__.py +5 -0
  11. dnakit/cache/store.py +424 -0
  12. dnakit/chunking.py +920 -0
  13. dnakit/cli/__init__.py +5 -0
  14. dnakit/cli/__main__.py +6 -0
  15. dnakit/cli/app.py +814 -0
  16. dnakit/cli/workflow.py +94 -0
  17. dnakit/config/__init__.py +5 -0
  18. dnakit/config/loader.py +381 -0
  19. dnakit/core/__init__.py +80 -0
  20. dnakit/core/_json.py +206 -0
  21. dnakit/core/backend_info.py +70 -0
  22. dnakit/core/collection.py +163 -0
  23. dnakit/core/coordinates.py +295 -0
  24. dnakit/core/enums.py +95 -0
  25. dnakit/core/facade.py +719 -0
  26. dnakit/core/feature.py +90 -0
  27. dnakit/core/gap.py +82 -0
  28. dnakit/core/issues.py +62 -0
  29. dnakit/core/provenance.py +305 -0
  30. dnakit/core/record.py +161 -0
  31. dnakit/core/results.py +134 -0
  32. dnakit/core/sequence.py +327 -0
  33. dnakit/datasets/__init__.py +100 -0
  34. dnakit/datasets/_advanced_shared.py +198 -0
  35. dnakit/datasets/_metadata.py +33 -0
  36. dnakit/datasets/advanced_deduplicate.py +183 -0
  37. dnakit/datasets/advanced_split.py +302 -0
  38. dnakit/datasets/clustering.py +286 -0
  39. dnakit/datasets/config.py +531 -0
  40. dnakit/datasets/deduplicate.py +303 -0
  41. dnakit/datasets/evaluation.py +244 -0
  42. dnakit/datasets/filter.py +135 -0
  43. dnakit/datasets/neural_clustering.py +221 -0
  44. dnakit/datasets/results.py +520 -0
  45. dnakit/datasets/split.py +450 -0
  46. dnakit/descriptors/__init__.py +102 -0
  47. dnakit/descriptors/_dinucleotide.py +345 -0
  48. dnakit/descriptors/_shared.py +180 -0
  49. dnakit/descriptors/all.py +917 -0
  50. dnakit/descriptors/basic.py +193 -0
  51. dnakit/descriptors/codon.py +195 -0
  52. dnakit/descriptors/complexity.py +201 -0
  53. dnakit/descriptors/entropy.py +101 -0
  54. dnakit/descriptors/homopolymer.py +105 -0
  55. dnakit/descriptors/kmer.py +88 -0
  56. dnakit/descriptors/results.py +286 -0
  57. dnakit/descriptors/schema.py +414 -0
  58. dnakit/descriptors/window.py +203 -0
  59. dnakit/download/__init__.py +54 -0
  60. dnakit/download/catalogs.py +166 -0
  61. dnakit/download/ena.py +131 -0
  62. dnakit/download/encode.py +103 -0
  63. dnakit/download/files.py +469 -0
  64. dnakit/download/indexes.py +233 -0
  65. dnakit/download/metadata.py +164 -0
  66. dnakit/download/models.py +227 -0
  67. dnakit/download/ncbi.py +333 -0
  68. dnakit/download/sequence.py +133 -0
  69. dnakit/download/ucsc.py +99 -0
  70. dnakit/evaluation/__init__.py +81 -0
  71. dnakit/evaluation/_shared.py +288 -0
  72. dnakit/evaluation/collection.py +270 -0
  73. dnakit/evaluation/config.py +591 -0
  74. dnakit/evaluation/distribution.py +287 -0
  75. dnakit/evaluation/frechet.py +223 -0
  76. dnakit/evaluation/generative.py +381 -0
  77. dnakit/evaluation/reference.py +409 -0
  78. dnakit/evaluation/results.py +137 -0
  79. dnakit/evaluation/scorecard.py +211 -0
  80. dnakit/evaluation/sequence.py +403 -0
  81. dnakit/evaluation/synthesis.py +249 -0
  82. dnakit/exceptions.py +189 -0
  83. dnakit/fingerprints/__init__.py +109 -0
  84. dnakit/fingerprints/_shared.py +116 -0
  85. dnakit/fingerprints/advanced.py +829 -0
  86. dnakit/fingerprints/bit.py +229 -0
  87. dnakit/fingerprints/encoding.py +351 -0
  88. dnakit/fingerprints/kmer.py +170 -0
  89. dnakit/fingerprints/results.py +531 -0
  90. dnakit/fingerprints/sketch.py +246 -0
  91. dnakit/io/__init__.py +112 -0
  92. dnakit/io/_advanced_common.py +105 -0
  93. dnakit/io/_formats.py +994 -0
  94. dnakit/io/annotations.py +1179 -0
  95. dnakit/io/api.py +759 -0
  96. dnakit/io/config.py +315 -0
  97. dnakit/io/genbank.py +523 -0
  98. dnakit/io/indexed.py +1146 -0
  99. dnakit/io/metadata.py +452 -0
  100. dnakit/io/parquet.py +56 -0
  101. dnakit/io/results.py +131 -0
  102. dnakit/io/source.py +138 -0
  103. dnakit/io/tables.py +1237 -0
  104. dnakit/molbio/__init__.py +101 -0
  105. dnakit/molbio/_shared.py +266 -0
  106. dnakit/molbio/assembly.py +279 -0
  107. dnakit/molbio/crispr.py +181 -0
  108. dnakit/molbio/optimization.py +897 -0
  109. dnakit/molbio/primer3_design.py +667 -0
  110. dnakit/molbio/primers.py +899 -0
  111. dnakit/molbio/restriction.py +573 -0
  112. dnakit/molbio/results.py +583 -0
  113. dnakit/ops/__init__.py +588 -0
  114. dnakit/ops/_common.py +201 -0
  115. dnakit/ops/circular.py +193 -0
  116. dnakit/ops/concat.py +204 -0
  117. dnakit/ops/direction.py +27 -0
  118. dnakit/ops/edit.py +443 -0
  119. dnakit/ops/evolution.py +991 -0
  120. dnakit/ops/mutation.py +151 -0
  121. dnakit/ops/records.py +1012 -0
  122. dnakit/ops/translation.py +257 -0
  123. dnakit/patterns/__init__.py +81 -0
  124. dnakit/patterns/_shared.py +282 -0
  125. dnakit/patterns/coding.py +360 -0
  126. dnakit/patterns/crispr.py +337 -0
  127. dnakit/patterns/motif.py +721 -0
  128. dnakit/patterns/regions.py +341 -0
  129. dnakit/patterns/repeats.py +480 -0
  130. dnakit/patterns/restriction.py +281 -0
  131. dnakit/patterns/results.py +411 -0
  132. dnakit/py.typed +1 -0
  133. dnakit/references/__init__.py +27 -0
  134. dnakit/references/models.py +155 -0
  135. dnakit/references/ncbi.py +785 -0
  136. dnakit/representations/__init__.py +29 -0
  137. dnakit/representations/backends.py +928 -0
  138. dnakit/representations/checkpoints.py +356 -0
  139. dnakit/representations/extraction.py +220 -0
  140. dnakit/representations/models.py +395 -0
  141. dnakit/representations/results.py +79 -0
  142. dnakit/search/__init__.py +132 -0
  143. dnakit/search/_http.py +271 -0
  144. dnakit/search/_shared.py +108 -0
  145. dnakit/search/blast.py +516 -0
  146. dnakit/search/ena.py +245 -0
  147. dnakit/search/encode.py +102 -0
  148. dnakit/search/ensembl.py +874 -0
  149. dnakit/search/models.py +159 -0
  150. dnakit/search/ncbi.py +756 -0
  151. dnakit/search/ucsc.py +388 -0
  152. dnakit/secondary_structure/__init__.py +38 -0
  153. dnakit/secondary_structure/dotbracket.py +410 -0
  154. dnakit/secondary_structure/nupack.py +589 -0
  155. dnakit/secondary_structure/results.py +142 -0
  156. dnakit/similarity/__init__.py +125 -0
  157. dnakit/similarity/_shared.py +152 -0
  158. dnakit/similarity/approximate.py +258 -0
  159. dnakit/similarity/compare.py +162 -0
  160. dnakit/similarity/dashing.py +936 -0
  161. dnakit/similarity/distance.py +279 -0
  162. dnakit/similarity/index.py +365 -0
  163. dnakit/similarity/matrix.py +254 -0
  164. dnakit/similarity/results.py +676 -0
  165. dnakit/similarity/search.py +286 -0
  166. dnakit/similarity/sketch.py +96 -0
  167. dnakit/similarity/vector.py +662 -0
  168. dnakit/standardize/__init__.py +77 -0
  169. dnakit/standardize/_shared.py +100 -0
  170. dnakit/standardize/config.py +411 -0
  171. dnakit/standardize/gaps.py +1060 -0
  172. dnakit/standardize/normalize.py +565 -0
  173. dnakit/standardize/results.py +247 -0
  174. dnakit/standardize/validate.py +487 -0
  175. dnakit/structure3d/__init__.py +47 -0
  176. dnakit/structure3d/_shared.py +47 -0
  177. dnakit/structure3d/geometry.py +773 -0
  178. dnakit/structure3d/pdb.py +253 -0
  179. dnakit/structure3d/results.py +233 -0
  180. dnakit/structure3d/threedna.py +174 -0
  181. dnakit/thermodynamics/__init__.py +112 -0
  182. dnakit/thermodynamics/_shared.py +92 -0
  183. dnakit/thermodynamics/backends.py +653 -0
  184. dnakit/thermodynamics/calculations.py +837 -0
  185. dnakit/thermodynamics/config.py +175 -0
  186. dnakit/thermodynamics/equilibrium.py +340 -0
  187. dnakit/thermodynamics/optics.py +385 -0
  188. dnakit/thermodynamics/parameters.py +79 -0
  189. dnakit/thermodynamics/results.py +445 -0
  190. dnakit/visualization/__init__.py +43 -0
  191. dnakit/visualization/_svg.py +185 -0
  192. dnakit/visualization/advanced.py +314 -0
  193. dnakit/visualization/config.py +255 -0
  194. dnakit/visualization/export.py +349 -0
  195. dnakit/visualization/matrix.py +279 -0
  196. dnakit/visualization/report.py +431 -0
  197. dnakit/visualization/results.py +236 -0
  198. dnakit/visualization/sequence.py +459 -0
  199. dnakit/workflows/__init__.py +45 -0
  200. dnakit/workflows/manifest.py +365 -0
  201. dnakit/workflows/runner.py +1548 -0
  202. dnakit/workflows/schema.py +898 -0
  203. dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
  204. dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
  205. dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
  206. dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  207. dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
  208. dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
  209. dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
  210. 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
+ ]