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