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,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"]