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,175 @@
1
+ """Validated conditions and safety limits for thermodynamic calculations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass
7
+ from typing import Literal, TypeAlias
8
+
9
+ from dnakit.exceptions import ConfigurationError
10
+
11
+ SaltModel: TypeAlias = Literal["santalucia1998-monovalent-entropy"]
12
+ TmMethod: TypeAlias = Literal["wallace", "nearest_neighbor"]
13
+
14
+
15
+ def _finite_number(value: object, name: str) -> float:
16
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
17
+ raise ConfigurationError(
18
+ f"{name} must be a finite number.",
19
+ code="INVALID_THERMODYNAMIC_CONDITION",
20
+ context={"field": name, "value": value},
21
+ )
22
+ return float(value)
23
+
24
+
25
+ def _positive_integer(value: object, name: str) -> int:
26
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
27
+ raise ConfigurationError(
28
+ f"{name} must be a positive integer.",
29
+ code="INVALID_THERMODYNAMIC_LIMIT",
30
+ context={"field": name, "value": value},
31
+ )
32
+ return value
33
+
34
+
35
+ @dataclass(frozen=True, init=False)
36
+ class ThermodynamicConditions:
37
+ """Physical conditions recorded with every temperature-dependent result.
38
+
39
+ Concentrations are expressed in mol/L. ``strand_concentration_molar`` is
40
+ the total oligonucleotide strand concentration used by the native Tm
41
+ equation. Native calculations treat Na+ and K+ as an explicit total
42
+ monovalent concentration. Magnesium, dNTP and cosolvent fields are retained
43
+ for backend calculations or explicit empirical corrections.
44
+ """
45
+
46
+ temperature_celsius: float
47
+ sodium_molar: float
48
+ potassium_molar: float
49
+ magnesium_molar: float
50
+ dntp_molar: float
51
+ strand_concentration_molar: float
52
+ dmso_percent: float
53
+ dmso_factor_celsius_per_percent: float
54
+ formamide_molar: float
55
+ salt_model: SaltModel
56
+
57
+ def __init__(
58
+ self,
59
+ *,
60
+ temperature_celsius: float = 37.0,
61
+ sodium_molar: float = 0.05,
62
+ potassium_molar: float = 0.0,
63
+ magnesium_molar: float = 0.0,
64
+ dntp_molar: float = 0.0,
65
+ strand_concentration_molar: float = 250e-9,
66
+ dmso_percent: float = 0.0,
67
+ dmso_factor_celsius_per_percent: float = 0.6,
68
+ formamide_molar: float = 0.0,
69
+ salt_model: SaltModel = "santalucia1998-monovalent-entropy",
70
+ ) -> None:
71
+ temperature = _finite_number(temperature_celsius, "temperature_celsius")
72
+ sodium = _finite_number(sodium_molar, "sodium_molar")
73
+ potassium = _finite_number(potassium_molar, "potassium_molar")
74
+ magnesium = _finite_number(magnesium_molar, "magnesium_molar")
75
+ dntp = _finite_number(dntp_molar, "dntp_molar")
76
+ concentration = _finite_number(strand_concentration_molar, "strand_concentration_molar")
77
+ dmso = _finite_number(dmso_percent, "dmso_percent")
78
+ dmso_factor = _finite_number(
79
+ dmso_factor_celsius_per_percent, "dmso_factor_celsius_per_percent"
80
+ )
81
+ formamide = _finite_number(formamide_molar, "formamide_molar")
82
+ if not 0.0 <= temperature <= 100.0:
83
+ raise ConfigurationError(
84
+ "temperature_celsius must be in the modeled interval [0, 100] C.",
85
+ code="THERMODYNAMIC_TEMPERATURE_OUT_OF_DOMAIN",
86
+ )
87
+ if not 0.0 <= sodium <= 2.0 or not 0.0 <= potassium <= 2.0:
88
+ raise ConfigurationError(
89
+ "sodium_molar and potassium_molar must each be in [0, 2].",
90
+ code="THERMODYNAMIC_MONOVALENT_OUT_OF_DOMAIN",
91
+ )
92
+ if not 0.0 < sodium + potassium <= 2.0:
93
+ raise ConfigurationError(
94
+ "The total monovalent concentration (Na+ + K+) must be in (0, 2].",
95
+ code="THERMODYNAMIC_MONOVALENT_OUT_OF_DOMAIN",
96
+ )
97
+ if not 0.0 <= magnesium <= 1.0 or not 0.0 <= dntp <= 1.0:
98
+ raise ConfigurationError(
99
+ "magnesium_molar and dntp_molar must be in the interval [0, 1].",
100
+ code="THERMODYNAMIC_DIVALENT_OUT_OF_DOMAIN",
101
+ )
102
+ if not 0.0 < concentration <= 1.0:
103
+ raise ConfigurationError(
104
+ "strand_concentration_molar must be in the interval (0, 1].",
105
+ code="THERMODYNAMIC_CONCENTRATION_OUT_OF_DOMAIN",
106
+ )
107
+ if not 0.0 <= dmso <= 100.0 or not 0.0 <= dmso_factor <= 2.0:
108
+ raise ConfigurationError(
109
+ "dmso_percent must be in [0, 100] and its factor in [0, 2].",
110
+ code="THERMODYNAMIC_COSOLVENT_OUT_OF_DOMAIN",
111
+ )
112
+ if not 0.0 <= formamide <= 30.0:
113
+ raise ConfigurationError(
114
+ "formamide_molar must be in [0, 30].",
115
+ code="THERMODYNAMIC_COSOLVENT_OUT_OF_DOMAIN",
116
+ )
117
+ if salt_model != "santalucia1998-monovalent-entropy":
118
+ raise ConfigurationError(
119
+ "The native implementation supports only the SantaLucia 1998 monovalent model.",
120
+ code="UNSUPPORTED_SALT_MODEL",
121
+ context={"salt_model": salt_model},
122
+ )
123
+ object.__setattr__(self, "temperature_celsius", temperature)
124
+ object.__setattr__(self, "sodium_molar", sodium)
125
+ object.__setattr__(self, "potassium_molar", potassium)
126
+ object.__setattr__(self, "magnesium_molar", magnesium)
127
+ object.__setattr__(self, "dntp_molar", dntp)
128
+ object.__setattr__(self, "strand_concentration_molar", concentration)
129
+ object.__setattr__(self, "dmso_percent", dmso)
130
+ object.__setattr__(self, "dmso_factor_celsius_per_percent", dmso_factor)
131
+ object.__setattr__(self, "formamide_molar", formamide)
132
+ object.__setattr__(self, "salt_model", salt_model)
133
+
134
+ @property
135
+ def monovalent_molar(self) -> float:
136
+ """Total Na+ plus K+ concentration used by supported monovalent models."""
137
+
138
+ return self.sodium_molar + self.potassium_molar
139
+
140
+
141
+ @dataclass(frozen=True, init=False)
142
+ class NearestNeighborConfig:
143
+ """Native model selection and hard sequence-size safety limit."""
144
+
145
+ parameter_set: str
146
+ max_sequence_length: int
147
+
148
+ def __init__(
149
+ self,
150
+ *,
151
+ parameter_set: str = "santalucia1998-v1",
152
+ max_sequence_length: int = 60,
153
+ ) -> None:
154
+ if parameter_set != "santalucia1998-v1":
155
+ raise ConfigurationError(
156
+ "Unknown native nearest-neighbor parameter set.",
157
+ code="UNKNOWN_THERMODYNAMIC_PARAMETER_SET",
158
+ context={"parameter_set": parameter_set},
159
+ )
160
+ limit = _positive_integer(max_sequence_length, "max_sequence_length")
161
+ if not 2 <= limit <= 60:
162
+ raise ConfigurationError(
163
+ "The native nearest-neighbor applicability limit must be in [2, 60] nt.",
164
+ code="THERMODYNAMIC_APPLICABILITY_LIMIT",
165
+ )
166
+ object.__setattr__(self, "parameter_set", parameter_set)
167
+ object.__setattr__(self, "max_sequence_length", limit)
168
+
169
+
170
+ __all__ = [
171
+ "NearestNeighborConfig",
172
+ "SaltModel",
173
+ "ThermodynamicConditions",
174
+ "TmMethod",
175
+ ]
@@ -0,0 +1,340 @@
1
+ """Duplex equilibrium, theoretical melting-curve and terminal-stability helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Callable, Iterable
7
+ from contextlib import suppress
8
+ from itertools import islice, pairwise
9
+ from typing import TypeAlias
10
+
11
+ from dnakit.core import DNASequence
12
+ from dnakit.exceptions import ConfigurationError
13
+
14
+ from ._shared import canonical_linear_symbols, native_provenance
15
+ from .calculations import nearest_neighbor
16
+ from .config import NearestNeighborConfig, ThermodynamicConditions
17
+ from .results import (
18
+ BindingEquilibriumResult,
19
+ CosolventCorrectionResult,
20
+ MeltingCurvePoint,
21
+ TerminalStabilityResult,
22
+ TheoreticalMeltingCurveResult,
23
+ )
24
+
25
+ MeltingCurveProgress: TypeAlias = Callable[[int, int], None]
26
+ _GAS_CONSTANT_KCAL_PER_K_MOL = 0.00198720425864083
27
+ _MAX_CURVE_POINTS = 100_001
28
+
29
+
30
+ def _conditions(value: ThermodynamicConditions | None) -> ThermodynamicConditions:
31
+ resolved = ThermodynamicConditions() if value is None else value
32
+ if not isinstance(resolved, ThermodynamicConditions):
33
+ raise ConfigurationError(
34
+ "conditions must be ThermodynamicConditions or None.",
35
+ code="INVALID_THERMODYNAMIC_CONDITIONS",
36
+ )
37
+ return resolved
38
+
39
+
40
+ def _association_constant(delta_g_kcal_per_mol: float, temperature_celsius: float) -> float:
41
+ temperature_kelvin = temperature_celsius + 273.15
42
+ exponent = -delta_g_kcal_per_mol / (_GAS_CONSTANT_KCAL_PER_K_MOL * temperature_kelvin)
43
+ return math.exp(min(700.0, max(-700.0, exponent)))
44
+
45
+
46
+ def binding_equilibrium(
47
+ sequence: DNASequence,
48
+ *,
49
+ complement: DNASequence | None = None,
50
+ conditions: ThermodynamicConditions | None = None,
51
+ config: NearestNeighborConfig | None = None,
52
+ ) -> BindingEquilibriumResult:
53
+ """Calculate Ka, Kd and exact two-state duplex fraction from mass balance."""
54
+
55
+ resolved = _conditions(conditions)
56
+ thermodynamics = nearest_neighbor(
57
+ sequence,
58
+ complement=complement,
59
+ conditions=resolved,
60
+ config=config,
61
+ )
62
+ association = _association_constant(
63
+ thermodynamics.delta_g_kcal_per_mol,
64
+ resolved.temperature_celsius,
65
+ )
66
+ total = resolved.strand_concentration_molar
67
+ if thermodynamics.self_complementary:
68
+ free = 2.0 * total / (1.0 + math.sqrt(1.0 + 8.0 * association * total))
69
+ duplex = max(0.0, (total - free) / 2.0)
70
+ fraction = 0.0 if total == 0.0 else 2.0 * duplex / total
71
+ else:
72
+ each_strand_total = total / 2.0
73
+ free = (
74
+ 2.0 * each_strand_total / (1.0 + math.sqrt(1.0 + 4.0 * association * each_strand_total))
75
+ )
76
+ duplex = max(0.0, each_strand_total - free)
77
+ fraction = 0.0 if each_strand_total == 0.0 else duplex / each_strand_total
78
+ return BindingEquilibriumResult(
79
+ temperature_celsius=resolved.temperature_celsius,
80
+ delta_g_kcal_per_mol=thermodynamics.delta_g_kcal_per_mol,
81
+ association_constant_m_inverse=association,
82
+ dissociation_constant_molar=1.0 / association,
83
+ total_strand_concentration_molar=total,
84
+ free_strand_concentration_molar=free,
85
+ duplex_concentration_molar=duplex,
86
+ duplex_fraction=min(1.0, max(0.0, fraction)),
87
+ self_complementary=thermodynamics.self_complementary,
88
+ model="ideal-two-state-perfect-duplex-mass-balance-v1",
89
+ thermodynamics=thermodynamics,
90
+ provenance=thermodynamics.provenance,
91
+ )
92
+
93
+
94
+ def _curve_conditions(
95
+ base: ThermodynamicConditions, temperature_celsius: float
96
+ ) -> ThermodynamicConditions:
97
+ return ThermodynamicConditions(
98
+ temperature_celsius=temperature_celsius,
99
+ sodium_molar=base.sodium_molar,
100
+ potassium_molar=base.potassium_molar,
101
+ magnesium_molar=base.magnesium_molar,
102
+ dntp_molar=base.dntp_molar,
103
+ strand_concentration_molar=base.strand_concentration_molar,
104
+ dmso_percent=base.dmso_percent,
105
+ dmso_factor_celsius_per_percent=base.dmso_factor_celsius_per_percent,
106
+ formamide_molar=base.formamide_molar,
107
+ salt_model=base.salt_model,
108
+ )
109
+
110
+
111
+ def theoretical_melting_curve(
112
+ sequence: DNASequence,
113
+ temperatures_celsius: Iterable[float],
114
+ *,
115
+ complement: DNASequence | None = None,
116
+ conditions: ThermodynamicConditions | None = None,
117
+ config: NearestNeighborConfig | None = None,
118
+ progress: MeltingCurveProgress | None = None,
119
+ ) -> TheoreticalMeltingCurveResult:
120
+ """Evaluate the ideal two-state duplex fraction at bounded temperatures."""
121
+
122
+ if isinstance(temperatures_celsius, (str, bytes)) or not isinstance(
123
+ temperatures_celsius, Iterable
124
+ ):
125
+ raise ConfigurationError(
126
+ "temperatures_celsius must be an iterable.", code="INVALID_MELTING_CURVE"
127
+ )
128
+ raw_temperatures = tuple(islice(iter(temperatures_celsius), _MAX_CURVE_POINTS + 1))
129
+ if not 2 <= len(raw_temperatures) <= _MAX_CURVE_POINTS:
130
+ raise ConfigurationError(
131
+ f"A melting curve requires 2-{_MAX_CURVE_POINTS} temperature points.",
132
+ code="INVALID_MELTING_CURVE",
133
+ )
134
+ temperatures: list[float] = []
135
+ for value in raw_temperatures:
136
+ if (
137
+ isinstance(value, bool)
138
+ or not isinstance(value, (int, float))
139
+ or not math.isfinite(value)
140
+ or not 0.0 <= value <= 100.0
141
+ ):
142
+ raise ConfigurationError(
143
+ "Every melting-curve temperature must be finite and in [0, 100] C.",
144
+ code="INVALID_MELTING_CURVE_TEMPERATURE",
145
+ )
146
+ temperatures.append(float(value))
147
+ if any(right <= left for left, right in pairwise(temperatures)):
148
+ raise ConfigurationError(
149
+ "Melting-curve temperatures must be strictly increasing.",
150
+ code="UNSORTED_MELTING_CURVE_TEMPERATURES",
151
+ )
152
+ if progress is not None and not callable(progress):
153
+ raise ConfigurationError(
154
+ "progress must be callable or None.", code="INVALID_MELTING_CURVE_PROGRESS"
155
+ )
156
+ resolved = _conditions(conditions)
157
+ points: list[MeltingCurvePoint] = []
158
+ curve_provenance = None
159
+ for index, temperature in enumerate(temperatures, start=1):
160
+ equilibrium = binding_equilibrium(
161
+ sequence,
162
+ complement=complement,
163
+ conditions=_curve_conditions(resolved, temperature),
164
+ config=config,
165
+ )
166
+ points.append(
167
+ MeltingCurvePoint(
168
+ temperature_celsius=temperature,
169
+ duplex_fraction=equilibrium.duplex_fraction,
170
+ delta_g_kcal_per_mol=equilibrium.delta_g_kcal_per_mol,
171
+ association_constant_m_inverse=equilibrium.association_constant_m_inverse,
172
+ )
173
+ )
174
+ curve_provenance = equilibrium.provenance
175
+ if progress is not None:
176
+ with suppress(Exception):
177
+ progress(index, len(temperatures))
178
+ midpoint: float | None = None
179
+ for left, right in pairwise(points):
180
+ if left.duplex_fraction == 0.5:
181
+ midpoint = left.temperature_celsius
182
+ break
183
+ if left.duplex_fraction >= 0.5 >= right.duplex_fraction:
184
+ span = left.duplex_fraction - right.duplex_fraction
185
+ fraction = 0.0 if span == 0.0 else (left.duplex_fraction - 0.5) / span
186
+ midpoint = left.temperature_celsius + fraction * (
187
+ right.temperature_celsius - left.temperature_celsius
188
+ )
189
+ break
190
+ if midpoint is None and points[-1].duplex_fraction == 0.5:
191
+ midpoint = points[-1].temperature_celsius
192
+ sequence_symbols = canonical_linear_symbols(
193
+ sequence,
194
+ operation="theoretical_melting_curve",
195
+ min_length=2,
196
+ max_length=60,
197
+ )
198
+ return TheoreticalMeltingCurveResult(
199
+ sequence=sequence_symbols,
200
+ temperatures_celsius=tuple(temperatures),
201
+ points=tuple(points),
202
+ midpoint_temperature_celsius=midpoint,
203
+ conditions=resolved,
204
+ model="ideal-two-state-perfect-duplex-mass-balance-v1",
205
+ applicability=(
206
+ "Equilibrium two-state curve for the native fully complementary nearest-neighbor "
207
+ "model; it is not an instrument response, kinetic trace, or heat-capacity model."
208
+ ),
209
+ provenance=native_provenance() if curve_provenance is None else curve_provenance,
210
+ )
211
+
212
+
213
+ def terminal_stability(
214
+ sequence: DNASequence,
215
+ *,
216
+ window_size: int = 5,
217
+ conditions: ThermodynamicConditions | None = None,
218
+ config: NearestNeighborConfig | None = None,
219
+ ) -> TerminalStabilityResult:
220
+ """Compare nearest-neighbor stability of equal 5-prime and 3-prime windows."""
221
+
222
+ symbols = canonical_linear_symbols(
223
+ sequence,
224
+ operation="terminal_stability",
225
+ min_length=2,
226
+ max_length=60,
227
+ )
228
+ if (
229
+ isinstance(window_size, bool)
230
+ or not isinstance(window_size, int)
231
+ or not 2 <= window_size <= len(symbols)
232
+ ):
233
+ raise ConfigurationError(
234
+ "window_size must be an integer in [2, sequence length].",
235
+ code="INVALID_TERMINAL_STABILITY_WINDOW",
236
+ )
237
+ resolved = _conditions(conditions)
238
+ five_sequence = symbols[:window_size]
239
+ three_sequence = symbols[-window_size:]
240
+ five = nearest_neighbor(DNASequence(five_sequence), conditions=resolved, config=config)
241
+ three = nearest_neighbor(DNASequence(three_sequence), conditions=resolved, config=config)
242
+ if math.isclose(five.delta_g_kcal_per_mol, three.delta_g_kcal_per_mol, abs_tol=1e-12):
243
+ less_stable = "equal"
244
+ elif five.delta_g_kcal_per_mol > three.delta_g_kcal_per_mol:
245
+ less_stable = "5-prime"
246
+ else:
247
+ less_stable = "3-prime"
248
+ return TerminalStabilityResult(
249
+ sequence=symbols,
250
+ window_size=window_size,
251
+ five_prime_sequence=five_sequence,
252
+ three_prime_sequence=three_sequence,
253
+ five_prime_delta_g_kcal_per_mol=five.delta_g_kcal_per_mol,
254
+ three_prime_delta_g_kcal_per_mol=three.delta_g_kcal_per_mol,
255
+ less_stable_end=less_stable,
256
+ conditions=resolved,
257
+ five_prime_thermodynamics=five,
258
+ three_prime_thermodynamics=three,
259
+ provenance=five.provenance,
260
+ )
261
+
262
+
263
+ def cosolvent_tm_correction(
264
+ sequence: DNASequence,
265
+ uncorrected_tm_celsius: float,
266
+ *,
267
+ dmso_percent: float = 0.0,
268
+ dmso_factor_celsius_per_percent: float = 0.6,
269
+ formamide_molar: float = 0.0,
270
+ ) -> CosolventCorrectionResult:
271
+ """Apply explicit Primer3 empirical DMSO and formamide Tm corrections."""
272
+
273
+ symbols = canonical_linear_symbols(
274
+ sequence,
275
+ operation="cosolvent_tm_correction",
276
+ min_length=1,
277
+ max_length=1_000_000,
278
+ )
279
+ values = {
280
+ "uncorrected_tm_celsius": uncorrected_tm_celsius,
281
+ "dmso_percent": dmso_percent,
282
+ "dmso_factor_celsius_per_percent": dmso_factor_celsius_per_percent,
283
+ "formamide_molar": formamide_molar,
284
+ }
285
+ resolved_values: dict[str, float] = {}
286
+ for name, value in values.items():
287
+ if (
288
+ isinstance(value, bool)
289
+ or not isinstance(value, (int, float))
290
+ or not math.isfinite(value)
291
+ ):
292
+ raise ConfigurationError(
293
+ f"{name} must be finite numeric data.",
294
+ code="INVALID_COSOLVENT_CORRECTION",
295
+ )
296
+ resolved_values[name] = float(value)
297
+ if not 0.0 <= resolved_values["dmso_percent"] <= 100.0:
298
+ raise ConfigurationError(
299
+ "dmso_percent must be in [0, 100].", code="INVALID_COSOLVENT_CORRECTION"
300
+ )
301
+ if not 0.0 <= resolved_values["dmso_factor_celsius_per_percent"] <= 2.0:
302
+ raise ConfigurationError(
303
+ "dmso_factor_celsius_per_percent must be in [0, 2].",
304
+ code="INVALID_COSOLVENT_CORRECTION",
305
+ )
306
+ if not 0.0 <= resolved_values["formamide_molar"] <= 30.0:
307
+ raise ConfigurationError(
308
+ "formamide_molar must be in [0, 30].", code="INVALID_COSOLVENT_CORRECTION"
309
+ )
310
+ gc_fraction = sum(base in "GC" for base in symbols) / len(symbols)
311
+ dmso_delta = -(
312
+ resolved_values["dmso_factor_celsius_per_percent"] * resolved_values["dmso_percent"]
313
+ )
314
+ formamide_delta = (0.453 * gc_fraction - 2.88) * resolved_values["formamide_molar"]
315
+ corrected = resolved_values["uncorrected_tm_celsius"] + dmso_delta + formamide_delta
316
+ return CosolventCorrectionResult(
317
+ uncorrected_tm_celsius=resolved_values["uncorrected_tm_celsius"],
318
+ dmso_percent=resolved_values["dmso_percent"],
319
+ dmso_factor_celsius_per_percent=resolved_values["dmso_factor_celsius_per_percent"],
320
+ dmso_delta_tm_celsius=dmso_delta,
321
+ formamide_molar=resolved_values["formamide_molar"],
322
+ gc_fraction=gc_fraction,
323
+ formamide_delta_tm_celsius=formamide_delta,
324
+ corrected_tm_celsius=corrected,
325
+ method="primer3-manual-empirical-cosolvent-correction-v1",
326
+ applicability=(
327
+ "Empirical additive correction only; DMSO and formamide effects are not a "
328
+ "mechanistic nearest-neighbor free-energy model."
329
+ ),
330
+ provenance=native_provenance(citation=False),
331
+ )
332
+
333
+
334
+ __all__ = [
335
+ "MeltingCurveProgress",
336
+ "binding_equilibrium",
337
+ "cosolvent_tm_correction",
338
+ "terminal_stability",
339
+ "theoretical_melting_curve",
340
+ ]