dnakit 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dnakit/__init__.py +108 -0
- dnakit/_version.py +5 -0
- dnakit/alignment/__init__.py +6 -0
- dnakit/alignment/pairwise.py +512 -0
- dnakit/alignment/results.py +188 -0
- dnakit/backends/__init__.py +80 -0
- dnakit/backends/external.py +517 -0
- dnakit/backends/registry.py +207 -0
- dnakit/batch.py +452 -0
- dnakit/cache/__init__.py +5 -0
- dnakit/cache/store.py +424 -0
- dnakit/chunking.py +920 -0
- dnakit/cli/__init__.py +5 -0
- dnakit/cli/__main__.py +6 -0
- dnakit/cli/app.py +814 -0
- dnakit/cli/workflow.py +94 -0
- dnakit/config/__init__.py +5 -0
- dnakit/config/loader.py +381 -0
- dnakit/core/__init__.py +80 -0
- dnakit/core/_json.py +206 -0
- dnakit/core/backend_info.py +70 -0
- dnakit/core/collection.py +163 -0
- dnakit/core/coordinates.py +295 -0
- dnakit/core/enums.py +95 -0
- dnakit/core/facade.py +719 -0
- dnakit/core/feature.py +90 -0
- dnakit/core/gap.py +82 -0
- dnakit/core/issues.py +62 -0
- dnakit/core/provenance.py +305 -0
- dnakit/core/record.py +161 -0
- dnakit/core/results.py +134 -0
- dnakit/core/sequence.py +327 -0
- dnakit/datasets/__init__.py +100 -0
- dnakit/datasets/_advanced_shared.py +198 -0
- dnakit/datasets/_metadata.py +33 -0
- dnakit/datasets/advanced_deduplicate.py +183 -0
- dnakit/datasets/advanced_split.py +302 -0
- dnakit/datasets/clustering.py +286 -0
- dnakit/datasets/config.py +531 -0
- dnakit/datasets/deduplicate.py +303 -0
- dnakit/datasets/evaluation.py +244 -0
- dnakit/datasets/filter.py +135 -0
- dnakit/datasets/neural_clustering.py +221 -0
- dnakit/datasets/results.py +520 -0
- dnakit/datasets/split.py +450 -0
- dnakit/descriptors/__init__.py +102 -0
- dnakit/descriptors/_dinucleotide.py +345 -0
- dnakit/descriptors/_shared.py +180 -0
- dnakit/descriptors/all.py +917 -0
- dnakit/descriptors/basic.py +193 -0
- dnakit/descriptors/codon.py +195 -0
- dnakit/descriptors/complexity.py +201 -0
- dnakit/descriptors/entropy.py +101 -0
- dnakit/descriptors/homopolymer.py +105 -0
- dnakit/descriptors/kmer.py +88 -0
- dnakit/descriptors/results.py +286 -0
- dnakit/descriptors/schema.py +414 -0
- dnakit/descriptors/window.py +203 -0
- dnakit/download/__init__.py +54 -0
- dnakit/download/catalogs.py +166 -0
- dnakit/download/ena.py +131 -0
- dnakit/download/encode.py +103 -0
- dnakit/download/files.py +469 -0
- dnakit/download/indexes.py +233 -0
- dnakit/download/metadata.py +164 -0
- dnakit/download/models.py +227 -0
- dnakit/download/ncbi.py +333 -0
- dnakit/download/sequence.py +133 -0
- dnakit/download/ucsc.py +99 -0
- dnakit/evaluation/__init__.py +81 -0
- dnakit/evaluation/_shared.py +288 -0
- dnakit/evaluation/collection.py +270 -0
- dnakit/evaluation/config.py +591 -0
- dnakit/evaluation/distribution.py +287 -0
- dnakit/evaluation/frechet.py +223 -0
- dnakit/evaluation/generative.py +381 -0
- dnakit/evaluation/reference.py +409 -0
- dnakit/evaluation/results.py +137 -0
- dnakit/evaluation/scorecard.py +211 -0
- dnakit/evaluation/sequence.py +403 -0
- dnakit/evaluation/synthesis.py +249 -0
- dnakit/exceptions.py +189 -0
- dnakit/fingerprints/__init__.py +109 -0
- dnakit/fingerprints/_shared.py +116 -0
- dnakit/fingerprints/advanced.py +829 -0
- dnakit/fingerprints/bit.py +229 -0
- dnakit/fingerprints/encoding.py +351 -0
- dnakit/fingerprints/kmer.py +170 -0
- dnakit/fingerprints/results.py +531 -0
- dnakit/fingerprints/sketch.py +246 -0
- dnakit/io/__init__.py +112 -0
- dnakit/io/_advanced_common.py +105 -0
- dnakit/io/_formats.py +994 -0
- dnakit/io/annotations.py +1179 -0
- dnakit/io/api.py +759 -0
- dnakit/io/config.py +315 -0
- dnakit/io/genbank.py +523 -0
- dnakit/io/indexed.py +1146 -0
- dnakit/io/metadata.py +452 -0
- dnakit/io/parquet.py +56 -0
- dnakit/io/results.py +131 -0
- dnakit/io/source.py +138 -0
- dnakit/io/tables.py +1237 -0
- dnakit/molbio/__init__.py +101 -0
- dnakit/molbio/_shared.py +266 -0
- dnakit/molbio/assembly.py +279 -0
- dnakit/molbio/crispr.py +181 -0
- dnakit/molbio/optimization.py +897 -0
- dnakit/molbio/primer3_design.py +667 -0
- dnakit/molbio/primers.py +899 -0
- dnakit/molbio/restriction.py +573 -0
- dnakit/molbio/results.py +583 -0
- dnakit/ops/__init__.py +588 -0
- dnakit/ops/_common.py +201 -0
- dnakit/ops/circular.py +193 -0
- dnakit/ops/concat.py +204 -0
- dnakit/ops/direction.py +27 -0
- dnakit/ops/edit.py +443 -0
- dnakit/ops/evolution.py +991 -0
- dnakit/ops/mutation.py +151 -0
- dnakit/ops/records.py +1012 -0
- dnakit/ops/translation.py +257 -0
- dnakit/patterns/__init__.py +81 -0
- dnakit/patterns/_shared.py +282 -0
- dnakit/patterns/coding.py +360 -0
- dnakit/patterns/crispr.py +337 -0
- dnakit/patterns/motif.py +721 -0
- dnakit/patterns/regions.py +341 -0
- dnakit/patterns/repeats.py +480 -0
- dnakit/patterns/restriction.py +281 -0
- dnakit/patterns/results.py +411 -0
- dnakit/py.typed +1 -0
- dnakit/references/__init__.py +27 -0
- dnakit/references/models.py +155 -0
- dnakit/references/ncbi.py +785 -0
- dnakit/representations/__init__.py +29 -0
- dnakit/representations/backends.py +928 -0
- dnakit/representations/checkpoints.py +356 -0
- dnakit/representations/extraction.py +220 -0
- dnakit/representations/models.py +395 -0
- dnakit/representations/results.py +79 -0
- dnakit/search/__init__.py +132 -0
- dnakit/search/_http.py +271 -0
- dnakit/search/_shared.py +108 -0
- dnakit/search/blast.py +516 -0
- dnakit/search/ena.py +245 -0
- dnakit/search/encode.py +102 -0
- dnakit/search/ensembl.py +874 -0
- dnakit/search/models.py +159 -0
- dnakit/search/ncbi.py +756 -0
- dnakit/search/ucsc.py +388 -0
- dnakit/secondary_structure/__init__.py +38 -0
- dnakit/secondary_structure/dotbracket.py +410 -0
- dnakit/secondary_structure/nupack.py +589 -0
- dnakit/secondary_structure/results.py +142 -0
- dnakit/similarity/__init__.py +125 -0
- dnakit/similarity/_shared.py +152 -0
- dnakit/similarity/approximate.py +258 -0
- dnakit/similarity/compare.py +162 -0
- dnakit/similarity/dashing.py +936 -0
- dnakit/similarity/distance.py +279 -0
- dnakit/similarity/index.py +365 -0
- dnakit/similarity/matrix.py +254 -0
- dnakit/similarity/results.py +676 -0
- dnakit/similarity/search.py +286 -0
- dnakit/similarity/sketch.py +96 -0
- dnakit/similarity/vector.py +662 -0
- dnakit/standardize/__init__.py +77 -0
- dnakit/standardize/_shared.py +100 -0
- dnakit/standardize/config.py +411 -0
- dnakit/standardize/gaps.py +1060 -0
- dnakit/standardize/normalize.py +565 -0
- dnakit/standardize/results.py +247 -0
- dnakit/standardize/validate.py +487 -0
- dnakit/structure3d/__init__.py +47 -0
- dnakit/structure3d/_shared.py +47 -0
- dnakit/structure3d/geometry.py +773 -0
- dnakit/structure3d/pdb.py +253 -0
- dnakit/structure3d/results.py +233 -0
- dnakit/structure3d/threedna.py +174 -0
- dnakit/thermodynamics/__init__.py +112 -0
- dnakit/thermodynamics/_shared.py +92 -0
- dnakit/thermodynamics/backends.py +653 -0
- dnakit/thermodynamics/calculations.py +837 -0
- dnakit/thermodynamics/config.py +175 -0
- dnakit/thermodynamics/equilibrium.py +340 -0
- dnakit/thermodynamics/optics.py +385 -0
- dnakit/thermodynamics/parameters.py +79 -0
- dnakit/thermodynamics/results.py +445 -0
- dnakit/visualization/__init__.py +43 -0
- dnakit/visualization/_svg.py +185 -0
- dnakit/visualization/advanced.py +314 -0
- dnakit/visualization/config.py +255 -0
- dnakit/visualization/export.py +349 -0
- dnakit/visualization/matrix.py +279 -0
- dnakit/visualization/report.py +431 -0
- dnakit/visualization/results.py +236 -0
- dnakit/visualization/sequence.py +459 -0
- dnakit/workflows/__init__.py +45 -0
- dnakit/workflows/manifest.py +365 -0
- dnakit/workflows/runner.py +1548 -0
- dnakit/workflows/schema.py +898 -0
- dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
- dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
- dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
- dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
- dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
- dnakit-0.1.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
"""Explicit adapter for a separately licensed and installed NUPACK 4 module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import importlib.util
|
|
7
|
+
import math
|
|
8
|
+
from collections.abc import Iterable, Mapping
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, distribution, version
|
|
10
|
+
from itertools import islice
|
|
11
|
+
from typing import Any, Literal, cast
|
|
12
|
+
|
|
13
|
+
from dnakit.core import (
|
|
14
|
+
BackendInfo,
|
|
15
|
+
Citation,
|
|
16
|
+
DNASequence,
|
|
17
|
+
ExecutionMode,
|
|
18
|
+
ImplementationInfo,
|
|
19
|
+
ImplementationLabel,
|
|
20
|
+
OriginClass,
|
|
21
|
+
Provenance,
|
|
22
|
+
)
|
|
23
|
+
from dnakit.exceptions import BackendExecutionError, BackendUnavailableError, ConfigurationError
|
|
24
|
+
from dnakit.thermodynamics import ThermodynamicConditions
|
|
25
|
+
|
|
26
|
+
from .dotbracket import _strands, analyze_dot_bracket, pair_probability_metrics
|
|
27
|
+
from .results import (
|
|
28
|
+
ComplexConcentration,
|
|
29
|
+
NupackComplexResult,
|
|
30
|
+
NupackTubeResult,
|
|
31
|
+
PredictedSecondaryStructure,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_NUPACK_CAPABILITIES = frozenset(
|
|
35
|
+
{
|
|
36
|
+
"pfunc",
|
|
37
|
+
"mfe",
|
|
38
|
+
"pairs",
|
|
39
|
+
"subopt",
|
|
40
|
+
"sample",
|
|
41
|
+
"ensemble_size",
|
|
42
|
+
"structure_probability",
|
|
43
|
+
"ensemble_defect",
|
|
44
|
+
"tube_concentrations",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
_MAX_SAMPLES = 100_000
|
|
48
|
+
_MAX_TUBE_STRANDS = 20
|
|
49
|
+
_MAX_COMPLEX_SIZE = 4
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def probe_nupack() -> BackendInfo:
|
|
53
|
+
"""Passively locate NUPACK without importing, installing, or downloading it."""
|
|
54
|
+
|
|
55
|
+
available = importlib.util.find_spec("nupack") is not None
|
|
56
|
+
package_version: str | None = None
|
|
57
|
+
package_location: str | None = None
|
|
58
|
+
if available:
|
|
59
|
+
try:
|
|
60
|
+
package_version = version("nupack")
|
|
61
|
+
package_location = str(distribution("nupack").locate_file("nupack"))
|
|
62
|
+
except PackageNotFoundError:
|
|
63
|
+
available = False
|
|
64
|
+
return BackendInfo(
|
|
65
|
+
"nupack",
|
|
66
|
+
version=package_version,
|
|
67
|
+
package_location=package_location,
|
|
68
|
+
license_expression="LicenseRef-NUPACK",
|
|
69
|
+
capabilities=_NUPACK_CAPABILITIES,
|
|
70
|
+
available=available,
|
|
71
|
+
metadata={
|
|
72
|
+
"adapter_status": "execution-enabled-only-when-user-licensed-and-installed",
|
|
73
|
+
"automatic_install": False,
|
|
74
|
+
"automatic_download": False,
|
|
75
|
+
"import_executed": False,
|
|
76
|
+
"separate_license_or_subscription_required": True,
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _provenance(info: BackendInfo) -> Provenance:
|
|
82
|
+
dependencies = {"nupack": info.version} if info.version is not None else {}
|
|
83
|
+
return Provenance(
|
|
84
|
+
dependency_versions=dependencies,
|
|
85
|
+
implementation=ImplementationInfo(
|
|
86
|
+
label=ImplementationLabel.ADAPTER,
|
|
87
|
+
execution_mode=ExecutionMode.HYBRID,
|
|
88
|
+
origin_class=OriginClass.INTEGRATION,
|
|
89
|
+
license_expression=info.license_expression,
|
|
90
|
+
citations=(
|
|
91
|
+
Citation(
|
|
92
|
+
"nupack2011",
|
|
93
|
+
title="NUPACK: analysis and design of nucleic acid systems",
|
|
94
|
+
doi="10.1002/jcc.21596",
|
|
95
|
+
),
|
|
96
|
+
),
|
|
97
|
+
),
|
|
98
|
+
backend=info,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _finite(value: object, field: str) -> float:
|
|
103
|
+
if isinstance(value, (bool, str, bytes)):
|
|
104
|
+
raise BackendExecutionError(
|
|
105
|
+
f"NUPACK output {field} must be finite numeric data.",
|
|
106
|
+
code="INVALID_NUPACK_OUTPUT",
|
|
107
|
+
)
|
|
108
|
+
try:
|
|
109
|
+
result = float(value) # type: ignore[arg-type]
|
|
110
|
+
except (TypeError, ValueError, OverflowError) as exc:
|
|
111
|
+
raise BackendExecutionError(
|
|
112
|
+
f"NUPACK output {field} must be finite numeric data.",
|
|
113
|
+
code="INVALID_NUPACK_OUTPUT",
|
|
114
|
+
) from exc
|
|
115
|
+
if not math.isfinite(result):
|
|
116
|
+
raise BackendExecutionError(
|
|
117
|
+
f"NUPACK output {field} must be finite numeric data.",
|
|
118
|
+
code="INVALID_NUPACK_OUTPUT",
|
|
119
|
+
)
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _input_finite(value: object, field: str) -> float:
|
|
124
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
|
125
|
+
raise ConfigurationError(
|
|
126
|
+
f"{field} must be finite numeric data.",
|
|
127
|
+
code="INVALID_NUPACK_CONFIGURATION",
|
|
128
|
+
context={"field": field, "value": value},
|
|
129
|
+
)
|
|
130
|
+
return float(value)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _structure_text(value: object) -> str:
|
|
134
|
+
text = str(value)
|
|
135
|
+
if not text or len(text) > 100_000:
|
|
136
|
+
raise BackendExecutionError(
|
|
137
|
+
"NUPACK returned an invalid structure string.", code="INVALID_NUPACK_OUTPUT"
|
|
138
|
+
)
|
|
139
|
+
return text
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class NupackAdapter:
|
|
143
|
+
"""NUPACK 4 utilities and tube-analysis adapter with bounded outputs."""
|
|
144
|
+
|
|
145
|
+
def __init__(self) -> None:
|
|
146
|
+
self._info = probe_nupack()
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def info(self) -> BackendInfo:
|
|
150
|
+
return self._info
|
|
151
|
+
|
|
152
|
+
def ensure_available(self) -> None:
|
|
153
|
+
if not self.info.available:
|
|
154
|
+
raise BackendUnavailableError(
|
|
155
|
+
"NUPACK is not installed in this environment.",
|
|
156
|
+
code="NUPACK_UNAVAILABLE",
|
|
157
|
+
hint=(
|
|
158
|
+
"Obtain an appropriate NUPACK license/subscription, install the downloaded "
|
|
159
|
+
"package locally, then call this adapter explicitly."
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def _module(self) -> Any:
|
|
164
|
+
self.ensure_available()
|
|
165
|
+
try:
|
|
166
|
+
return importlib.import_module("nupack")
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
raise BackendExecutionError(
|
|
169
|
+
"The installed NUPACK module could not be imported.",
|
|
170
|
+
code="NUPACK_IMPORT_FAILED",
|
|
171
|
+
context={"error_type": type(exc).__name__},
|
|
172
|
+
) from exc
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _conditions(
|
|
176
|
+
value: ThermodynamicConditions | None,
|
|
177
|
+
) -> ThermodynamicConditions:
|
|
178
|
+
resolved = ThermodynamicConditions(sodium_molar=1.0) if value is None else value
|
|
179
|
+
if not isinstance(resolved, ThermodynamicConditions):
|
|
180
|
+
raise ConfigurationError(
|
|
181
|
+
"conditions must be ThermodynamicConditions or None.",
|
|
182
|
+
code="INVALID_NUPACK_CONDITIONS",
|
|
183
|
+
)
|
|
184
|
+
if not 0.05 <= resolved.monovalent_molar <= 1.1:
|
|
185
|
+
raise ConfigurationError(
|
|
186
|
+
"NUPACK DNA monovalent concentration must be in [0.05, 1.1] M.",
|
|
187
|
+
code="NUPACK_SALT_OUT_OF_DOMAIN",
|
|
188
|
+
)
|
|
189
|
+
if not 0.0 <= resolved.magnesium_molar <= 0.2:
|
|
190
|
+
raise ConfigurationError(
|
|
191
|
+
"NUPACK DNA magnesium concentration must be in [0, 0.2] M.",
|
|
192
|
+
code="NUPACK_SALT_OUT_OF_DOMAIN",
|
|
193
|
+
)
|
|
194
|
+
if (
|
|
195
|
+
resolved.dntp_molar != 0.0
|
|
196
|
+
or resolved.dmso_percent != 0.0
|
|
197
|
+
or resolved.formamide_molar != 0.0
|
|
198
|
+
):
|
|
199
|
+
raise ConfigurationError(
|
|
200
|
+
"The NUPACK adapter does not map dNTP, DMSO, or formamide fields.",
|
|
201
|
+
code="NUPACK_CONDITION_UNSUPPORTED",
|
|
202
|
+
)
|
|
203
|
+
return resolved
|
|
204
|
+
|
|
205
|
+
@staticmethod
|
|
206
|
+
def _model(module: Any, conditions: ThermodynamicConditions, ensemble: str) -> object:
|
|
207
|
+
if ensemble not in {"stacking", "dangle-stacking", "coaxial-stacking", "nostacking"}:
|
|
208
|
+
raise ConfigurationError(
|
|
209
|
+
"Unknown NUPACK ensemble.",
|
|
210
|
+
code="INVALID_NUPACK_ENSEMBLE",
|
|
211
|
+
context={"ensemble": ensemble},
|
|
212
|
+
)
|
|
213
|
+
try:
|
|
214
|
+
return module.Model(
|
|
215
|
+
material="dna",
|
|
216
|
+
ensemble=ensemble,
|
|
217
|
+
celsius=conditions.temperature_celsius,
|
|
218
|
+
sodium=conditions.monovalent_molar,
|
|
219
|
+
magnesium=conditions.magnesium_molar,
|
|
220
|
+
)
|
|
221
|
+
except Exception as exc:
|
|
222
|
+
raise BackendExecutionError(
|
|
223
|
+
"NUPACK model construction failed.",
|
|
224
|
+
code="NUPACK_MODEL_FAILED",
|
|
225
|
+
context={"error_type": type(exc).__name__},
|
|
226
|
+
) from exc
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def _predicted_structures(
|
|
230
|
+
raw_values: object,
|
|
231
|
+
strands: tuple[DNASequence, ...],
|
|
232
|
+
*,
|
|
233
|
+
maximum: int,
|
|
234
|
+
) -> tuple[PredictedSecondaryStructure, ...]:
|
|
235
|
+
if isinstance(raw_values, (str, bytes)) or not isinstance(raw_values, Iterable):
|
|
236
|
+
raise BackendExecutionError(
|
|
237
|
+
"NUPACK returned a non-iterable structure collection.",
|
|
238
|
+
code="INVALID_NUPACK_OUTPUT",
|
|
239
|
+
)
|
|
240
|
+
items = tuple(islice(iter(raw_values), maximum + 1))
|
|
241
|
+
if len(items) > maximum:
|
|
242
|
+
raise BackendExecutionError(
|
|
243
|
+
"NUPACK structure output exceeded the configured bound.",
|
|
244
|
+
code="NUPACK_OUTPUT_LIMIT_EXCEEDED",
|
|
245
|
+
)
|
|
246
|
+
results: list[PredictedSecondaryStructure] = []
|
|
247
|
+
for item in items:
|
|
248
|
+
typed_item = cast(Any, item)
|
|
249
|
+
try:
|
|
250
|
+
structure_text = _structure_text(typed_item.structure)
|
|
251
|
+
energy = _finite(typed_item.energy, "energy")
|
|
252
|
+
raw_stack_energy = typed_item.stack_energy
|
|
253
|
+
stack_energy = (
|
|
254
|
+
None if raw_stack_energy is None else _finite(raw_stack_energy, "stack_energy")
|
|
255
|
+
)
|
|
256
|
+
except AttributeError as exc:
|
|
257
|
+
raise BackendExecutionError(
|
|
258
|
+
"NUPACK structure output omitted a required field.",
|
|
259
|
+
code="INVALID_NUPACK_OUTPUT",
|
|
260
|
+
) from exc
|
|
261
|
+
results.append(
|
|
262
|
+
PredictedSecondaryStructure(
|
|
263
|
+
summary=analyze_dot_bracket(strands, structure_text),
|
|
264
|
+
free_energy_kcal_per_mol=energy,
|
|
265
|
+
stack_free_energy_kcal_per_mol=stack_energy,
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
return tuple(results)
|
|
269
|
+
|
|
270
|
+
def analyze_complex(
|
|
271
|
+
self,
|
|
272
|
+
strands: Iterable[DNASequence],
|
|
273
|
+
*,
|
|
274
|
+
conditions: ThermodynamicConditions | None = None,
|
|
275
|
+
ensemble: Literal[
|
|
276
|
+
"stacking", "dangle-stacking", "coaxial-stacking", "nostacking"
|
|
277
|
+
] = "stacking",
|
|
278
|
+
target_structure: str | None = None,
|
|
279
|
+
suboptimal_energy_gap_kcal_per_mol: float = 1.0,
|
|
280
|
+
num_samples: int = 100,
|
|
281
|
+
accessibility_window_size: int = 4,
|
|
282
|
+
) -> NupackComplexResult:
|
|
283
|
+
"""Run bounded NUPACK single-complex ensemble utilities."""
|
|
284
|
+
|
|
285
|
+
strand_symbols = _strands(strands)
|
|
286
|
+
strand_objects = tuple(DNASequence(item) for item in strand_symbols)
|
|
287
|
+
resolved = self._conditions(conditions)
|
|
288
|
+
if (
|
|
289
|
+
isinstance(num_samples, bool)
|
|
290
|
+
or not isinstance(num_samples, int)
|
|
291
|
+
or not 0 <= num_samples <= _MAX_SAMPLES
|
|
292
|
+
):
|
|
293
|
+
raise ConfigurationError(
|
|
294
|
+
f"num_samples must be an integer in [0, {_MAX_SAMPLES}].",
|
|
295
|
+
code="INVALID_NUPACK_SAMPLE_COUNT",
|
|
296
|
+
)
|
|
297
|
+
gap = _input_finite(suboptimal_energy_gap_kcal_per_mol, "suboptimal_energy_gap")
|
|
298
|
+
if not 0.0 <= gap <= 20.0:
|
|
299
|
+
raise ConfigurationError(
|
|
300
|
+
"suboptimal_energy_gap_kcal_per_mol must be in [0, 20].",
|
|
301
|
+
code="INVALID_NUPACK_ENERGY_GAP",
|
|
302
|
+
)
|
|
303
|
+
target = (
|
|
304
|
+
None
|
|
305
|
+
if target_structure is None
|
|
306
|
+
else analyze_dot_bracket(strand_objects, target_structure)
|
|
307
|
+
)
|
|
308
|
+
module = self._module()
|
|
309
|
+
model = self._model(module, resolved, ensemble)
|
|
310
|
+
sequence_list = list(strand_symbols)
|
|
311
|
+
try:
|
|
312
|
+
pfunc_raw = module.pfunc(strands=sequence_list, model=model)
|
|
313
|
+
ensemble_free_energy = _finite(pfunc_raw[1], "ensemble_free_energy")
|
|
314
|
+
partition_function = pfunc_raw[0]
|
|
315
|
+
if hasattr(partition_function, "log"):
|
|
316
|
+
partition_log = _finite(partition_function.log(), "partition_function_log")
|
|
317
|
+
else:
|
|
318
|
+
partition_log = math.log(_finite(partition_function, "partition_function"))
|
|
319
|
+
mfe_raw = module.mfe(strands=sequence_list, model=model)
|
|
320
|
+
pairs_raw = module.pairs(strands=sequence_list, model=model)
|
|
321
|
+
array = pairs_raw.to_array()
|
|
322
|
+
matrix = array.tolist() if hasattr(array, "tolist") else array
|
|
323
|
+
suboptimal_raw = module.subopt(
|
|
324
|
+
strands=sequence_list,
|
|
325
|
+
energy_gap=gap,
|
|
326
|
+
model=model,
|
|
327
|
+
)
|
|
328
|
+
samples_raw = (
|
|
329
|
+
[]
|
|
330
|
+
if num_samples == 0
|
|
331
|
+
else module.sample(
|
|
332
|
+
strands=sequence_list,
|
|
333
|
+
num_sample=num_samples,
|
|
334
|
+
model=model,
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
ensemble_size = int(module.ensemble_size(strands=sequence_list, model=model))
|
|
338
|
+
target_probability = (
|
|
339
|
+
None
|
|
340
|
+
if target_structure is None
|
|
341
|
+
else _finite(
|
|
342
|
+
module.structure_probability(
|
|
343
|
+
strands=sequence_list,
|
|
344
|
+
structure=target_structure,
|
|
345
|
+
model=model,
|
|
346
|
+
),
|
|
347
|
+
"target_structure_probability",
|
|
348
|
+
)
|
|
349
|
+
)
|
|
350
|
+
target_defect = (
|
|
351
|
+
None
|
|
352
|
+
if target_structure is None
|
|
353
|
+
else _finite(
|
|
354
|
+
module.defect(
|
|
355
|
+
strands=sequence_list,
|
|
356
|
+
structure=target_structure,
|
|
357
|
+
model=model,
|
|
358
|
+
),
|
|
359
|
+
"target_ensemble_defect",
|
|
360
|
+
)
|
|
361
|
+
)
|
|
362
|
+
except (BackendExecutionError, ConfigurationError):
|
|
363
|
+
raise
|
|
364
|
+
except Exception as exc:
|
|
365
|
+
raise BackendExecutionError(
|
|
366
|
+
"NUPACK complex analysis failed.",
|
|
367
|
+
code="NUPACK_EXECUTION_FAILED",
|
|
368
|
+
context={"error_type": type(exc).__name__},
|
|
369
|
+
) from exc
|
|
370
|
+
if ensemble_size < 1:
|
|
371
|
+
raise BackendExecutionError(
|
|
372
|
+
"NUPACK ensemble_size must be positive.", code="INVALID_NUPACK_OUTPUT"
|
|
373
|
+
)
|
|
374
|
+
mfe_structures = self._predicted_structures(mfe_raw, strand_objects, maximum=100)
|
|
375
|
+
if not mfe_structures:
|
|
376
|
+
raise BackendExecutionError(
|
|
377
|
+
"NUPACK returned no MFE structure.", code="INVALID_NUPACK_OUTPUT"
|
|
378
|
+
)
|
|
379
|
+
suboptimal = self._predicted_structures(
|
|
380
|
+
suboptimal_raw,
|
|
381
|
+
strand_objects,
|
|
382
|
+
maximum=100_000,
|
|
383
|
+
)
|
|
384
|
+
if isinstance(samples_raw, (str, bytes)) or not isinstance(samples_raw, Iterable):
|
|
385
|
+
raise BackendExecutionError(
|
|
386
|
+
"NUPACK samples output is invalid.", code="INVALID_NUPACK_OUTPUT"
|
|
387
|
+
)
|
|
388
|
+
sample_items = tuple(islice(iter(samples_raw), num_samples + 1))
|
|
389
|
+
if len(sample_items) != num_samples:
|
|
390
|
+
raise BackendExecutionError(
|
|
391
|
+
"NUPACK sample count does not match the request.",
|
|
392
|
+
code="INVALID_NUPACK_OUTPUT",
|
|
393
|
+
)
|
|
394
|
+
samples = tuple(_structure_text(item) for item in sample_items)
|
|
395
|
+
probability_result = pair_probability_metrics(
|
|
396
|
+
strand_objects,
|
|
397
|
+
cast(Iterable[Iterable[float]], matrix),
|
|
398
|
+
accessibility_window_size=accessibility_window_size,
|
|
399
|
+
)
|
|
400
|
+
if target_probability is not None and not 0.0 <= target_probability <= 1.0:
|
|
401
|
+
raise BackendExecutionError(
|
|
402
|
+
"NUPACK target probability is outside [0, 1].",
|
|
403
|
+
code="INVALID_NUPACK_OUTPUT",
|
|
404
|
+
)
|
|
405
|
+
if target_defect is not None and not 0.0 <= target_defect <= 1.0:
|
|
406
|
+
raise BackendExecutionError(
|
|
407
|
+
"NUPACK normalized ensemble defect is outside [0, 1].",
|
|
408
|
+
code="INVALID_NUPACK_OUTPUT",
|
|
409
|
+
)
|
|
410
|
+
return NupackComplexResult(
|
|
411
|
+
strands_5to3=strand_symbols,
|
|
412
|
+
material="dna",
|
|
413
|
+
temperature_celsius=resolved.temperature_celsius,
|
|
414
|
+
monovalent_molar=resolved.monovalent_molar,
|
|
415
|
+
magnesium_molar=resolved.magnesium_molar,
|
|
416
|
+
ensemble=ensemble,
|
|
417
|
+
partition_function_log=partition_log,
|
|
418
|
+
ensemble_free_energy_kcal_per_mol=ensemble_free_energy,
|
|
419
|
+
mfe_structures=mfe_structures,
|
|
420
|
+
pair_probabilities=probability_result,
|
|
421
|
+
suboptimal_structures=suboptimal,
|
|
422
|
+
boltzmann_samples=samples,
|
|
423
|
+
ensemble_size=ensemble_size,
|
|
424
|
+
target_structure=target,
|
|
425
|
+
target_structure_probability=target_probability,
|
|
426
|
+
target_ensemble_defect=target_defect,
|
|
427
|
+
method="nupack-4-utilities-adapter-v1",
|
|
428
|
+
backend=self.info,
|
|
429
|
+
provenance=_provenance(self.info),
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
def analyze_tube(
|
|
433
|
+
self,
|
|
434
|
+
strands: Mapping[str, DNASequence],
|
|
435
|
+
concentrations_molar: Mapping[str, float],
|
|
436
|
+
*,
|
|
437
|
+
target_strand_names: Iterable[str],
|
|
438
|
+
conditions: ThermodynamicConditions | None = None,
|
|
439
|
+
max_complex_size: int = 2,
|
|
440
|
+
) -> NupackTubeResult:
|
|
441
|
+
"""Calculate target and off-target equilibrium complex concentrations."""
|
|
442
|
+
|
|
443
|
+
if not isinstance(strands, Mapping) or not strands:
|
|
444
|
+
raise ConfigurationError(
|
|
445
|
+
"strands must be a non-empty name-to-DNASequence mapping.",
|
|
446
|
+
code="INVALID_NUPACK_TUBE_STRANDS",
|
|
447
|
+
)
|
|
448
|
+
if len(strands) > _MAX_TUBE_STRANDS or any(
|
|
449
|
+
not isinstance(name, str) or not name.strip() or not isinstance(sequence, DNASequence)
|
|
450
|
+
for name, sequence in strands.items()
|
|
451
|
+
):
|
|
452
|
+
raise ConfigurationError(
|
|
453
|
+
f"NUPACK tube strand names and sequences are invalid or exceed "
|
|
454
|
+
f"the {_MAX_TUBE_STRANDS}-strand limit.",
|
|
455
|
+
code="INVALID_NUPACK_TUBE_STRANDS",
|
|
456
|
+
)
|
|
457
|
+
if set(concentrations_molar) != set(strands):
|
|
458
|
+
raise ConfigurationError(
|
|
459
|
+
"concentrations_molar keys must exactly match strands.",
|
|
460
|
+
code="NUPACK_TUBE_CONCENTRATION_KEYS",
|
|
461
|
+
)
|
|
462
|
+
names = tuple(strands)
|
|
463
|
+
sequences = _strands(tuple(strands[name] for name in names))
|
|
464
|
+
concentrations = tuple(_input_finite(concentrations_molar[name], name) for name in names)
|
|
465
|
+
if any(value <= 0.0 or value > 1.0 for value in concentrations):
|
|
466
|
+
raise ConfigurationError(
|
|
467
|
+
"Every strand concentration must be in (0, 1] M.",
|
|
468
|
+
code="INVALID_NUPACK_TUBE_CONCENTRATION",
|
|
469
|
+
)
|
|
470
|
+
if isinstance(target_strand_names, (str, bytes)) or not isinstance(
|
|
471
|
+
target_strand_names, Iterable
|
|
472
|
+
):
|
|
473
|
+
raise ConfigurationError(
|
|
474
|
+
"target_strand_names must be an iterable.",
|
|
475
|
+
code="INVALID_NUPACK_TARGET_COMPLEX",
|
|
476
|
+
)
|
|
477
|
+
target_names = tuple(islice(iter(target_strand_names), _MAX_COMPLEX_SIZE + 1))
|
|
478
|
+
if not target_names or any(name not in strands for name in target_names):
|
|
479
|
+
raise ConfigurationError(
|
|
480
|
+
"Target complex contains an unknown strand name.",
|
|
481
|
+
code="INVALID_NUPACK_TARGET_COMPLEX",
|
|
482
|
+
)
|
|
483
|
+
if (
|
|
484
|
+
isinstance(max_complex_size, bool)
|
|
485
|
+
or not isinstance(max_complex_size, int)
|
|
486
|
+
or not 1 <= max_complex_size <= _MAX_COMPLEX_SIZE
|
|
487
|
+
or len(target_names) > max_complex_size
|
|
488
|
+
):
|
|
489
|
+
raise ConfigurationError(
|
|
490
|
+
f"max_complex_size must be in [1, {_MAX_COMPLEX_SIZE}] and include the target.",
|
|
491
|
+
code="INVALID_NUPACK_COMPLEX_SIZE",
|
|
492
|
+
)
|
|
493
|
+
resolved = self._conditions(conditions)
|
|
494
|
+
module = self._module()
|
|
495
|
+
model = self._model(module, resolved, "stacking")
|
|
496
|
+
try:
|
|
497
|
+
strand_objects = {
|
|
498
|
+
name: module.Strand(sequence, name=name)
|
|
499
|
+
for name, sequence in zip(names, sequences, strict=True)
|
|
500
|
+
}
|
|
501
|
+
target_complex = module.Complex(
|
|
502
|
+
[strand_objects[name] for name in target_names], name="dnakit-target"
|
|
503
|
+
)
|
|
504
|
+
tube = module.Tube(
|
|
505
|
+
strands={
|
|
506
|
+
strand_objects[name]: concentration
|
|
507
|
+
for name, concentration in zip(names, concentrations, strict=True)
|
|
508
|
+
},
|
|
509
|
+
complexes=module.SetSpec(max_size=max_complex_size, include=[target_complex]),
|
|
510
|
+
name="dnakit-tube",
|
|
511
|
+
)
|
|
512
|
+
analysis = module.tube_analysis(tubes=[tube], model=model, compute=["pfunc", "pairs"])
|
|
513
|
+
tube_result = analysis[tube]
|
|
514
|
+
raw_concentrations = tube_result.complex_concentrations
|
|
515
|
+
fraction_unpaired = _finite(
|
|
516
|
+
tube_result.fraction_bases_unpaired, "fraction_bases_unpaired"
|
|
517
|
+
)
|
|
518
|
+
except (BackendExecutionError, ConfigurationError):
|
|
519
|
+
raise
|
|
520
|
+
except Exception as exc:
|
|
521
|
+
raise BackendExecutionError(
|
|
522
|
+
"NUPACK tube analysis failed.",
|
|
523
|
+
code="NUPACK_EXECUTION_FAILED",
|
|
524
|
+
context={"error_type": type(exc).__name__},
|
|
525
|
+
) from exc
|
|
526
|
+
complex_results: list[ComplexConcentration] = []
|
|
527
|
+
target_concentration = 0.0
|
|
528
|
+
for raw_complex, raw_concentration in raw_concentrations.items():
|
|
529
|
+
concentration = _finite(raw_concentration, "complex_concentration")
|
|
530
|
+
if concentration < 0.0 or concentration > math.fsum(concentrations) * (1.0 + 1e-6):
|
|
531
|
+
raise BackendExecutionError(
|
|
532
|
+
"NUPACK returned an invalid complex concentration.",
|
|
533
|
+
code="INVALID_NUPACK_OUTPUT",
|
|
534
|
+
)
|
|
535
|
+
raw_name = getattr(raw_complex, "name", None)
|
|
536
|
+
name = str(raw_name) if raw_name else str(raw_complex)
|
|
537
|
+
raw_complex_strands = getattr(raw_complex, "strands", ())
|
|
538
|
+
complex_strand_names = tuple(
|
|
539
|
+
str(getattr(item, "name", item)) for item in raw_complex_strands
|
|
540
|
+
)
|
|
541
|
+
is_target = raw_complex is target_complex or name == "dnakit-target"
|
|
542
|
+
if is_target:
|
|
543
|
+
target_concentration += concentration
|
|
544
|
+
complex_results.append(
|
|
545
|
+
ComplexConcentration(
|
|
546
|
+
name=name,
|
|
547
|
+
strand_names=complex_strand_names,
|
|
548
|
+
concentration_molar=concentration,
|
|
549
|
+
is_target=is_target,
|
|
550
|
+
)
|
|
551
|
+
)
|
|
552
|
+
total_complex_concentration = math.fsum(
|
|
553
|
+
item.concentration_molar for item in complex_results
|
|
554
|
+
)
|
|
555
|
+
target_fraction = (
|
|
556
|
+
0.0
|
|
557
|
+
if total_complex_concentration == 0.0
|
|
558
|
+
else target_concentration / total_complex_concentration
|
|
559
|
+
)
|
|
560
|
+
if not 0.0 <= target_fraction <= 1.0:
|
|
561
|
+
raise BackendExecutionError(
|
|
562
|
+
"NUPACK target complex fraction is outside [0, 1].",
|
|
563
|
+
code="INVALID_NUPACK_OUTPUT",
|
|
564
|
+
)
|
|
565
|
+
if not 0.0 <= fraction_unpaired <= 1.0:
|
|
566
|
+
raise BackendExecutionError(
|
|
567
|
+
"NUPACK fraction_bases_unpaired is outside [0, 1].",
|
|
568
|
+
code="INVALID_NUPACK_OUTPUT",
|
|
569
|
+
)
|
|
570
|
+
return NupackTubeResult(
|
|
571
|
+
strand_names=names,
|
|
572
|
+
sequences_5to3=sequences,
|
|
573
|
+
input_concentrations_molar=concentrations,
|
|
574
|
+
complex_concentrations=tuple(complex_results),
|
|
575
|
+
target_complex_name="dnakit-target",
|
|
576
|
+
target_strand_names=target_names,
|
|
577
|
+
target_complex_concentration_molar=target_concentration,
|
|
578
|
+
complex_fraction_denominator_molar=total_complex_concentration,
|
|
579
|
+
target_complex_fraction=target_fraction,
|
|
580
|
+
non_target_complex_fraction=1.0 - target_fraction,
|
|
581
|
+
fraction_bases_unpaired=fraction_unpaired,
|
|
582
|
+
max_complex_size=max_complex_size,
|
|
583
|
+
method="nupack-4-tube-analysis-adapter-v1",
|
|
584
|
+
backend=self.info,
|
|
585
|
+
provenance=_provenance(self.info),
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
__all__ = ["NupackAdapter", "probe_nupack"]
|