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,773 @@
1
+ """Deterministic geometry descriptors derived from explicit DNA coordinates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections import defaultdict
7
+ from collections.abc import Callable, Iterable
8
+ from contextlib import suppress
9
+ from itertools import combinations, pairwise
10
+ from typing import TypeAlias
11
+
12
+ from dnakit.exceptions import ConfigurationError
13
+
14
+ from ._shared import native_structure_provenance
15
+ from .results import (
16
+ Atom3D,
17
+ BackboneTorsion,
18
+ ChainGeometry,
19
+ Conditional3DFeature,
20
+ DNA3DStructure,
21
+ EnsembleFlexibilityResult,
22
+ HelixGeometry,
23
+ HydrogenBondGeometry,
24
+ Residue3D,
25
+ ResidueFlexibility,
26
+ ShapeGeometry,
27
+ Structure3DAnalysisResult,
28
+ )
29
+
30
+ Vector3: TypeAlias = tuple[float, float, float]
31
+ StructureProgress: TypeAlias = Callable[[str, int, int], None]
32
+
33
+ _ATOMIC_MASS = {
34
+ "H": 1.008,
35
+ "C": 12.011,
36
+ "N": 14.007,
37
+ "O": 15.999,
38
+ "P": 30.974,
39
+ "S": 32.06,
40
+ }
41
+ _VDW_RADIUS = {"H": 1.20, "C": 1.70, "N": 1.55, "O": 1.52, "P": 1.80, "S": 1.80}
42
+ _BASE_HETERO_ATOMS = frozenset({"N1", "N2", "N3", "N4", "N6", "N7", "N9", "O2", "O4", "O6"})
43
+ _COMPLEMENT = str.maketrans("ACGT", "TGCA")
44
+
45
+
46
+ def _add(first: Vector3, second: Vector3) -> Vector3:
47
+ return (first[0] + second[0], first[1] + second[1], first[2] + second[2])
48
+
49
+
50
+ def _subtract(first: Vector3, second: Vector3) -> Vector3:
51
+ return (first[0] - second[0], first[1] - second[1], first[2] - second[2])
52
+
53
+
54
+ def _scale(value: Vector3, factor: float) -> Vector3:
55
+ return (value[0] * factor, value[1] * factor, value[2] * factor)
56
+
57
+
58
+ def _dot(first: Vector3, second: Vector3) -> float:
59
+ return first[0] * second[0] + first[1] * second[1] + first[2] * second[2]
60
+
61
+
62
+ def _cross(first: Vector3, second: Vector3) -> Vector3:
63
+ return (
64
+ first[1] * second[2] - first[2] * second[1],
65
+ first[2] * second[0] - first[0] * second[2],
66
+ first[0] * second[1] - first[1] * second[0],
67
+ )
68
+
69
+
70
+ def _norm(value: Vector3) -> float:
71
+ return math.sqrt(_dot(value, value))
72
+
73
+
74
+ def _unit(value: Vector3) -> Vector3:
75
+ length = _norm(value)
76
+ if length <= 1e-12:
77
+ raise ConfigurationError(
78
+ "A required geometric vector has zero length.", code="DEGENERATE_3D_GEOMETRY"
79
+ )
80
+ return _scale(value, 1.0 / length)
81
+
82
+
83
+ def _distance(first: Vector3, second: Vector3) -> float:
84
+ return _norm(_subtract(first, second))
85
+
86
+
87
+ def _angle(first: Vector3, second: Vector3) -> float:
88
+ denominator = _norm(first) * _norm(second)
89
+ if denominator <= 1e-12:
90
+ return 0.0
91
+ cosine = min(1.0, max(-1.0, _dot(first, second) / denominator))
92
+ return math.degrees(math.acos(cosine))
93
+
94
+
95
+ def _atom(residue: Residue3D, *names: str) -> Atom3D | None:
96
+ mapping = {item.name.replace("*", "'"): item for item in residue.atoms}
97
+ return next(
98
+ (mapping[name.replace("*", "'")] for name in names if name.replace("*", "'") in mapping),
99
+ None,
100
+ )
101
+
102
+
103
+ def _dihedral(
104
+ first: Atom3D | None, second: Atom3D | None, third: Atom3D | None, fourth: Atom3D | None
105
+ ) -> float | None:
106
+ if first is None or second is None or third is None or fourth is None:
107
+ return None
108
+ b0 = _scale(_subtract(second.coordinates, first.coordinates), -1.0)
109
+ b1 = _unit(_subtract(third.coordinates, second.coordinates))
110
+ b2 = _subtract(fourth.coordinates, third.coordinates)
111
+ v = _subtract(b0, _scale(b1, _dot(b0, b1)))
112
+ w = _subtract(b2, _scale(b1, _dot(b2, b1)))
113
+ if _norm(v) <= 1e-12 or _norm(w) <= 1e-12:
114
+ return None
115
+ return math.degrees(math.atan2(_dot(_cross(b1, v), w), _dot(v, w)))
116
+
117
+
118
+ def _eigenvalues_symmetric(matrix: tuple[Vector3, Vector3, Vector3]) -> tuple[float, float, float]:
119
+ values = [list(row) for row in matrix]
120
+ for _ in range(64):
121
+ first, second = max(
122
+ ((0, 1), (0, 2), (1, 2)), key=lambda pair: abs(values[pair[0]][pair[1]])
123
+ )
124
+ off_diagonal = values[first][second]
125
+ if abs(off_diagonal) < 1e-12:
126
+ break
127
+ angle = 0.5 * math.atan2(2.0 * off_diagonal, values[second][second] - values[first][first])
128
+ cosine, sine = math.cos(angle), math.sin(angle)
129
+ old_first = values[first][first]
130
+ old_second = values[second][second]
131
+ values[first][first] = (
132
+ cosine * cosine * old_first
133
+ - 2.0 * sine * cosine * off_diagonal
134
+ + sine * sine * old_second
135
+ )
136
+ values[second][second] = (
137
+ sine * sine * old_first
138
+ + 2.0 * sine * cosine * off_diagonal
139
+ + cosine * cosine * old_second
140
+ )
141
+ values[first][second] = values[second][first] = 0.0
142
+ for index in range(3):
143
+ if index in {first, second}:
144
+ continue
145
+ old_index_first = values[index][first]
146
+ old_index_second = values[index][second]
147
+ values[index][first] = values[first][index] = (
148
+ cosine * old_index_first - sine * old_index_second
149
+ )
150
+ values[index][second] = values[second][index] = (
151
+ sine * old_index_first + cosine * old_index_second
152
+ )
153
+ result = sorted(
154
+ (max(0.0, values[index][index]) for index in range(3)),
155
+ reverse=True,
156
+ )
157
+ return (result[0], result[1], result[2])
158
+
159
+
160
+ def _shape(atoms: tuple[Atom3D, ...]) -> ShapeGeometry:
161
+ masses = tuple(_ATOMIC_MASS.get(atom.element, 12.011) for atom in atoms)
162
+ total_mass = math.fsum(masses)
163
+ center: Vector3 = (
164
+ math.fsum(mass * atom.coordinates[0] for mass, atom in zip(masses, atoms, strict=True))
165
+ / total_mass,
166
+ math.fsum(mass * atom.coordinates[1] for mass, atom in zip(masses, atoms, strict=True))
167
+ / total_mass,
168
+ math.fsum(mass * atom.coordinates[2] for mass, atom in zip(masses, atoms, strict=True))
169
+ / total_mass,
170
+ )
171
+ centered = tuple(_subtract(atom.coordinates, center) for atom in atoms)
172
+ radius_squared = (
173
+ math.fsum(
174
+ mass * _dot(coordinate, coordinate)
175
+ for mass, coordinate in zip(masses, centered, strict=True)
176
+ )
177
+ / total_mass
178
+ )
179
+ xx = (
180
+ math.fsum(
181
+ mass * coordinate[0] * coordinate[0]
182
+ for mass, coordinate in zip(masses, centered, strict=True)
183
+ )
184
+ / total_mass
185
+ )
186
+ yy = (
187
+ math.fsum(
188
+ mass * coordinate[1] * coordinate[1]
189
+ for mass, coordinate in zip(masses, centered, strict=True)
190
+ )
191
+ / total_mass
192
+ )
193
+ zz = (
194
+ math.fsum(
195
+ mass * coordinate[2] * coordinate[2]
196
+ for mass, coordinate in zip(masses, centered, strict=True)
197
+ )
198
+ / total_mass
199
+ )
200
+ xy = (
201
+ math.fsum(
202
+ mass * coordinate[0] * coordinate[1]
203
+ for mass, coordinate in zip(masses, centered, strict=True)
204
+ )
205
+ / total_mass
206
+ )
207
+ xz = (
208
+ math.fsum(
209
+ mass * coordinate[0] * coordinate[2]
210
+ for mass, coordinate in zip(masses, centered, strict=True)
211
+ )
212
+ / total_mass
213
+ )
214
+ yz = (
215
+ math.fsum(
216
+ mass * coordinate[1] * coordinate[2]
217
+ for mass, coordinate in zip(masses, centered, strict=True)
218
+ )
219
+ / total_mass
220
+ )
221
+ gyration = _eigenvalues_symmetric(((xx, xy, xz), (xy, yy, yz), (xz, yz, zz)))
222
+ inertia = _eigenvalues_symmetric(
223
+ (
224
+ ((yy + zz) * total_mass, -xy * total_mass, -xz * total_mass),
225
+ (-xy * total_mass, (xx + zz) * total_mass, -yz * total_mass),
226
+ (-xz * total_mass, -yz * total_mass, (xx + yy) * total_mass),
227
+ )
228
+ )
229
+ eigen_sum = math.fsum(gyration)
230
+ sphericity = (
231
+ 0.0
232
+ if eigen_sum == 0.0
233
+ else 3.0 * (gyration[0] * gyration[1] * gyration[2]) ** (1.0 / 3.0) / eigen_sum
234
+ )
235
+ eccentricity = (
236
+ 0.0 if gyration[0] == 0.0 else math.sqrt(max(0.0, 1.0 - gyration[2] / gyration[0]))
237
+ )
238
+ asphericity = gyration[0] - 0.5 * (gyration[1] + gyration[2])
239
+ pair_product = gyration[0] * gyration[1] + gyration[1] * gyration[2] + gyration[2] * gyration[0]
240
+ anisotropy = 0.0 if eigen_sum == 0.0 else 1.0 - 3.0 * pair_product / (eigen_sum**2)
241
+ return ShapeGeometry(
242
+ center_of_mass_angstrom=center,
243
+ radius_of_gyration_angstrom=math.sqrt(max(0.0, radius_squared)),
244
+ principal_moments_dalton_angstrom2=inertia,
245
+ gyration_eigenvalues_angstrom2=gyration,
246
+ sphericity=min(1.0, max(0.0, sphericity)),
247
+ eccentricity=min(1.0, max(0.0, eccentricity)),
248
+ asphericity_angstrom2=asphericity,
249
+ relative_shape_anisotropy=min(1.0, max(0.0, anisotropy)),
250
+ )
251
+
252
+
253
+ def _chain_geometry(structure: DNA3DStructure) -> tuple[ChainGeometry, ...]:
254
+ results: list[ChainGeometry] = []
255
+ for chain_id in structure.chain_ids:
256
+ residues = tuple(item for item in structure.residues if item.chain_id == chain_id)
257
+ anchor_name = next(
258
+ (
259
+ name
260
+ for name in ("C4'", "C1'", "P")
261
+ if all(_atom(residue, name) is not None for residue in residues)
262
+ ),
263
+ None,
264
+ )
265
+ if anchor_name is None:
266
+ continue
267
+ anchors = tuple(
268
+ item.coordinates
269
+ for residue in residues
270
+ if (item := _atom(residue, anchor_name)) is not None
271
+ )
272
+ contour = math.fsum(_distance(first, second) for first, second in pairwise(anchors))
273
+ results.append(
274
+ ChainGeometry(
275
+ chain_id=chain_id,
276
+ residue_count=len(residues),
277
+ contour_length_angstrom=contour,
278
+ end_to_end_distance_angstrom=(
279
+ 0.0 if len(anchors) < 2 else _distance(anchors[0], anchors[-1])
280
+ ),
281
+ anchor_atom=anchor_name,
282
+ )
283
+ )
284
+ return tuple(results)
285
+
286
+
287
+ def _sphere_points(count: int) -> tuple[Vector3, ...]:
288
+ golden_angle = math.pi * (3.0 - math.sqrt(5.0))
289
+ return tuple(
290
+ (
291
+ math.sqrt(max(0.0, 1.0 - (1.0 - 2.0 * (index + 0.5) / count) ** 2))
292
+ * math.cos(index * golden_angle),
293
+ math.sqrt(max(0.0, 1.0 - (1.0 - 2.0 * (index + 0.5) / count) ** 2))
294
+ * math.sin(index * golden_angle),
295
+ 1.0 - 2.0 * (index + 0.5) / count,
296
+ )
297
+ for index in range(count)
298
+ )
299
+
300
+
301
+ def _emit(progress: StructureProgress | None, stage: str, completed: int, total: int) -> None:
302
+ if progress is not None:
303
+ with suppress(Exception):
304
+ progress(stage, completed, total)
305
+
306
+
307
+ def _sasa(
308
+ atoms: tuple[Atom3D, ...],
309
+ *,
310
+ probe_radius: float,
311
+ points_per_atom: int,
312
+ progress: StructureProgress | None,
313
+ ) -> float:
314
+ expanded = tuple(_VDW_RADIUS.get(atom.element, 1.70) + probe_radius for atom in atoms)
315
+ cell_size = 2.0 * max(expanded)
316
+ grid: dict[tuple[int, int, int], list[int]] = defaultdict(list)
317
+ for index, atom in enumerate(atoms):
318
+ cell = (
319
+ math.floor(atom.coordinates[0] / cell_size),
320
+ math.floor(atom.coordinates[1] / cell_size),
321
+ math.floor(atom.coordinates[2] / cell_size),
322
+ )
323
+ grid[cell].append(index)
324
+ unit_points = _sphere_points(points_per_atom)
325
+ area = 0.0
326
+ for atom_index, (atom, radius) in enumerate(zip(atoms, expanded, strict=True), start=1):
327
+ exposed = 0
328
+ for unit_point in unit_points:
329
+ point = _add(atom.coordinates, _scale(unit_point, radius))
330
+ cell = (
331
+ math.floor(point[0] / cell_size),
332
+ math.floor(point[1] / cell_size),
333
+ math.floor(point[2] / cell_size),
334
+ )
335
+ occluded = False
336
+ for dx in (-1, 0, 1):
337
+ for dy in (-1, 0, 1):
338
+ for dz in (-1, 0, 1):
339
+ for other_index in grid.get((cell[0] + dx, cell[1] + dy, cell[2] + dz), ()):
340
+ if other_index == atom_index - 1:
341
+ continue
342
+ if (
343
+ _distance(point, atoms[other_index].coordinates)
344
+ < expanded[other_index]
345
+ ):
346
+ occluded = True
347
+ break
348
+ if occluded:
349
+ break
350
+ if occluded:
351
+ break
352
+ if occluded:
353
+ break
354
+ if not occluded:
355
+ exposed += 1
356
+ area += 4.0 * math.pi * radius * radius * exposed / points_per_atom
357
+ _emit(progress, "sasa", atom_index, len(atoms))
358
+ return area
359
+
360
+
361
+ def _volume(
362
+ atoms: tuple[Atom3D, ...],
363
+ *,
364
+ spacing: float,
365
+ progress: StructureProgress | None,
366
+ ) -> float:
367
+ occupied: set[tuple[int, int, int]] = set()
368
+ for atom_index, atom in enumerate(atoms, start=1):
369
+ radius = _VDW_RADIUS.get(atom.element, 1.70)
370
+ lower = tuple(math.floor((value - radius) / spacing) for value in atom.coordinates)
371
+ upper = tuple(math.ceil((value + radius) / spacing) for value in atom.coordinates)
372
+ radius_squared = radius * radius
373
+ for x_index in range(lower[0], upper[0] + 1):
374
+ for y_index in range(lower[1], upper[1] + 1):
375
+ for z_index in range(lower[2], upper[2] + 1):
376
+ center = (
377
+ (x_index + 0.5) * spacing,
378
+ (y_index + 0.5) * spacing,
379
+ (z_index + 0.5) * spacing,
380
+ )
381
+ if (
382
+ _dot(
383
+ _subtract(center, atom.coordinates), _subtract(center, atom.coordinates)
384
+ )
385
+ <= radius_squared
386
+ ):
387
+ occupied.add((x_index, y_index, z_index))
388
+ _emit(progress, "volume", atom_index, len(atoms))
389
+ return len(occupied) * spacing**3
390
+
391
+
392
+ def _hydrogen_bonds(structure: DNA3DStructure) -> HydrogenBondGeometry:
393
+ def residue_key(atom: Atom3D) -> tuple[str, int, str]:
394
+ return (atom.chain_id, atom.residue_number, atom.insertion_code)
395
+
396
+ base_hetero = tuple(
397
+ atom
398
+ for atom in structure.atoms
399
+ if atom.name in _BASE_HETERO_ATOMS and atom.element in {"N", "O"}
400
+ )
401
+ contacts = 0
402
+ for first, second in combinations(base_hetero, 2):
403
+ if residue_key(first) == residue_key(second):
404
+ continue
405
+ if (
406
+ first.chain_id == second.chain_id
407
+ and abs(first.residue_number - second.residue_number) <= 1
408
+ ):
409
+ continue
410
+ distance = _distance(first.coordinates, second.coordinates)
411
+ if 2.2 <= distance <= 3.5:
412
+ contacts += 1
413
+ hydrogens = tuple(atom for atom in structure.atoms if atom.element == "H")
414
+ explicit_count: int | None = None
415
+ if hydrogens:
416
+ explicit = 0
417
+ for hydrogen in hydrogens:
418
+ donors = tuple(
419
+ atom
420
+ for atom in base_hetero
421
+ if residue_key(atom) == residue_key(hydrogen)
422
+ and _distance(atom.coordinates, hydrogen.coordinates) <= 1.3
423
+ )
424
+ for donor in donors:
425
+ for acceptor in base_hetero:
426
+ if residue_key(acceptor) == residue_key(donor):
427
+ continue
428
+ if (
429
+ _distance(hydrogen.coordinates, acceptor.coordinates) <= 2.5
430
+ and _angle(
431
+ _subtract(donor.coordinates, hydrogen.coordinates),
432
+ _subtract(acceptor.coordinates, hydrogen.coordinates),
433
+ )
434
+ >= 120.0
435
+ ):
436
+ explicit += 1
437
+ explicit_count = explicit
438
+ return HydrogenBondGeometry(
439
+ explicit_hydrogen_bond_count=explicit_count,
440
+ geometric_base_n_o_contact_count=contacts,
441
+ heavy_atom_distance_cutoff_angstrom=3.5,
442
+ method="explicit-D-H-A-geometry-or-base-N-O-contact-screen-v1",
443
+ limitation=(
444
+ "Actual hydrogen bonds require explicit hydrogen atoms and protonation; when absent, "
445
+ "only geometric base N/O contacts are reported and must not be called actual bonds."
446
+ ),
447
+ )
448
+
449
+
450
+ def _torsions(structure: DNA3DStructure) -> tuple[BackboneTorsion, ...]:
451
+ results: list[BackboneTorsion] = []
452
+ for chain_id in structure.chain_ids:
453
+ residues = tuple(item for item in structure.residues if item.chain_id == chain_id)
454
+ for index, residue in enumerate(residues):
455
+ previous = None if index == 0 else residues[index - 1]
456
+ following = None if index + 1 == len(residues) else residues[index + 1]
457
+ results.append(
458
+ BackboneTorsion(
459
+ chain_id=chain_id,
460
+ residue_number=residue.number,
461
+ insertion_code=residue.insertion_code,
462
+ alpha_degree=_dihedral(
463
+ None if previous is None else _atom(previous, "O3'"),
464
+ _atom(residue, "P"),
465
+ _atom(residue, "O5'"),
466
+ _atom(residue, "C5'"),
467
+ ),
468
+ beta_degree=_dihedral(
469
+ _atom(residue, "P"),
470
+ _atom(residue, "O5'"),
471
+ _atom(residue, "C5'"),
472
+ _atom(residue, "C4'"),
473
+ ),
474
+ gamma_degree=_dihedral(
475
+ _atom(residue, "O5'"),
476
+ _atom(residue, "C5'"),
477
+ _atom(residue, "C4'"),
478
+ _atom(residue, "C3'"),
479
+ ),
480
+ delta_degree=_dihedral(
481
+ _atom(residue, "C5'"),
482
+ _atom(residue, "C4'"),
483
+ _atom(residue, "C3'"),
484
+ _atom(residue, "O3'"),
485
+ ),
486
+ epsilon_degree=_dihedral(
487
+ _atom(residue, "C4'"),
488
+ _atom(residue, "C3'"),
489
+ _atom(residue, "O3'"),
490
+ None if following is None else _atom(following, "P"),
491
+ ),
492
+ zeta_degree=_dihedral(
493
+ _atom(residue, "C3'"),
494
+ _atom(residue, "O3'"),
495
+ None if following is None else _atom(following, "P"),
496
+ None if following is None else _atom(following, "O5'"),
497
+ ),
498
+ )
499
+ )
500
+ return tuple(results)
501
+
502
+
503
+ def _project_perpendicular(value: Vector3, axis: Vector3) -> Vector3:
504
+ return _subtract(value, _scale(axis, _dot(value, axis)))
505
+
506
+
507
+ def _helix(structure: DNA3DStructure) -> HelixGeometry | None:
508
+ chains = {
509
+ chain_id: tuple(item for item in structure.residues if item.chain_id == chain_id)
510
+ for chain_id in structure.chain_ids
511
+ }
512
+ selected: tuple[str, str] | None = None
513
+ for first, second in combinations(structure.chain_ids, 2):
514
+ first_sequence = "".join(item.base for item in chains[first])
515
+ second_sequence = "".join(item.base for item in chains[second])
516
+ if (
517
+ len(first_sequence) >= 2
518
+ and second_sequence == first_sequence.translate(_COMPLEMENT)[::-1]
519
+ ):
520
+ selected = (first, second)
521
+ break
522
+ if selected is None:
523
+ return None
524
+ first_residues = chains[selected[0]]
525
+ second_residues = chains[selected[1]][::-1]
526
+ anchors: list[tuple[Vector3, Vector3]] = []
527
+ for first_residue, second_residue in zip(first_residues, second_residues, strict=True):
528
+ first_atom = _atom(first_residue, "C1'")
529
+ second_atom = _atom(second_residue, "C1'")
530
+ if first_atom is None or second_atom is None:
531
+ return None
532
+ anchors.append((first_atom.coordinates, second_atom.coordinates))
533
+ centers = tuple(_scale(_add(first, second), 0.5) for first, second in anchors)
534
+ axis = _unit(_subtract(centers[-1], centers[0]))
535
+ orientations = tuple(
536
+ _project_perpendicular(_subtract(second, first), axis) for first, second in anchors
537
+ )
538
+ if any(_norm(value) <= 1e-12 for value in orientations):
539
+ return None
540
+ twist_values = tuple(abs(_angle(first, second)) for first, second in pairwise(orientations))
541
+ rise_values = tuple(
542
+ abs(_dot(_subtract(second, first), axis)) for first, second in pairwise(centers)
543
+ )
544
+ mean_twist = math.fsum(twist_values) / len(twist_values)
545
+ mean_rise = math.fsum(rise_values) / len(rise_values)
546
+ contour = math.fsum(_distance(first, second) for first, second in pairwise(centers))
547
+ first_direction = _subtract(centers[min(2, len(centers) - 1)], centers[0])
548
+ last_direction = _subtract(centers[-1], centers[max(0, len(centers) - 3)])
549
+ bending = _angle(first_direction, last_direction)
550
+ pairs_per_turn = 0.0 if mean_twist == 0.0 else 360.0 / mean_twist
551
+ return HelixGeometry(
552
+ chain_ids=selected,
553
+ base_pair_count=len(centers),
554
+ helical_axis_unit_vector=axis,
555
+ helical_contour_length_angstrom=contour,
556
+ mean_rise_angstrom=mean_rise,
557
+ mean_twist_degree=mean_twist,
558
+ helical_pitch_angstrom=mean_rise * pairs_per_turn,
559
+ base_pairs_per_turn=pairs_per_turn,
560
+ bending_angle_degree=bending,
561
+ mean_curvature_inverse_angstrom=(
562
+ 0.0 if contour == 0.0 else math.radians(bending) / contour
563
+ ),
564
+ method="paired-C1-prime-centers-global-axis-v1",
565
+ limitation=(
566
+ "Approximate global-axis descriptor for two equal reverse-complement chains; "
567
+ "use 3DNA/DSSR or Curves+ for standard local base-pair reference frames."
568
+ ),
569
+ )
570
+
571
+
572
+ def analyze_structure(
573
+ structure: DNA3DStructure,
574
+ *,
575
+ sasa_probe_radius_angstrom: float = 1.4,
576
+ sasa_points_per_atom: int = 96,
577
+ volume_grid_spacing_angstrom: float = 0.75,
578
+ progress: StructureProgress | None = None,
579
+ ) -> Structure3DAnalysisResult:
580
+ """Calculate native geometry from one explicit PDB model."""
581
+
582
+ if not isinstance(structure, DNA3DStructure):
583
+ raise ConfigurationError("structure must be DNA3DStructure.", code="INVALID_3D_STRUCTURE")
584
+ if not structure.atoms or not structure.residues:
585
+ raise ConfigurationError("structure is empty.", code="EMPTY_3D_STRUCTURE")
586
+ if (
587
+ isinstance(sasa_probe_radius_angstrom, bool)
588
+ or not isinstance(sasa_probe_radius_angstrom, (int, float))
589
+ or not math.isfinite(sasa_probe_radius_angstrom)
590
+ or not 0.0 <= sasa_probe_radius_angstrom <= 5.0
591
+ ):
592
+ raise ConfigurationError(
593
+ "sasa_probe_radius_angstrom must be in [0, 5].", code="INVALID_SASA_CONFIG"
594
+ )
595
+ if (
596
+ isinstance(sasa_points_per_atom, bool)
597
+ or not isinstance(sasa_points_per_atom, int)
598
+ or not 24 <= sasa_points_per_atom <= 4_096
599
+ ):
600
+ raise ConfigurationError(
601
+ "sasa_points_per_atom must be in [24, 4096].", code="INVALID_SASA_CONFIG"
602
+ )
603
+ if (
604
+ isinstance(volume_grid_spacing_angstrom, bool)
605
+ or not isinstance(volume_grid_spacing_angstrom, (int, float))
606
+ or not math.isfinite(volume_grid_spacing_angstrom)
607
+ or not 0.25 <= volume_grid_spacing_angstrom <= 5.0
608
+ ):
609
+ raise ConfigurationError(
610
+ "volume_grid_spacing_angstrom must be in [0.25, 5].",
611
+ code="INVALID_VOLUME_CONFIG",
612
+ )
613
+ if progress is not None and not callable(progress):
614
+ raise ConfigurationError("progress must be callable or None.", code="INVALID_3D_PROGRESS")
615
+ conditional = (
616
+ Conditional3DFeature(
617
+ "major_minor_groove_width_depth",
618
+ "conditional",
619
+ "3DNA/DSSR or Curves+ output",
620
+ "Standard groove geometry requires local base-pair frames and phosphate tracing.",
621
+ ),
622
+ Conditional3DFeature(
623
+ "base_stacking_area",
624
+ "conditional",
625
+ "3DNA/DSSR stacking analysis",
626
+ "Projected ring overlap depends on standard base frames and atom classification.",
627
+ ),
628
+ Conditional3DFeature(
629
+ "electrostatic_potential_charge_distribution",
630
+ "conditional",
631
+ "PQR charges plus an electrostatics backend such as APBS",
632
+ "A PDB coordinate file does not contain force-field charges or solvent electrostatics.",
633
+ ),
634
+ Conditional3DFeature(
635
+ "persistence_length_and_mechanical_stiffness",
636
+ "conditional",
637
+ "trajectory or structural ensemble with a declared mechanical model",
638
+ "A single conformation cannot identify persistence, bend, twist, or stretch moduli.",
639
+ ),
640
+ Conditional3DFeature(
641
+ "standard_base_pair_and_step_parameters",
642
+ "conditional",
643
+ "3DNA/DSSR bp_step.par output",
644
+ "Use read_3dna_bp_step() to preserve the standard rigid-body definitions.",
645
+ ),
646
+ )
647
+ return Structure3DAnalysisResult(
648
+ pdb_id=structure.pdb_id,
649
+ model_index=structure.model_index,
650
+ atom_count=len(structure.atoms),
651
+ residue_count=len(structure.residues),
652
+ chain_count=len(structure.chain_ids),
653
+ chain_geometry=_chain_geometry(structure),
654
+ shape=_shape(structure.atoms),
655
+ solvent_accessible_surface_area_angstrom2=_sasa(
656
+ structure.atoms,
657
+ probe_radius=float(sasa_probe_radius_angstrom),
658
+ points_per_atom=sasa_points_per_atom,
659
+ progress=progress,
660
+ ),
661
+ molecular_volume_angstrom3=_volume(
662
+ structure.atoms,
663
+ spacing=float(volume_grid_spacing_angstrom),
664
+ progress=progress,
665
+ ),
666
+ sasa_probe_radius_angstrom=float(sasa_probe_radius_angstrom),
667
+ sasa_points_per_atom=sasa_points_per_atom,
668
+ volume_grid_spacing_angstrom=float(volume_grid_spacing_angstrom),
669
+ hydrogen_bonds=_hydrogen_bonds(structure),
670
+ backbone_torsions=_torsions(structure),
671
+ helix=_helix(structure),
672
+ conditional_features=conditional,
673
+ method="dnakit-explicit-coordinate-geometry-v1",
674
+ provenance=native_structure_provenance(),
675
+ )
676
+
677
+
678
+ def analyze_ensemble_flexibility(
679
+ structures: Iterable[DNA3DStructure],
680
+ ) -> EnsembleFlexibilityResult:
681
+ """Calculate translation-centered RMSF for a pre-oriented PDB model ensemble."""
682
+
683
+ if not isinstance(structures, Iterable):
684
+ raise ConfigurationError("structures must be an iterable.", code="INVALID_3D_ENSEMBLE")
685
+ models = tuple(structures)
686
+ if not 2 <= len(models) <= 10_000 or any(
687
+ not isinstance(item, DNA3DStructure) for item in models
688
+ ):
689
+ raise ConfigurationError(
690
+ "A 3D ensemble requires 2-10000 DNA3DStructure models.",
691
+ code="INVALID_3D_ENSEMBLE",
692
+ )
693
+
694
+ def atom_map(model: DNA3DStructure) -> dict[tuple[str, int, str, str, str], Atom3D]:
695
+ return {
696
+ (
697
+ atom.chain_id,
698
+ atom.residue_number,
699
+ atom.insertion_code,
700
+ atom.name,
701
+ atom.element,
702
+ ): atom
703
+ for atom in model.atoms
704
+ }
705
+
706
+ maps = tuple(atom_map(model) for model in models)
707
+ common = set(maps[0])
708
+ for mapping in maps[1:]:
709
+ common.intersection_update(mapping)
710
+ ordered_keys = tuple(sorted(common))
711
+ if len(ordered_keys) < 3:
712
+ raise ConfigurationError(
713
+ "Ensemble models have fewer than three common atoms.",
714
+ code="INSUFFICIENT_COMMON_ENSEMBLE_ATOMS",
715
+ )
716
+ centered_models: list[dict[tuple[str, int, str, str, str], Vector3]] = []
717
+ for mapping in maps:
718
+ centroid: Vector3 = (
719
+ math.fsum(mapping[key].coordinates[0] for key in ordered_keys) / len(ordered_keys),
720
+ math.fsum(mapping[key].coordinates[1] for key in ordered_keys) / len(ordered_keys),
721
+ math.fsum(mapping[key].coordinates[2] for key in ordered_keys) / len(ordered_keys),
722
+ )
723
+ centered_models.append(
724
+ {key: _subtract(mapping[key].coordinates, centroid) for key in ordered_keys}
725
+ )
726
+ rmsf_by_key: dict[tuple[str, int, str, str, str], float] = {}
727
+ for key in ordered_keys:
728
+ mean: Vector3 = (
729
+ math.fsum(model[key][0] for model in centered_models) / len(centered_models),
730
+ math.fsum(model[key][1] for model in centered_models) / len(centered_models),
731
+ math.fsum(model[key][2] for model in centered_models) / len(centered_models),
732
+ )
733
+ rmsf_by_key[key] = math.sqrt(
734
+ math.fsum(
735
+ _dot(_subtract(model[key], mean), _subtract(model[key], mean))
736
+ for model in centered_models
737
+ )
738
+ / len(centered_models)
739
+ )
740
+ grouped: dict[tuple[str, int, str], list[float]] = defaultdict(list)
741
+ for key, value in rmsf_by_key.items():
742
+ grouped[key[:3]].append(value)
743
+ residue_flexibility = tuple(
744
+ ResidueFlexibility(
745
+ chain_id=key[0],
746
+ residue_number=key[1],
747
+ insertion_code=key[2],
748
+ rmsf_angstrom=math.sqrt(math.fsum(item * item for item in values) / len(values)),
749
+ common_atom_count=len(values),
750
+ )
751
+ for key, values in sorted(grouped.items())
752
+ )
753
+ atomic_values = tuple(rmsf_by_key.values())
754
+ return EnsembleFlexibilityResult(
755
+ model_count=len(models),
756
+ common_atom_count=len(ordered_keys),
757
+ mean_atomic_rmsf_angstrom=math.fsum(atomic_values) / len(atomic_values),
758
+ max_atomic_rmsf_angstrom=max(atomic_values),
759
+ residue_flexibility=residue_flexibility,
760
+ method="common-atom-translation-centered-rmsf-v1",
761
+ limitation=(
762
+ "Models must already share a common rotational frame; DNAKit removes translation "
763
+ "but does not perform Kabsch rotational superposition or infer force constants."
764
+ ),
765
+ provenance=native_structure_provenance(),
766
+ )
767
+
768
+
769
+ __all__ = [
770
+ "StructureProgress",
771
+ "analyze_ensemble_flexibility",
772
+ "analyze_structure",
773
+ ]