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,385 @@
1
+ """Optical properties and unit-safe oligonucleotide concentration conversions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Iterable
7
+ from itertools import islice
8
+ from typing import Literal
9
+
10
+ from dnakit.core import DNASequence
11
+ from dnakit.exceptions import ConfigurationError
12
+
13
+ from ._shared import canonical_linear_symbols
14
+ from .calculations import extinction_coefficient_260nm, molecular_weight
15
+ from .results import (
16
+ A260ConcentrationResult,
17
+ LabelAbsorbanceCorrection,
18
+ OligoQuantityResult,
19
+ OpticalModification,
20
+ OpticalPropertiesResult,
21
+ )
22
+
23
+ _AVERAGE_DUPLEX_EXTINCTION_PER_BASE_PAIR = 13_200.0
24
+ _MAX_CORRECTIONS = 1_000
25
+
26
+
27
+ def _finite(
28
+ value: object,
29
+ name: str,
30
+ *,
31
+ minimum: float | None = None,
32
+ strictly_positive: bool = False,
33
+ ) -> float:
34
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
35
+ raise ConfigurationError(
36
+ f"{name} must be finite numeric data.",
37
+ code="INVALID_OPTICAL_VALUE",
38
+ context={"field": name, "value": value},
39
+ )
40
+ result = float(value)
41
+ if strictly_positive and result <= 0.0:
42
+ raise ConfigurationError(
43
+ f"{name} must be greater than zero.",
44
+ code="INVALID_OPTICAL_VALUE",
45
+ context={"field": name, "value": value},
46
+ )
47
+ if minimum is not None and result < minimum:
48
+ raise ConfigurationError(
49
+ f"{name} must be at least {minimum}.",
50
+ code="INVALID_OPTICAL_VALUE",
51
+ context={"field": name, "value": value},
52
+ )
53
+ return result
54
+
55
+
56
+ def _bounded_tuple(
57
+ values: Iterable[object], expected_type: type[object], name: str
58
+ ) -> tuple[object, ...]:
59
+ if isinstance(values, (str, bytes)) or not isinstance(values, Iterable):
60
+ raise ConfigurationError(f"{name} must be an iterable.", code="INVALID_OPTICAL_VALUE")
61
+ resolved = tuple(islice(iter(values), _MAX_CORRECTIONS + 1))
62
+ if len(resolved) > _MAX_CORRECTIONS:
63
+ raise ConfigurationError(
64
+ f"{name} exceeds the {_MAX_CORRECTIONS}-item safety limit.",
65
+ code="OPTICAL_CORRECTION_LIMIT_EXCEEDED",
66
+ )
67
+ if any(not isinstance(item, expected_type) for item in resolved):
68
+ raise ConfigurationError(
69
+ f"Every {name} item must be {expected_type.__name__}.",
70
+ code="INVALID_OPTICAL_VALUE",
71
+ )
72
+ return resolved
73
+
74
+
75
+ def optical_properties(
76
+ sequence: DNASequence,
77
+ *,
78
+ strand_type: Literal["single", "double"] = "single",
79
+ complement: DNASequence | None = None,
80
+ duplex_method: Literal["average-base-pair", "strand-sum-hypochromicity"] = (
81
+ "average-base-pair"
82
+ ),
83
+ hypochromicity_fraction: float | None = None,
84
+ modifications: Iterable[OpticalModification] = (),
85
+ ) -> OpticalPropertiesResult:
86
+ """Calculate epsilon260, molecular weight, nmol/OD and mass/OD.
87
+
88
+ ``average-base-pair`` uses 13,200 M^-1 cm^-1 per base pair and is the
89
+ conventional 1 OD260 = 50 microgram/mL duplex approximation.
90
+ ``strand-sum-hypochromicity`` requires the caller to provide the fractional
91
+ absorbance reduction on duplex formation instead of silently assuming one.
92
+ """
93
+
94
+ if strand_type not in {"single", "double"}:
95
+ raise ConfigurationError(
96
+ "strand_type must be 'single' or 'double'.",
97
+ code="INVALID_OPTICAL_STRAND_TYPE",
98
+ )
99
+ symbols = canonical_linear_symbols(
100
+ sequence,
101
+ operation="optical_properties",
102
+ min_length=1,
103
+ max_length=1_000_000,
104
+ )
105
+ sequence_single = DNASequence(symbols)
106
+ resolved_modifications = _bounded_tuple(modifications, OpticalModification, "modifications")
107
+ typed_modifications = tuple(
108
+ item for item in resolved_modifications if isinstance(item, OpticalModification)
109
+ )
110
+
111
+ sequences: tuple[str, ...]
112
+ assumptions: tuple[str, ...]
113
+ if strand_type == "single":
114
+ if complement is not None:
115
+ raise ConfigurationError(
116
+ "complement is only valid for double-stranded optical properties.",
117
+ code="UNUSED_OPTICAL_COMPLEMENT",
118
+ )
119
+ if hypochromicity_fraction is not None:
120
+ raise ConfigurationError(
121
+ "hypochromicity_fraction is only valid for a duplex strand-sum model.",
122
+ code="UNUSED_HYPOCHROMICITY",
123
+ )
124
+ extinction = extinction_coefficient_260nm(sequence_single)
125
+ native_epsilon = extinction.value_m_inverse_cm_inverse
126
+ mass = molecular_weight(sequence_single).value_dalton
127
+ sequences = (symbols,)
128
+ method = extinction.method
129
+ assumptions = (
130
+ "Single-stranded, linear, canonical and unmodified DNA before explicit corrections.",
131
+ "One OD260 unit means A260 multiplied by sample volume in mL equals one, "
132
+ "using a 1 cm reference path.",
133
+ )
134
+ provenance = extinction.provenance
135
+ else:
136
+ expected_complement = sequence_single.reverse_complement().symbols
137
+ if complement is None:
138
+ complement_symbols = expected_complement
139
+ else:
140
+ complement_symbols = canonical_linear_symbols(
141
+ complement,
142
+ operation="optical_properties complement",
143
+ min_length=1,
144
+ max_length=1_000_000,
145
+ )
146
+ if complement_symbols != expected_complement:
147
+ raise ConfigurationError(
148
+ "The native duplex optical model requires a full reverse complement.",
149
+ code="MISMATCHED_OPTICAL_DUPLEX",
150
+ context={"expected_complement_5to3": expected_complement},
151
+ )
152
+ if duplex_method == "average-base-pair":
153
+ if hypochromicity_fraction is not None:
154
+ raise ConfigurationError(
155
+ "average-base-pair does not accept hypochromicity_fraction.",
156
+ code="UNUSED_HYPOCHROMICITY",
157
+ )
158
+ native_epsilon = _AVERAGE_DUPLEX_EXTINCTION_PER_BASE_PAIR * len(symbols)
159
+ method = "average-dsdna-base-pair-extinction"
160
+ assumptions = (
161
+ "Fully complementary duplex with average epsilon260 of 13,200 M^-1 cm^-1 "
162
+ "per base pair.",
163
+ "This average conversion is not a sequence-specific hypochromicity prediction.",
164
+ "One OD260 unit means A260 multiplied by sample volume in mL equals one, "
165
+ "using a 1 cm reference path.",
166
+ )
167
+ elif duplex_method == "strand-sum-hypochromicity":
168
+ if hypochromicity_fraction is None:
169
+ raise ConfigurationError(
170
+ "strand-sum-hypochromicity requires hypochromicity_fraction.",
171
+ code="MISSING_HYPOCHROMICITY",
172
+ )
173
+ fraction = _finite(
174
+ hypochromicity_fraction,
175
+ "hypochromicity_fraction",
176
+ minimum=0.0,
177
+ )
178
+ if fraction >= 1.0:
179
+ raise ConfigurationError(
180
+ "hypochromicity_fraction must be in [0, 1).",
181
+ code="INVALID_HYPOCHROMICITY",
182
+ )
183
+ primary = extinction_coefficient_260nm(sequence_single)
184
+ secondary = extinction_coefficient_260nm(DNASequence(complement_symbols))
185
+ native_epsilon = (
186
+ primary.value_m_inverse_cm_inverse + secondary.value_m_inverse_cm_inverse
187
+ ) * (1.0 - fraction)
188
+ method = "sequence-specific-strand-sum-with-explicit-hypochromicity"
189
+ assumptions = (
190
+ "Fully complementary duplex.",
191
+ "The hypochromicity fraction is caller-supplied experimental or literature input.",
192
+ "One OD260 unit means A260 multiplied by sample volume in mL equals one, "
193
+ "using a 1 cm reference path.",
194
+ )
195
+ else:
196
+ raise ConfigurationError(
197
+ "Unknown duplex_method.",
198
+ code="UNKNOWN_DUPLEX_OPTICAL_METHOD",
199
+ context={"duplex_method": duplex_method},
200
+ )
201
+ mass_result = molecular_weight(sequence_single, strand="double")
202
+ mass = mass_result.value_dalton
203
+ provenance = mass_result.provenance
204
+ sequences = (symbols, complement_symbols)
205
+
206
+ modification_epsilon = math.fsum(
207
+ item.count * item.extinction_coefficient_260_delta_m_inverse_cm_inverse
208
+ for item in typed_modifications
209
+ )
210
+ modification_mass = math.fsum(
211
+ item.count * item.molecular_weight_delta_dalton for item in typed_modifications
212
+ )
213
+ corrected_epsilon = native_epsilon + modification_epsilon
214
+ corrected_mass = mass + modification_mass
215
+ if corrected_epsilon <= 0.0 or corrected_mass <= 0.0:
216
+ raise ConfigurationError(
217
+ "Modification corrections must leave positive epsilon260 and molecular weight.",
218
+ code="INVALID_OPTICAL_CORRECTION_TOTAL",
219
+ )
220
+ return OpticalPropertiesResult(
221
+ strand_type=strand_type,
222
+ sequences_5to3=sequences,
223
+ sequence_lengths=tuple(len(item) for item in sequences),
224
+ native_extinction_coefficient_260_m_inverse_cm_inverse=native_epsilon,
225
+ modification_extinction_coefficient_260_m_inverse_cm_inverse=modification_epsilon,
226
+ extinction_coefficient_260_m_inverse_cm_inverse=corrected_epsilon,
227
+ molecular_weight_dalton=corrected_mass,
228
+ one_od260_nmol=1_000_000.0 / corrected_epsilon,
229
+ one_od260_microgram=1_000.0 * corrected_mass / corrected_epsilon,
230
+ method=method,
231
+ modifications=typed_modifications,
232
+ assumptions=assumptions,
233
+ provenance=provenance,
234
+ )
235
+
236
+
237
+ def concentration_from_a260(
238
+ measured_a260: float,
239
+ properties: OpticalPropertiesResult,
240
+ *,
241
+ path_length_cm: float = 1.0,
242
+ dilution_factor: float = 1.0,
243
+ label_corrections: Iterable[LabelAbsorbanceCorrection] = (),
244
+ volume_liter: float | None = None,
245
+ ) -> A260ConcentrationResult:
246
+ """Apply dye correction and Beer-Lambert law to an A260 measurement."""
247
+
248
+ absorbance = _finite(measured_a260, "measured_a260", minimum=0.0)
249
+ path = _finite(path_length_cm, "path_length_cm", strictly_positive=True)
250
+ dilution = _finite(dilution_factor, "dilution_factor", strictly_positive=True)
251
+ if not isinstance(properties, OpticalPropertiesResult):
252
+ raise ConfigurationError(
253
+ "properties must be OpticalPropertiesResult.", code="INVALID_OPTICAL_PROPERTIES"
254
+ )
255
+ resolved_corrections = _bounded_tuple(
256
+ label_corrections, LabelAbsorbanceCorrection, "label_corrections"
257
+ )
258
+ typed_corrections = tuple(
259
+ item for item in resolved_corrections if isinstance(item, LabelAbsorbanceCorrection)
260
+ )
261
+ subtracted = math.fsum(
262
+ item.absorbance_at_label_max * item.a260_correction_factor for item in typed_corrections
263
+ )
264
+ corrected = absorbance - subtracted
265
+ if corrected < 0.0:
266
+ raise ConfigurationError(
267
+ "Label correction exceeds the measured A260.",
268
+ code="NEGATIVE_CORRECTED_A260",
269
+ context={"measured_a260": absorbance, "subtracted_a260": subtracted},
270
+ )
271
+ epsilon = properties.extinction_coefficient_260_m_inverse_cm_inverse
272
+ molar = corrected * dilution / (epsilon * path)
273
+ mass_g_per_l = molar * properties.molecular_weight_dalton
274
+ resolved_volume = (
275
+ None
276
+ if volume_liter is None
277
+ else _finite(volume_liter, "volume_liter", strictly_positive=True)
278
+ )
279
+ amount = None if resolved_volume is None else molar * resolved_volume
280
+ mass_microgram = (
281
+ None if resolved_volume is None else mass_g_per_l * resolved_volume * 1_000_000.0
282
+ )
283
+ return A260ConcentrationResult(
284
+ measured_a260=absorbance,
285
+ label_a260_subtracted=subtracted,
286
+ corrected_a260=corrected,
287
+ path_length_cm=path,
288
+ dilution_factor=dilution,
289
+ extinction_coefficient_260_m_inverse_cm_inverse=epsilon,
290
+ molar_concentration_molar=molar,
291
+ molar_concentration_micromolar=molar * 1_000_000.0,
292
+ molar_concentration_nanomolar=molar * 1_000_000_000.0,
293
+ mass_concentration_g_per_l=mass_g_per_l,
294
+ mass_concentration_ng_per_microliter=mass_g_per_l * 1_000.0,
295
+ volume_liter=resolved_volume,
296
+ amount_mol=amount,
297
+ mass_microgram=mass_microgram,
298
+ label_corrections=typed_corrections,
299
+ method="beer-lambert-a260-with-explicit-label-correction",
300
+ provenance=properties.provenance,
301
+ )
302
+
303
+
304
+ def convert_oligo_quantity(
305
+ molecular_weight_dalton: float,
306
+ *,
307
+ volume_liter: float | None = None,
308
+ molar_concentration_molar: float | None = None,
309
+ mass_concentration_g_per_l: float | None = None,
310
+ amount_mol: float | None = None,
311
+ mass_g: float | None = None,
312
+ ) -> OligoQuantityResult:
313
+ """Convert exactly one concentration or amount input into all four forms."""
314
+
315
+ molecular_weight = _finite(
316
+ molecular_weight_dalton, "molecular_weight_dalton", strictly_positive=True
317
+ )
318
+ provided = {
319
+ "molar_concentration_molar": molar_concentration_molar,
320
+ "mass_concentration_g_per_l": mass_concentration_g_per_l,
321
+ "amount_mol": amount_mol,
322
+ "mass_g": mass_g,
323
+ }
324
+ selected = tuple(name for name, value in provided.items() if value is not None)
325
+ if len(selected) != 1:
326
+ raise ConfigurationError(
327
+ "Provide exactly one concentration or amount input.",
328
+ code="OLIGO_QUANTITY_INPUT_COUNT",
329
+ context={"provided_fields": selected},
330
+ )
331
+ input_kind = selected[0]
332
+ raw_value = _finite(provided[input_kind], input_kind, minimum=0.0)
333
+ resolved_volume = (
334
+ None
335
+ if volume_liter is None
336
+ else _finite(volume_liter, "volume_liter", strictly_positive=True)
337
+ )
338
+ molar: float | None
339
+ mass_concentration: float | None
340
+ if (input_kind.endswith("per_l") or input_kind.endswith("molar")) and resolved_volume is None:
341
+ raise ConfigurationError(
342
+ "volume_liter is required when converting a concentration to an amount.",
343
+ code="OLIGO_QUANTITY_VOLUME_REQUIRED",
344
+ )
345
+ if input_kind == "molar_concentration_molar":
346
+ molar = raw_value
347
+ mass_concentration = molar * molecular_weight
348
+ assert resolved_volume is not None
349
+ amount = molar * resolved_volume
350
+ mass = amount * molecular_weight
351
+ elif input_kind == "mass_concentration_g_per_l":
352
+ mass_concentration = raw_value
353
+ molar = mass_concentration / molecular_weight
354
+ assert resolved_volume is not None
355
+ mass = mass_concentration * resolved_volume
356
+ amount = mass / molecular_weight
357
+ elif input_kind == "amount_mol":
358
+ amount = raw_value
359
+ mass = amount * molecular_weight
360
+ molar = None if resolved_volume is None else amount / resolved_volume
361
+ mass_concentration = None if resolved_volume is None else mass / resolved_volume
362
+ else:
363
+ mass = raw_value
364
+ amount = mass / molecular_weight
365
+ molar = None if resolved_volume is None else amount / resolved_volume
366
+ mass_concentration = None if resolved_volume is None else mass / resolved_volume
367
+ return OligoQuantityResult(
368
+ molecular_weight_dalton=molecular_weight,
369
+ volume_liter=resolved_volume,
370
+ molar_concentration_molar=molar,
371
+ mass_concentration_g_per_l=mass_concentration,
372
+ amount_mol=amount,
373
+ amount_nmol=amount * 1_000_000_000.0,
374
+ mass_g=mass,
375
+ mass_microgram=mass * 1_000_000.0,
376
+ input_kind=input_kind,
377
+ method="molar-mass-volume-dimensional-conversion",
378
+ )
379
+
380
+
381
+ __all__ = [
382
+ "concentration_from_a260",
383
+ "convert_oligo_quantity",
384
+ "optical_properties",
385
+ ]
@@ -0,0 +1,79 @@
1
+ """Versioned native DNA/DNA nearest-neighbor parameters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from types import MappingProxyType
7
+ from typing import Any, cast
8
+
9
+ from dnakit.core._json import to_json_compatible
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class NearestNeighborParameter:
14
+ """One enthalpy/entropy contribution in explicit units."""
15
+
16
+ delta_h_kcal_per_mol: float
17
+ delta_s_cal_per_k_mol: float
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class NearestNeighborParameterSet:
22
+ """Immutable identity and values for a published parameter table."""
23
+
24
+ name: str
25
+ version: str
26
+ citation: str
27
+ doi: str
28
+ reference_sodium_molar: float
29
+ stacking: MappingProxyType[str, NearestNeighborParameter]
30
+ terminal_at: NearestNeighborParameter
31
+ terminal_gc: NearestNeighborParameter
32
+ symmetry: NearestNeighborParameter
33
+
34
+ def to_dict(self) -> dict[str, Any]:
35
+ return cast(dict[str, Any], to_json_compatible(self))
36
+
37
+
38
+ _STACKING = {
39
+ "AA": NearestNeighborParameter(-7.9, -22.2),
40
+ "TT": NearestNeighborParameter(-7.9, -22.2),
41
+ "AT": NearestNeighborParameter(-7.2, -20.4),
42
+ "TA": NearestNeighborParameter(-7.2, -21.3),
43
+ "CA": NearestNeighborParameter(-8.5, -22.7),
44
+ "TG": NearestNeighborParameter(-8.5, -22.7),
45
+ "GT": NearestNeighborParameter(-8.4, -22.4),
46
+ "AC": NearestNeighborParameter(-8.4, -22.4),
47
+ "CT": NearestNeighborParameter(-7.8, -21.0),
48
+ "AG": NearestNeighborParameter(-7.8, -21.0),
49
+ "GA": NearestNeighborParameter(-8.2, -22.2),
50
+ "TC": NearestNeighborParameter(-8.2, -22.2),
51
+ "CG": NearestNeighborParameter(-10.6, -27.2),
52
+ "GC": NearestNeighborParameter(-9.8, -24.4),
53
+ "GG": NearestNeighborParameter(-8.0, -19.9),
54
+ "CC": NearestNeighborParameter(-8.0, -19.9),
55
+ }
56
+
57
+ SANTALUCIA_1998 = NearestNeighborParameterSet(
58
+ name="SantaLucia unified DNA/DNA nearest-neighbor",
59
+ version="santalucia1998-v1",
60
+ citation=(
61
+ "SantaLucia J Jr. A unified view of polymer, dumbbell, and "
62
+ "oligonucleotide DNA nearest-neighbor thermodynamics. PNAS 1998."
63
+ ),
64
+ doi="10.1073/pnas.95.4.1460",
65
+ reference_sodium_molar=1.0,
66
+ stacking=MappingProxyType(_STACKING),
67
+ terminal_at=NearestNeighborParameter(2.3, 4.1),
68
+ terminal_gc=NearestNeighborParameter(0.1, -2.8),
69
+ symmetry=NearestNeighborParameter(0.0, -1.4),
70
+ )
71
+
72
+ PARAMETER_SETS = MappingProxyType({SANTALUCIA_1998.version: SANTALUCIA_1998})
73
+
74
+ __all__ = [
75
+ "PARAMETER_SETS",
76
+ "SANTALUCIA_1998",
77
+ "NearestNeighborParameter",
78
+ "NearestNeighborParameterSet",
79
+ ]