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,253 @@
|
|
|
1
|
+
"""Bounded parser for canonical DNA atoms in legacy PDB coordinate files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import math
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from dnakit.exceptions import ConfigurationError, InputFormatError
|
|
11
|
+
|
|
12
|
+
from .results import Atom3D, DNA3DStructure, Residue3D
|
|
13
|
+
|
|
14
|
+
_DNA_RESIDUES = {
|
|
15
|
+
"DA": "A",
|
|
16
|
+
"DC": "C",
|
|
17
|
+
"DG": "G",
|
|
18
|
+
"DT": "T",
|
|
19
|
+
"A": "A",
|
|
20
|
+
"C": "C",
|
|
21
|
+
"G": "G",
|
|
22
|
+
"T": "T",
|
|
23
|
+
"ADE": "A",
|
|
24
|
+
"CYT": "C",
|
|
25
|
+
"GUA": "G",
|
|
26
|
+
"THY": "T",
|
|
27
|
+
}
|
|
28
|
+
_MAX_PDB_BYTES = 100_000_000
|
|
29
|
+
_MAX_ATOMS = 1_000_000
|
|
30
|
+
_MAX_MODELS = 10_000
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _integer(text: str, field: str, line_number: int) -> int:
|
|
34
|
+
try:
|
|
35
|
+
return int(text.strip())
|
|
36
|
+
except ValueError as exc:
|
|
37
|
+
raise InputFormatError(
|
|
38
|
+
f"PDB {field} is not an integer.",
|
|
39
|
+
code="INVALID_PDB_FIELD",
|
|
40
|
+
context={"field": field, "line_number": line_number, "value": text},
|
|
41
|
+
) from exc
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _number(text: str, field: str, line_number: int, *, default: float | None = None) -> float:
|
|
45
|
+
if not text.strip() and default is not None:
|
|
46
|
+
return default
|
|
47
|
+
try:
|
|
48
|
+
value = float(text.strip())
|
|
49
|
+
except ValueError as exc:
|
|
50
|
+
raise InputFormatError(
|
|
51
|
+
f"PDB {field} is not numeric.",
|
|
52
|
+
code="INVALID_PDB_FIELD",
|
|
53
|
+
context={"field": field, "line_number": line_number, "value": text},
|
|
54
|
+
) from exc
|
|
55
|
+
if not math.isfinite(value):
|
|
56
|
+
raise InputFormatError(
|
|
57
|
+
f"PDB {field} must be finite.",
|
|
58
|
+
code="INVALID_PDB_FIELD",
|
|
59
|
+
context={"field": field, "line_number": line_number},
|
|
60
|
+
)
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _element(atom_name: str, explicit: str) -> str:
|
|
65
|
+
if explicit.strip():
|
|
66
|
+
return explicit.strip().upper()
|
|
67
|
+
letters = "".join(character for character in atom_name if character.isalpha())
|
|
68
|
+
return letters[:1].upper() or "X"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _structure(
|
|
72
|
+
*,
|
|
73
|
+
path: Path,
|
|
74
|
+
digest: str,
|
|
75
|
+
pdb_id: str | None,
|
|
76
|
+
title: str | None,
|
|
77
|
+
model_index: int,
|
|
78
|
+
atoms: tuple[Atom3D, ...],
|
|
79
|
+
) -> DNA3DStructure:
|
|
80
|
+
residue_atoms: dict[tuple[str, int, str, str], list[Atom3D]] = {}
|
|
81
|
+
residue_order: list[tuple[str, int, str, str]] = []
|
|
82
|
+
for atom in atoms:
|
|
83
|
+
key = (atom.chain_id, atom.residue_number, atom.insertion_code, atom.residue_name)
|
|
84
|
+
if key not in residue_atoms:
|
|
85
|
+
residue_atoms[key] = []
|
|
86
|
+
residue_order.append(key)
|
|
87
|
+
residue_atoms[key].append(atom)
|
|
88
|
+
residues = tuple(
|
|
89
|
+
Residue3D(
|
|
90
|
+
name=residue_name,
|
|
91
|
+
base=_DNA_RESIDUES[residue_name],
|
|
92
|
+
chain_id=chain_id,
|
|
93
|
+
number=number,
|
|
94
|
+
insertion_code=insertion_code,
|
|
95
|
+
atoms=tuple(residue_atoms[(chain_id, number, insertion_code, residue_name)]),
|
|
96
|
+
)
|
|
97
|
+
for chain_id, number, insertion_code, residue_name in residue_order
|
|
98
|
+
)
|
|
99
|
+
chain_ids = tuple(dict.fromkeys(residue.chain_id for residue in residues))
|
|
100
|
+
sequence_by_chain = tuple(
|
|
101
|
+
"".join(residue.base for residue in residues if residue.chain_id == chain_id)
|
|
102
|
+
for chain_id in chain_ids
|
|
103
|
+
)
|
|
104
|
+
return DNA3DStructure(
|
|
105
|
+
pdb_id=pdb_id,
|
|
106
|
+
title=title,
|
|
107
|
+
model_index=model_index,
|
|
108
|
+
source_path=str(path),
|
|
109
|
+
source_sha256=digest,
|
|
110
|
+
atoms=atoms,
|
|
111
|
+
residues=residues,
|
|
112
|
+
chain_ids=chain_ids,
|
|
113
|
+
sequence_by_chain=sequence_by_chain,
|
|
114
|
+
parser="dnakit-bounded-legacy-pdb-parser-v1",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def load_pdb_ensemble(path: str | Path) -> tuple[DNA3DStructure, ...]:
|
|
119
|
+
"""Load all PDB MODEL records, retaining canonical DNA ATOM records only."""
|
|
120
|
+
|
|
121
|
+
if not isinstance(path, (str, Path)):
|
|
122
|
+
raise ConfigurationError("path must be text or Path.", code="INVALID_PDB_PATH")
|
|
123
|
+
source = Path(path).expanduser().resolve()
|
|
124
|
+
if not source.is_file():
|
|
125
|
+
raise InputFormatError(
|
|
126
|
+
"PDB path is not a readable file.",
|
|
127
|
+
code="PDB_FILE_NOT_FOUND",
|
|
128
|
+
context={"path": str(source)},
|
|
129
|
+
)
|
|
130
|
+
size = source.stat().st_size
|
|
131
|
+
if not 1 <= size <= _MAX_PDB_BYTES:
|
|
132
|
+
raise InputFormatError(
|
|
133
|
+
f"PDB file size must be in [1, {_MAX_PDB_BYTES}] bytes.",
|
|
134
|
+
code="PDB_FILE_SIZE_LIMIT",
|
|
135
|
+
context={"size_bytes": size},
|
|
136
|
+
)
|
|
137
|
+
payload = source.read_bytes()
|
|
138
|
+
digest = hashlib.sha256(payload).hexdigest()
|
|
139
|
+
try:
|
|
140
|
+
text = payload.decode("ascii")
|
|
141
|
+
except UnicodeDecodeError as exc:
|
|
142
|
+
raise InputFormatError(
|
|
143
|
+
"Legacy PDB files must contain ASCII text.", code="INVALID_PDB_ENCODING"
|
|
144
|
+
) from exc
|
|
145
|
+
pdb_id: str | None = None
|
|
146
|
+
title_parts: list[str] = []
|
|
147
|
+
current_model = 1
|
|
148
|
+
explicit_models = False
|
|
149
|
+
selected_atoms: dict[int, dict[tuple[str, int, str, str, str], tuple[int, float, Atom3D]]] = (
|
|
150
|
+
defaultdict(dict)
|
|
151
|
+
)
|
|
152
|
+
atom_records = 0
|
|
153
|
+
for line_number, raw_line in enumerate(text.splitlines(), start=1):
|
|
154
|
+
line = raw_line.ljust(80)
|
|
155
|
+
record = line[:6]
|
|
156
|
+
if record == "HEADER":
|
|
157
|
+
candidate = line[62:66].strip().upper()
|
|
158
|
+
if candidate:
|
|
159
|
+
pdb_id = candidate
|
|
160
|
+
elif record == "TITLE ":
|
|
161
|
+
part = line[10:80].strip()
|
|
162
|
+
if part:
|
|
163
|
+
title_parts.append(part)
|
|
164
|
+
elif record == "MODEL ":
|
|
165
|
+
explicit_models = True
|
|
166
|
+
current_model = _integer(line[10:14], "model", line_number)
|
|
167
|
+
if not 1 <= current_model <= _MAX_MODELS:
|
|
168
|
+
raise InputFormatError(
|
|
169
|
+
"PDB model index exceeds the safety limit.",
|
|
170
|
+
code="PDB_MODEL_LIMIT",
|
|
171
|
+
context={"model_index": current_model},
|
|
172
|
+
)
|
|
173
|
+
elif record == "ATOM ":
|
|
174
|
+
residue_name = line[17:20].strip().upper()
|
|
175
|
+
if residue_name not in _DNA_RESIDUES:
|
|
176
|
+
continue
|
|
177
|
+
atom_records += 1
|
|
178
|
+
if atom_records > _MAX_ATOMS:
|
|
179
|
+
raise InputFormatError(
|
|
180
|
+
"PDB DNA atom count exceeds the safety limit.",
|
|
181
|
+
code="PDB_ATOM_LIMIT",
|
|
182
|
+
)
|
|
183
|
+
altloc = line[16].strip()
|
|
184
|
+
if altloc not in {"", "A"}:
|
|
185
|
+
continue
|
|
186
|
+
serial = _integer(line[6:11], "serial", line_number)
|
|
187
|
+
atom_name = line[12:16].strip()
|
|
188
|
+
chain_id = line[21].strip() or "_"
|
|
189
|
+
residue_number = _integer(line[22:26], "residue_number", line_number)
|
|
190
|
+
insertion_code = line[26].strip()
|
|
191
|
+
occupancy = _number(line[54:60], "occupancy", line_number, default=1.0)
|
|
192
|
+
atom = Atom3D(
|
|
193
|
+
serial=serial,
|
|
194
|
+
name=atom_name,
|
|
195
|
+
element=_element(atom_name, line[76:78]),
|
|
196
|
+
residue_name=residue_name,
|
|
197
|
+
chain_id=chain_id,
|
|
198
|
+
residue_number=residue_number,
|
|
199
|
+
insertion_code=insertion_code,
|
|
200
|
+
x_angstrom=_number(line[30:38], "x", line_number),
|
|
201
|
+
y_angstrom=_number(line[38:46], "y", line_number),
|
|
202
|
+
z_angstrom=_number(line[46:54], "z", line_number),
|
|
203
|
+
occupancy=occupancy,
|
|
204
|
+
model_index=current_model,
|
|
205
|
+
)
|
|
206
|
+
key = (chain_id, residue_number, insertion_code, residue_name, atom_name)
|
|
207
|
+
priority = 2 if not altloc else 1
|
|
208
|
+
previous = selected_atoms[current_model].get(key)
|
|
209
|
+
if previous is None or (priority, occupancy) > (previous[0], previous[1]):
|
|
210
|
+
selected_atoms[current_model][key] = (priority, occupancy, atom)
|
|
211
|
+
if not selected_atoms:
|
|
212
|
+
raise InputFormatError(
|
|
213
|
+
"PDB file contains no canonical DNA ATOM records.",
|
|
214
|
+
code="PDB_DNA_ATOMS_NOT_FOUND",
|
|
215
|
+
)
|
|
216
|
+
if explicit_models and len(selected_atoms) > _MAX_MODELS:
|
|
217
|
+
raise InputFormatError("PDB model count exceeds the safety limit.", code="PDB_MODEL_LIMIT")
|
|
218
|
+
title = " ".join(title_parts) or None
|
|
219
|
+
return tuple(
|
|
220
|
+
_structure(
|
|
221
|
+
path=source,
|
|
222
|
+
digest=digest,
|
|
223
|
+
pdb_id=pdb_id,
|
|
224
|
+
title=title,
|
|
225
|
+
model_index=model_index,
|
|
226
|
+
atoms=tuple(value[2] for value in model_atoms.values()),
|
|
227
|
+
)
|
|
228
|
+
for model_index, model_atoms in sorted(selected_atoms.items())
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def load_pdb(path: str | Path, *, model_index: int = 1) -> DNA3DStructure:
|
|
233
|
+
"""Load one selected PDB model."""
|
|
234
|
+
|
|
235
|
+
if isinstance(model_index, bool) or not isinstance(model_index, int) or model_index <= 0:
|
|
236
|
+
raise ConfigurationError(
|
|
237
|
+
"model_index must be a positive integer.", code="INVALID_PDB_MODEL_INDEX"
|
|
238
|
+
)
|
|
239
|
+
structures = load_pdb_ensemble(path)
|
|
240
|
+
for structure in structures:
|
|
241
|
+
if structure.model_index == model_index:
|
|
242
|
+
return structure
|
|
243
|
+
raise InputFormatError(
|
|
244
|
+
"Requested PDB model is absent.",
|
|
245
|
+
code="PDB_MODEL_NOT_FOUND",
|
|
246
|
+
context={
|
|
247
|
+
"model_index": model_index,
|
|
248
|
+
"available_models": tuple(item.model_index for item in structures),
|
|
249
|
+
},
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
__all__ = ["load_pdb", "load_pdb_ensemble"]
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Immutable coordinate, geometry, flexibility, and 3DNA/DSSR result objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from dnakit.core import Provenance
|
|
9
|
+
from dnakit.core._json import to_json_compatible
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _SerializableResult:
|
|
13
|
+
def to_dict(self) -> dict[str, Any]:
|
|
14
|
+
return cast(dict[str, Any], to_json_compatible(self))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Atom3D(_SerializableResult):
|
|
19
|
+
serial: int
|
|
20
|
+
name: str
|
|
21
|
+
element: str
|
|
22
|
+
residue_name: str
|
|
23
|
+
chain_id: str
|
|
24
|
+
residue_number: int
|
|
25
|
+
insertion_code: str
|
|
26
|
+
x_angstrom: float
|
|
27
|
+
y_angstrom: float
|
|
28
|
+
z_angstrom: float
|
|
29
|
+
occupancy: float
|
|
30
|
+
model_index: int
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def coordinates(self) -> tuple[float, float, float]:
|
|
34
|
+
return (self.x_angstrom, self.y_angstrom, self.z_angstrom)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Residue3D(_SerializableResult):
|
|
39
|
+
name: str
|
|
40
|
+
base: str
|
|
41
|
+
chain_id: str
|
|
42
|
+
number: int
|
|
43
|
+
insertion_code: str
|
|
44
|
+
atoms: tuple[Atom3D, ...]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class DNA3DStructure(_SerializableResult):
|
|
49
|
+
pdb_id: str | None
|
|
50
|
+
title: str | None
|
|
51
|
+
model_index: int
|
|
52
|
+
source_path: str
|
|
53
|
+
source_sha256: str
|
|
54
|
+
atoms: tuple[Atom3D, ...]
|
|
55
|
+
residues: tuple[Residue3D, ...]
|
|
56
|
+
chain_ids: tuple[str, ...]
|
|
57
|
+
sequence_by_chain: tuple[str, ...]
|
|
58
|
+
parser: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class ChainGeometry(_SerializableResult):
|
|
63
|
+
chain_id: str
|
|
64
|
+
residue_count: int
|
|
65
|
+
contour_length_angstrom: float
|
|
66
|
+
end_to_end_distance_angstrom: float
|
|
67
|
+
anchor_atom: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class ShapeGeometry(_SerializableResult):
|
|
72
|
+
center_of_mass_angstrom: tuple[float, float, float]
|
|
73
|
+
radius_of_gyration_angstrom: float
|
|
74
|
+
principal_moments_dalton_angstrom2: tuple[float, float, float]
|
|
75
|
+
gyration_eigenvalues_angstrom2: tuple[float, float, float]
|
|
76
|
+
sphericity: float
|
|
77
|
+
eccentricity: float
|
|
78
|
+
asphericity_angstrom2: float
|
|
79
|
+
relative_shape_anisotropy: float
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class HydrogenBondGeometry(_SerializableResult):
|
|
84
|
+
explicit_hydrogen_bond_count: int | None
|
|
85
|
+
geometric_base_n_o_contact_count: int
|
|
86
|
+
heavy_atom_distance_cutoff_angstrom: float
|
|
87
|
+
method: str
|
|
88
|
+
limitation: str
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class BackboneTorsion(_SerializableResult):
|
|
93
|
+
chain_id: str
|
|
94
|
+
residue_number: int
|
|
95
|
+
insertion_code: str
|
|
96
|
+
alpha_degree: float | None
|
|
97
|
+
beta_degree: float | None
|
|
98
|
+
gamma_degree: float | None
|
|
99
|
+
delta_degree: float | None
|
|
100
|
+
epsilon_degree: float | None
|
|
101
|
+
zeta_degree: float | None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class HelixGeometry(_SerializableResult):
|
|
106
|
+
chain_ids: tuple[str, str]
|
|
107
|
+
base_pair_count: int
|
|
108
|
+
helical_axis_unit_vector: tuple[float, float, float]
|
|
109
|
+
helical_contour_length_angstrom: float
|
|
110
|
+
mean_rise_angstrom: float
|
|
111
|
+
mean_twist_degree: float
|
|
112
|
+
helical_pitch_angstrom: float
|
|
113
|
+
base_pairs_per_turn: float
|
|
114
|
+
bending_angle_degree: float
|
|
115
|
+
mean_curvature_inverse_angstrom: float
|
|
116
|
+
method: str
|
|
117
|
+
limitation: str
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class Conditional3DFeature(_SerializableResult):
|
|
122
|
+
name: str
|
|
123
|
+
status: str
|
|
124
|
+
required_input_or_backend: str
|
|
125
|
+
reason: str
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class Structure3DAnalysisResult(_SerializableResult):
|
|
130
|
+
pdb_id: str | None
|
|
131
|
+
model_index: int
|
|
132
|
+
atom_count: int
|
|
133
|
+
residue_count: int
|
|
134
|
+
chain_count: int
|
|
135
|
+
chain_geometry: tuple[ChainGeometry, ...]
|
|
136
|
+
shape: ShapeGeometry
|
|
137
|
+
solvent_accessible_surface_area_angstrom2: float
|
|
138
|
+
molecular_volume_angstrom3: float
|
|
139
|
+
sasa_probe_radius_angstrom: float
|
|
140
|
+
sasa_points_per_atom: int
|
|
141
|
+
volume_grid_spacing_angstrom: float
|
|
142
|
+
hydrogen_bonds: HydrogenBondGeometry
|
|
143
|
+
backbone_torsions: tuple[BackboneTorsion, ...]
|
|
144
|
+
helix: HelixGeometry | None
|
|
145
|
+
conditional_features: tuple[Conditional3DFeature, ...]
|
|
146
|
+
method: str
|
|
147
|
+
provenance: Provenance
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True)
|
|
151
|
+
class ResidueFlexibility(_SerializableResult):
|
|
152
|
+
chain_id: str
|
|
153
|
+
residue_number: int
|
|
154
|
+
insertion_code: str
|
|
155
|
+
rmsf_angstrom: float
|
|
156
|
+
common_atom_count: int
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class EnsembleFlexibilityResult(_SerializableResult):
|
|
161
|
+
model_count: int
|
|
162
|
+
common_atom_count: int
|
|
163
|
+
mean_atomic_rmsf_angstrom: float
|
|
164
|
+
max_atomic_rmsf_angstrom: float
|
|
165
|
+
residue_flexibility: tuple[ResidueFlexibility, ...]
|
|
166
|
+
method: str
|
|
167
|
+
limitation: str
|
|
168
|
+
provenance: Provenance
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True)
|
|
172
|
+
class BasePairStepParameters(_SerializableResult):
|
|
173
|
+
index: int
|
|
174
|
+
base_pair: str
|
|
175
|
+
shear_angstrom: float
|
|
176
|
+
stretch_angstrom: float
|
|
177
|
+
stagger_angstrom: float
|
|
178
|
+
buckle_degree: float
|
|
179
|
+
propeller_degree: float
|
|
180
|
+
opening_degree: float
|
|
181
|
+
shift_angstrom: float
|
|
182
|
+
slide_angstrom: float
|
|
183
|
+
rise_angstrom: float
|
|
184
|
+
tilt_degree: float
|
|
185
|
+
roll_degree: float
|
|
186
|
+
twist_degree: float
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True)
|
|
190
|
+
class ThreeDNAParameterResult(_SerializableResult):
|
|
191
|
+
source_path: str
|
|
192
|
+
base_pair_count: int
|
|
193
|
+
parameters: tuple[BasePairStepParameters, ...]
|
|
194
|
+
mean_rise_angstrom: float | None
|
|
195
|
+
mean_twist_degree: float | None
|
|
196
|
+
helical_pitch_angstrom: float | None
|
|
197
|
+
base_pairs_per_turn: float | None
|
|
198
|
+
method: str
|
|
199
|
+
provenance: Provenance
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(frozen=True)
|
|
203
|
+
class DSSRSummaryResult(_SerializableResult):
|
|
204
|
+
source_path: str
|
|
205
|
+
program_version: str
|
|
206
|
+
nucleotide_count: int
|
|
207
|
+
pair_count: int
|
|
208
|
+
helix_count: int
|
|
209
|
+
stem_count: int
|
|
210
|
+
hairpin_count: int
|
|
211
|
+
hydrogen_bond_count: int
|
|
212
|
+
backbone_torsion_record_count: int
|
|
213
|
+
method: str
|
|
214
|
+
provenance: Provenance
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
__all__ = [
|
|
218
|
+
"Atom3D",
|
|
219
|
+
"BackboneTorsion",
|
|
220
|
+
"BasePairStepParameters",
|
|
221
|
+
"ChainGeometry",
|
|
222
|
+
"Conditional3DFeature",
|
|
223
|
+
"DNA3DStructure",
|
|
224
|
+
"DSSRSummaryResult",
|
|
225
|
+
"EnsembleFlexibilityResult",
|
|
226
|
+
"HelixGeometry",
|
|
227
|
+
"HydrogenBondGeometry",
|
|
228
|
+
"Residue3D",
|
|
229
|
+
"ResidueFlexibility",
|
|
230
|
+
"ShapeGeometry",
|
|
231
|
+
"Structure3DAnalysisResult",
|
|
232
|
+
"ThreeDNAParameterResult",
|
|
233
|
+
]
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Parsers for standard 3DNA base-pair steps and DSSR JSON summaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import cast
|
|
10
|
+
|
|
11
|
+
from dnakit.exceptions import ConfigurationError, InputFormatError
|
|
12
|
+
|
|
13
|
+
from ._shared import threedna_provenance
|
|
14
|
+
from .results import BasePairStepParameters, DSSRSummaryResult, ThreeDNAParameterResult
|
|
15
|
+
|
|
16
|
+
_MAX_PARAMETER_BYTES = 100_000_000
|
|
17
|
+
_MAX_BASE_PAIRS = 1_000_000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _source(path: str | Path) -> tuple[Path, str]:
|
|
21
|
+
if not isinstance(path, (str, Path)):
|
|
22
|
+
raise ConfigurationError("path must be text or Path.", code="INVALID_3DNA_PATH")
|
|
23
|
+
source = Path(path).expanduser().resolve()
|
|
24
|
+
if not source.is_file():
|
|
25
|
+
raise InputFormatError(
|
|
26
|
+
"3DNA/DSSR input file does not exist.",
|
|
27
|
+
code="THREEDNA_FILE_NOT_FOUND",
|
|
28
|
+
context={"path": str(source)},
|
|
29
|
+
)
|
|
30
|
+
size = source.stat().st_size
|
|
31
|
+
if not 1 <= size <= _MAX_PARAMETER_BYTES:
|
|
32
|
+
raise InputFormatError(
|
|
33
|
+
f"3DNA/DSSR input size must be in [1, {_MAX_PARAMETER_BYTES}] bytes.",
|
|
34
|
+
code="THREEDNA_FILE_SIZE_LIMIT",
|
|
35
|
+
)
|
|
36
|
+
try:
|
|
37
|
+
return source, source.read_text(encoding="utf-8")
|
|
38
|
+
except UnicodeDecodeError as exc:
|
|
39
|
+
raise InputFormatError(
|
|
40
|
+
"3DNA/DSSR text must be UTF-8 compatible.", code="INVALID_3DNA_ENCODING"
|
|
41
|
+
) from exc
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def read_3dna_bp_step(path: str | Path) -> ThreeDNAParameterResult:
|
|
45
|
+
"""Parse the 12 standard rigid-body columns from ``bp_step.par``."""
|
|
46
|
+
|
|
47
|
+
source, text = _source(path)
|
|
48
|
+
lines = tuple(line.strip() for line in text.splitlines() if line.strip())
|
|
49
|
+
if not lines:
|
|
50
|
+
raise InputFormatError("3DNA parameter file is empty.", code="EMPTY_3DNA_PARAMETERS")
|
|
51
|
+
try:
|
|
52
|
+
declared_count = int(lines[0].split()[0])
|
|
53
|
+
except (ValueError, IndexError) as exc:
|
|
54
|
+
raise InputFormatError(
|
|
55
|
+
"The first 3DNA bp_step.par line must declare the base-pair count.",
|
|
56
|
+
code="INVALID_3DNA_PARAMETERS",
|
|
57
|
+
) from exc
|
|
58
|
+
if not 1 <= declared_count <= _MAX_BASE_PAIRS:
|
|
59
|
+
raise InputFormatError(
|
|
60
|
+
"Declared 3DNA base-pair count is outside safety limits.",
|
|
61
|
+
code="INVALID_3DNA_PARAMETERS",
|
|
62
|
+
)
|
|
63
|
+
parameters: list[BasePairStepParameters] = []
|
|
64
|
+
for line in lines[1:]:
|
|
65
|
+
tokens = line.split()
|
|
66
|
+
if len(tokens) != 13 or not any(separator in tokens[0] for separator in ("-", "/")):
|
|
67
|
+
continue
|
|
68
|
+
try:
|
|
69
|
+
values = tuple(float(token) for token in tokens[1:])
|
|
70
|
+
except ValueError:
|
|
71
|
+
continue
|
|
72
|
+
if any(not math.isfinite(value) for value in values):
|
|
73
|
+
raise InputFormatError(
|
|
74
|
+
"3DNA parameters must be finite.", code="INVALID_3DNA_PARAMETERS"
|
|
75
|
+
)
|
|
76
|
+
parameters.append(
|
|
77
|
+
BasePairStepParameters(
|
|
78
|
+
len(parameters) + 1,
|
|
79
|
+
tokens[0],
|
|
80
|
+
*values,
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
if len(parameters) != declared_count:
|
|
84
|
+
raise InputFormatError(
|
|
85
|
+
"Parsed 3DNA parameter rows do not match the declared base-pair count.",
|
|
86
|
+
code="THREEDNA_PARAMETER_COUNT_MISMATCH",
|
|
87
|
+
context={"declared": declared_count, "parsed": len(parameters)},
|
|
88
|
+
)
|
|
89
|
+
step_rows = parameters[1:]
|
|
90
|
+
mean_rise = (
|
|
91
|
+
None
|
|
92
|
+
if not step_rows
|
|
93
|
+
else math.fsum(item.rise_angstrom for item in step_rows) / len(step_rows)
|
|
94
|
+
)
|
|
95
|
+
mean_twist = (
|
|
96
|
+
None
|
|
97
|
+
if not step_rows
|
|
98
|
+
else math.fsum(item.twist_degree for item in step_rows) / len(step_rows)
|
|
99
|
+
)
|
|
100
|
+
base_pairs_per_turn = None if mean_twist is None or mean_twist == 0.0 else 360.0 / mean_twist
|
|
101
|
+
pitch = (
|
|
102
|
+
None
|
|
103
|
+
if mean_rise is None or base_pairs_per_turn is None
|
|
104
|
+
else mean_rise * base_pairs_per_turn
|
|
105
|
+
)
|
|
106
|
+
return ThreeDNAParameterResult(
|
|
107
|
+
source_path=str(source),
|
|
108
|
+
base_pair_count=declared_count,
|
|
109
|
+
parameters=tuple(parameters),
|
|
110
|
+
mean_rise_angstrom=mean_rise,
|
|
111
|
+
mean_twist_degree=mean_twist,
|
|
112
|
+
helical_pitch_angstrom=pitch,
|
|
113
|
+
base_pairs_per_turn=base_pairs_per_turn,
|
|
114
|
+
method="3dna-bp-step-12-parameter-parser-v1",
|
|
115
|
+
provenance=threedna_provenance(),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _count(data: Mapping[str, object], count_key: str, array_key: str) -> int:
|
|
120
|
+
raw_count = data.get(count_key)
|
|
121
|
+
raw_array = data.get(array_key, ())
|
|
122
|
+
if raw_count is None:
|
|
123
|
+
if isinstance(raw_array, list):
|
|
124
|
+
return len(raw_array)
|
|
125
|
+
return 0
|
|
126
|
+
if isinstance(raw_count, bool) or not isinstance(raw_count, int) or raw_count < 0:
|
|
127
|
+
raise InputFormatError(
|
|
128
|
+
f"DSSR {count_key} must be a non-negative integer.",
|
|
129
|
+
code="INVALID_DSSR_JSON",
|
|
130
|
+
)
|
|
131
|
+
if isinstance(raw_array, list) and len(raw_array) != raw_count:
|
|
132
|
+
raise InputFormatError(
|
|
133
|
+
f"DSSR {count_key} disagrees with {array_key} length.",
|
|
134
|
+
code="DSSR_COUNT_MISMATCH",
|
|
135
|
+
)
|
|
136
|
+
return raw_count
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def read_dssr_json(path: str | Path) -> DSSRSummaryResult:
|
|
140
|
+
"""Parse audited capability counts from an x3dna-dssr ``--json`` result."""
|
|
141
|
+
|
|
142
|
+
source, text = _source(path)
|
|
143
|
+
try:
|
|
144
|
+
raw = json.loads(text)
|
|
145
|
+
except json.JSONDecodeError as exc:
|
|
146
|
+
raise InputFormatError(
|
|
147
|
+
"DSSR input is not valid JSON.",
|
|
148
|
+
code="INVALID_DSSR_JSON",
|
|
149
|
+
context={"line": exc.lineno, "column": exc.colno},
|
|
150
|
+
) from exc
|
|
151
|
+
if not isinstance(raw, Mapping):
|
|
152
|
+
raise InputFormatError("DSSR JSON root must be an object.", code="INVALID_DSSR_JSON")
|
|
153
|
+
data = cast(Mapping[str, object], raw)
|
|
154
|
+
version = data.get("dssr_ver", data.get("program", "unknown DSSR version"))
|
|
155
|
+
if not isinstance(version, str) or not version.strip() or len(version) > 1_000:
|
|
156
|
+
raise InputFormatError("DSSR program version is invalid.", code="INVALID_DSSR_JSON")
|
|
157
|
+
torsions_key = "ntParams" if "ntParams" in data else "ntPars"
|
|
158
|
+
raw_torsions = data.get(torsions_key)
|
|
159
|
+
return DSSRSummaryResult(
|
|
160
|
+
source_path=str(source),
|
|
161
|
+
program_version=version,
|
|
162
|
+
nucleotide_count=_count(data, "num_nts", "nts"),
|
|
163
|
+
pair_count=_count(data, "num_pairs", "pairs"),
|
|
164
|
+
helix_count=_count(data, "num_helices", "helices"),
|
|
165
|
+
stem_count=_count(data, "num_stems", "stems"),
|
|
166
|
+
hairpin_count=_count(data, "num_hairpins", "hairpins"),
|
|
167
|
+
hydrogen_bond_count=_count(data, "num_hbonds", "hbonds"),
|
|
168
|
+
backbone_torsion_record_count=len(raw_torsions) if isinstance(raw_torsions, list) else 0,
|
|
169
|
+
method="x3dna-dssr-json-summary-parser-v1",
|
|
170
|
+
provenance=threedna_provenance(),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
__all__ = ["read_3dna_bp_step", "read_dssr_json"]
|