vimol 0.1.4__tar.gz → 0.1.6__tar.gz
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.
- {vimol-0.1.4/src/vimol.egg-info → vimol-0.1.6}/PKG-INFO +1 -1
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/__init__.py +1 -1
- vimol-0.1.6/src/vimol/cistrans.py +266 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/select.py +111 -53
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/viewer.py +10 -2
- {vimol-0.1.4 → vimol-0.1.6/src/vimol.egg-info}/PKG-INFO +1 -1
- {vimol-0.1.4 → vimol-0.1.6}/tests/test_align.py +43 -0
- vimol-0.1.6/tests/test_cistrans.py +281 -0
- vimol-0.1.4/src/vimol/cistrans.py +0 -142
- vimol-0.1.4/tests/test_cistrans.py +0 -146
- {vimol-0.1.4 → vimol-0.1.6}/LICENSE +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/README.md +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/pyproject.toml +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/setup.cfg +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/__main__.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/_render_fast.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/align.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/app.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/arrows.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/bonds.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/camera.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/data/c60.xyz +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/editor.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/elements.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/file_browser.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/gl_adapter.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/gl_render.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/glyph_font.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/glyph_mesh.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/glyph_outlines.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/glyphs.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/input.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/kitty.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/molecule.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/parsers/__init__.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/parsers/mol.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/parsers/pdb.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/parsers/xyz.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/periodic_table.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/py.typed +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/render.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/render_glyph.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/residues.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/scene.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/structures.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/templates.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/theme.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol/widget.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol.egg-info/SOURCES.txt +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol.egg-info/dependency_links.txt +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol.egg-info/entry_points.txt +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol.egg-info/requires.txt +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/src/vimol.egg-info/top_level.txt +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/tests/test_add_file.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/tests/test_bonds.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/tests/test_editing.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/tests/test_file_browser.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/tests/test_gl_render.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/tests/test_glyphs.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/tests/test_render_fast.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/tests/test_scene_structures.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/tests/test_structures.py +0 -0
- {vimol-0.1.4 → vimol-0.1.6}/tests/test_vimol.py +0 -0
|
@@ -36,7 +36,7 @@ from . import select
|
|
|
36
36
|
from .structures import StructureSet, Structure, Transform, AlignmentResult
|
|
37
37
|
from .align import kabsch, superpose, permutation_search, subset_search
|
|
38
38
|
|
|
39
|
-
__version__ = "0.1.
|
|
39
|
+
__version__ = "0.1.6"
|
|
40
40
|
|
|
41
41
|
__all__ = [
|
|
42
42
|
"Molecule", "VectorField", "Camera", "Renderer", "Style", "Scene", "MoleculeWidget",
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Cis/trans classification of peptide and peptoid backbone bonds.
|
|
2
|
+
|
|
3
|
+
A peptide bond's rotation is described by the omega dihedral,
|
|
4
|
+
``Cα(i)-C(i)-N(i+1)-Cα(i+1)``: near 0° it is cis, near 180° trans. This is
|
|
5
|
+
the same walk :func:`vimol.select.peptide_motifs` already builds for the
|
|
6
|
+
ribbon/RMSD backbone presets, and it does not depend on where a residue's
|
|
7
|
+
side chain attaches -- a peptoid's N-substituted backbone (no amide H) is
|
|
8
|
+
the same N-Cα-C path a peptide's is, so no separate peptoid handling is
|
|
9
|
+
needed here.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
import re
|
|
15
|
+
from typing import List, Optional, Sequence, Tuple
|
|
16
|
+
|
|
17
|
+
from .editor import measure_value
|
|
18
|
+
from .molecule import Molecule
|
|
19
|
+
# The private variant: classification needs the bond graph alongside the
|
|
20
|
+
# runs to notice a cyclic backbone's ring-closing peptide bond, which the
|
|
21
|
+
# public runs-only view cannot express.
|
|
22
|
+
from .select import _peptide_motifs, ring_systems
|
|
23
|
+
|
|
24
|
+
# |omega| below this is cis, at or above it is trans -- the standard
|
|
25
|
+
# literature convention, and a strict binary split with no third bucket.
|
|
26
|
+
_CIS_MAX_ABS_OMEGA = 90.0
|
|
27
|
+
|
|
28
|
+
# A ring system at least this big, carrying amides, makes the molecule a
|
|
29
|
+
# macrocycle: a cyclic peptide/peptoid, where the ring's own amides are the
|
|
30
|
+
# conformational question and the exocyclic tail is noise. Twelve is the
|
|
31
|
+
# usual line between an ordinary ring and a macrocycle, and it sits well
|
|
32
|
+
# clear of proline's pyrrolidine (5) and of the 6-membered diketopiperazine
|
|
33
|
+
# that a cyclic dipeptide forms -- both of which stay on the linear path,
|
|
34
|
+
# where the head-to-tail closure already handles them.
|
|
35
|
+
_MACROCYCLE_MIN_ATOMS = 12
|
|
36
|
+
|
|
37
|
+
# Boltzmann population of each classification across a multi-frame file,
|
|
38
|
+
# weighted by the energy in each frame's XYZ comment line. Energies are
|
|
39
|
+
# always read as Hartree (the convention CREST/xtb/ORCA write when the
|
|
40
|
+
# comment is a bare or labeled electronic energy) -- there is deliberately
|
|
41
|
+
# no unit auto-detection: a silently wrong unit produces a distribution that
|
|
42
|
+
# still looks plausible (collapsed onto one conformer, or smeared flat),
|
|
43
|
+
# so getting it wrong is worse than not offering the feature at all.
|
|
44
|
+
TEMPERATURE_K = 298.15
|
|
45
|
+
_KB_HARTREE_PER_K = 3.166811563e-6
|
|
46
|
+
_KT_HARTREE = _KB_HARTREE_PER_K * TEMPERATURE_K
|
|
47
|
+
|
|
48
|
+
# A bare float is CREST's own convention (the whole comment IS the energy).
|
|
49
|
+
_BARE_FLOAT_RE = re.compile(r"^[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$")
|
|
50
|
+
# "energy", "energy:", "energy=", "E", "E=" ... followed by the number --
|
|
51
|
+
# the xtb/ORCA-style labeled convention.
|
|
52
|
+
_LABELED_ENERGY_RE = re.compile(
|
|
53
|
+
r"(?:^|\s)(?:energy|e)\s*[:=]?\s*([+-]?\d+\.\d+(?:[eE][+-]?\d+)?)",
|
|
54
|
+
re.IGNORECASE)
|
|
55
|
+
# Fallback: a decimal-point number anywhere in the comment (excludes bare
|
|
56
|
+
# integers like a model/frame index, which a comment often also carries).
|
|
57
|
+
_DECIMAL_RE = re.compile(r"[+-]?\d+\.\d+(?:[eE][+-]?\d+)?")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_energy(comment: str) -> Optional[float]:
|
|
61
|
+
"""Pull one energy value out of an XYZ comment line, or ``None``.
|
|
62
|
+
|
|
63
|
+
Three conventions, tried in order of confidence: the whole comment is
|
|
64
|
+
the number (CREST); a label ("energy"/"E", "=" or ":" optional)
|
|
65
|
+
precedes it (xtb, ORCA); or exactly one decimal number appears
|
|
66
|
+
anywhere in the comment. Two or more equally plausible numbers with no
|
|
67
|
+
label to disambiguate them is not a confident read -- ``None``, same
|
|
68
|
+
as a comment with no number at all.
|
|
69
|
+
"""
|
|
70
|
+
text = comment.strip()
|
|
71
|
+
if not text:
|
|
72
|
+
return None
|
|
73
|
+
if _BARE_FLOAT_RE.match(text):
|
|
74
|
+
return float(text)
|
|
75
|
+
labeled = _LABELED_ENERGY_RE.search(text)
|
|
76
|
+
if labeled:
|
|
77
|
+
return float(labeled.group(1))
|
|
78
|
+
decimals = _DECIMAL_RE.findall(text)
|
|
79
|
+
if len(decimals) == 1:
|
|
80
|
+
return float(decimals[0])
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def boltzmann_breakdown(classifications: Sequence[str],
|
|
85
|
+
energies_hartree: Sequence[float],
|
|
86
|
+
min_pct: float = 1.0) -> List[Tuple[str, float]]:
|
|
87
|
+
"""Boltzmann-weighted population of each distinct classification string.
|
|
88
|
+
|
|
89
|
+
``classifications`` and ``energies_hartree`` are parallel, one pair per
|
|
90
|
+
frame -- every frame must already have a real (non-``None``)
|
|
91
|
+
classification and energy; filtering that is the caller's job, not
|
|
92
|
+
this function's. Weights are relative to the group's own minimum
|
|
93
|
+
energy (numerically stable, and the group's reference point is the
|
|
94
|
+
only one that matters for population *within* the group). Returns
|
|
95
|
+
``(classification, percentage)`` pairs, rounded to nothing here (the
|
|
96
|
+
caller rounds for display), sorted by descending population, with
|
|
97
|
+
anything under ``min_pct`` dropped -- population that round to 100%
|
|
98
|
+
across the survivors is not guaranteed, by design.
|
|
99
|
+
"""
|
|
100
|
+
if not classifications or len(classifications) != len(energies_hartree):
|
|
101
|
+
return []
|
|
102
|
+
e_min = min(energies_hartree)
|
|
103
|
+
weights = [math.exp(-(e - e_min) / _KT_HARTREE) for e in energies_hartree]
|
|
104
|
+
total = sum(weights)
|
|
105
|
+
populations: dict = {}
|
|
106
|
+
for cls, w in zip(classifications, weights):
|
|
107
|
+
populations[cls] = populations.get(cls, 0.0) + w
|
|
108
|
+
breakdown = [(cls, 100.0 * w / total) for cls, w in populations.items()]
|
|
109
|
+
breakdown = [item for item in breakdown if item[1] >= min_pct]
|
|
110
|
+
breakdown.sort(key=lambda item: item[1], reverse=True)
|
|
111
|
+
return breakdown
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _amide_ring_systems(molecule: Molecule, neighbors, symbols, carbonyls):
|
|
115
|
+
"""Macrocyclic ring systems, each with the amide C-N bonds inside it.
|
|
116
|
+
|
|
117
|
+
Returns ``[(ring_atoms, [(carbonyl_C, amide_N), ...]), ...]``, only for
|
|
118
|
+
systems of at least :data:`_MACROCYCLE_MIN_ATOMS` atoms that actually
|
|
119
|
+
carry an amide. The size test alone is not enough: a steroid or a
|
|
120
|
+
porphyrin is a big ring system with no amide in it, and restricting a
|
|
121
|
+
peptide's classification to one of those would report nothing at all.
|
|
122
|
+
|
|
123
|
+
Built from the ``neighbors`` the motif walk already returned, so a
|
|
124
|
+
frame is never bond-perceived twice -- this runs over every frame of a
|
|
125
|
+
multi-thousand-frame ensemble on one keypress.
|
|
126
|
+
"""
|
|
127
|
+
systems = []
|
|
128
|
+
for ring in ring_systems(neighbors):
|
|
129
|
+
if len(ring) < _MACROCYCLE_MIN_ATOMS:
|
|
130
|
+
continue
|
|
131
|
+
inside = set(ring)
|
|
132
|
+
amides = []
|
|
133
|
+
for carbon in ring:
|
|
134
|
+
# The motif walk's own carbonyl verdict, not a second opinion:
|
|
135
|
+
# a rule of "terminal oxygen" would also accept a hydroxyl and
|
|
136
|
+
# read hydrogen-free threonine's C-OH as an amide.
|
|
137
|
+
if carbon not in carbonyls:
|
|
138
|
+
continue
|
|
139
|
+
amides.extend((carbon, n) for n in neighbors[carbon]
|
|
140
|
+
if symbols[n] == "N" and n in inside)
|
|
141
|
+
if amides:
|
|
142
|
+
systems.append((inside, amides))
|
|
143
|
+
return systems
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _ring_omega_atoms(carbon: int, nitrogen: int, ring, neighbors):
|
|
147
|
+
"""The two Cα-side atoms of a ring amide no motif pair describes.
|
|
148
|
+
|
|
149
|
+
Each is the bond partner's *other* neighbour inside the ring. Ambiguity
|
|
150
|
+
is refused rather than guessed: in a proline fused into a macrocycle the
|
|
151
|
+
nitrogen has two ring neighbours (Cα and Cδ), and picking Cδ would flip
|
|
152
|
+
c and t on exactly the residue whose cis state people care about. Those
|
|
153
|
+
bonds are motif pairs anyway, and are classified as such before this is
|
|
154
|
+
ever reached.
|
|
155
|
+
|
|
156
|
+
KNOWN GAP: refusing drops that amide from the string entirely, which is
|
|
157
|
+
the same silent class-merge that including the lactam exists to
|
|
158
|
+
prevent. Reaching it needs a non-motif amide sitting at a ring fusion
|
|
159
|
+
point, which nothing seen so far does -- but a third state would be
|
|
160
|
+
honester than omission if something ever gets there.
|
|
161
|
+
"""
|
|
162
|
+
carbon_side = [x for x in neighbors[carbon] if x in ring and x != nitrogen]
|
|
163
|
+
nitrogen_side = [x for x in neighbors[nitrogen] if x in ring and x != carbon]
|
|
164
|
+
if len(carbon_side) != 1 or len(nitrogen_side) != 1:
|
|
165
|
+
return None
|
|
166
|
+
return carbon_side[0], nitrogen_side[0]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def classify_cis_trans(molecule: Molecule) -> Optional[str]:
|
|
170
|
+
"""One ``c``/``t`` character per peptide bond, chain by chain.
|
|
171
|
+
|
|
172
|
+
A linear chain of N residues yields N-1 characters, one per
|
|
173
|
+
Cα(i)-C(i)-N(i+1) junction; a *cyclic* chain yields N, the extra one
|
|
174
|
+
for the ring-closing bond back from the last residue's carbonyl to the
|
|
175
|
+
first residue's nitrogen -- omitting it would silently merge cyclic
|
|
176
|
+
conformers that differ only in that omega. Multiple chains (a real
|
|
177
|
+
chain break, not just a gap in the file) join with ``/``, in the order
|
|
178
|
+
the motif walk returns them. A chain of a single residue has no
|
|
179
|
+
peptide bond to classify and is dropped; ``None`` means nothing in the
|
|
180
|
+
molecule survived that filter.
|
|
181
|
+
|
|
182
|
+
A MACROCYCLE is classified on its ring alone (see
|
|
183
|
+
:data:`_MACROCYCLE_MIN_ATOMS`). In a cyclic peptide it is the ring's
|
|
184
|
+
amides that are the conformational question; a pendant tail's omegas
|
|
185
|
+
are a separate, largely independent story, and letting them into the
|
|
186
|
+
same string makes every Boltzmann class a product of the two. The ring
|
|
187
|
+
string covers every amide in the ring, including a side-chain lactam
|
|
188
|
+
that no backbone walk passes through -- that bond closes the ring, and
|
|
189
|
+
dropping it would merge conformers differing only there.
|
|
190
|
+
"""
|
|
191
|
+
runs, neighbors, symbols, carbonyls = _peptide_motifs(molecule)
|
|
192
|
+
|
|
193
|
+
def omega_char(ca_i: int, c_i: int, n_j: int, ca_j: int) -> str:
|
|
194
|
+
omega = measure_value(molecule, (ca_i, c_i, n_j, ca_j))
|
|
195
|
+
return ("c" if omega is not None and abs(omega) < _CIS_MAX_ABS_OMEGA
|
|
196
|
+
else "t")
|
|
197
|
+
|
|
198
|
+
macrocycles = _amide_ring_systems(molecule, neighbors, symbols, carbonyls)
|
|
199
|
+
if macrocycles:
|
|
200
|
+
chains = []
|
|
201
|
+
for ring, amides in macrocycles:
|
|
202
|
+
# Motif pairs first, in backbone order -- they carry real Cα
|
|
203
|
+
# atoms, and their order is the one a chemist reads the ring
|
|
204
|
+
# in. Whatever is left (a lactam) follows by atom index. The
|
|
205
|
+
# order must come from topology alone: rotating the string to
|
|
206
|
+
# some "canonical" start chosen by its own c/t values would
|
|
207
|
+
# collapse cttt and tctt into one Boltzmann class.
|
|
208
|
+
chars, done = [], set()
|
|
209
|
+
for run in runs:
|
|
210
|
+
for (_n_i, ca_i, c_i, _o_i), (n_j, ca_j, _c_j, _o_j) in zip(run, run[1:]):
|
|
211
|
+
if (c_i, n_j) in amides and (c_i, n_j) not in done:
|
|
212
|
+
done.add((c_i, n_j))
|
|
213
|
+
chars.append(omega_char(ca_i, c_i, n_j, ca_j))
|
|
214
|
+
if len(run) < 2:
|
|
215
|
+
continue
|
|
216
|
+
first_n, first_ca = run[0][0], run[0][1]
|
|
217
|
+
last_ca, last_c = run[-1][1], run[-1][2]
|
|
218
|
+
if (last_c, first_n) in amides and (last_c, first_n) not in done:
|
|
219
|
+
done.add((last_c, first_n))
|
|
220
|
+
chars.append(omega_char(last_ca, last_c, first_n, first_ca))
|
|
221
|
+
for carbon, nitrogen in sorted(set(amides) - done):
|
|
222
|
+
ends = _ring_omega_atoms(carbon, nitrogen, ring, neighbors)
|
|
223
|
+
if ends is not None:
|
|
224
|
+
chars.append(omega_char(ends[0], carbon, nitrogen, ends[1]))
|
|
225
|
+
if chars:
|
|
226
|
+
chains.append("".join(chars))
|
|
227
|
+
if chains:
|
|
228
|
+
return "/".join(chains)
|
|
229
|
+
|
|
230
|
+
chains = []
|
|
231
|
+
for run in runs:
|
|
232
|
+
if len(run) < 2:
|
|
233
|
+
continue
|
|
234
|
+
chars = [omega_char(ca_i, c_i, n_j, ca_j)
|
|
235
|
+
for (_n_i, ca_i, c_i, _o_i), (n_j, ca_j, _c_j, _o_j)
|
|
236
|
+
in zip(run, run[1:])]
|
|
237
|
+
first_n, first_ca = run[0][0], run[0][1]
|
|
238
|
+
last_ca, last_c = run[-1][1], run[-1][2]
|
|
239
|
+
if first_n in neighbors[last_c]:
|
|
240
|
+
chars.append(omega_char(last_ca, last_c, first_n, first_ca))
|
|
241
|
+
chains.append("".join(chars))
|
|
242
|
+
if not chains:
|
|
243
|
+
return None
|
|
244
|
+
return "/".join(chains)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def macrocycle_size(molecule: Molecule) -> Optional[int]:
|
|
248
|
+
"""Atom count of the macrocycle a classification is restricted to, if
|
|
249
|
+
any -- what the viewer tells the user so a string that got shorter does
|
|
250
|
+
not read as a regression.
|
|
251
|
+
|
|
252
|
+
This counts the fused ring SYSTEM, not a single cycle: a proline in the
|
|
253
|
+
ring shares its N-Cα bond with the pyrrolidine, so the two fuse and the
|
|
254
|
+
count runs three past the macrocycle's own size. Hence the viewer says
|
|
255
|
+
"N ring atoms" and not "N-membered", which would be a claim about a
|
|
256
|
+
cycle and would be wrong exactly there.
|
|
257
|
+
"""
|
|
258
|
+
runs, neighbors, symbols, carbonyls = _peptide_motifs(molecule)
|
|
259
|
+
if not runs:
|
|
260
|
+
return None
|
|
261
|
+
systems = _amide_ring_systems(molecule, neighbors, symbols, carbonyls)
|
|
262
|
+
return max((len(ring) for ring, _amides in systems), default=None)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
__all__ = ["classify_cis_trans", "macrocycle_size", "parse_energy",
|
|
266
|
+
"boltzmann_breakdown", "TEMPERATURE_K"]
|
|
@@ -25,27 +25,98 @@ def heavy_atoms(molecule: Molecule) -> np.ndarray:
|
|
|
25
25
|
)
|
|
26
26
|
|
|
27
27
|
|
|
28
|
-
def
|
|
29
|
-
"""
|
|
28
|
+
def bridge_edges(adjacency) -> set:
|
|
29
|
+
"""Every edge whose removal would disconnect the graph (Tarjan lowlink).
|
|
30
|
+
|
|
31
|
+
``adjacency[i]`` is any iterable of *i*'s neighbours. Edges come back
|
|
32
|
+
normalized to ``(low, high)``. The bridge set is a property of the graph
|
|
33
|
+
alone, so it does not depend on the file's atom order -- which is what
|
|
34
|
+
makes the ring systems built from it well-defined.
|
|
35
|
+
|
|
36
|
+
Iterative on purpose: the recursive form is shorter but blows the
|
|
37
|
+
interpreter's stack on a few-thousand-atom protein, and the selection
|
|
38
|
+
presets run on proteins.
|
|
39
|
+
"""
|
|
40
|
+
n = len(adjacency)
|
|
41
|
+
discovered = [-1] * n
|
|
42
|
+
low = [0] * n
|
|
43
|
+
bridges = set()
|
|
44
|
+
clock = 0
|
|
45
|
+
for root in range(n):
|
|
46
|
+
if discovered[root] != -1:
|
|
47
|
+
continue
|
|
48
|
+
discovered[root] = low[root] = clock
|
|
49
|
+
clock += 1
|
|
50
|
+
stack = [(root, -1, iter(adjacency[root]))]
|
|
51
|
+
while stack:
|
|
52
|
+
atom, parent, pending = stack[-1]
|
|
53
|
+
for other in pending:
|
|
54
|
+
if other == parent:
|
|
55
|
+
continue # the edge we arrived by, not a back edge
|
|
56
|
+
if discovered[other] == -1:
|
|
57
|
+
discovered[other] = low[other] = clock
|
|
58
|
+
clock += 1
|
|
59
|
+
stack.append((other, atom, iter(adjacency[other])))
|
|
60
|
+
break # descend; this iterator resumes later
|
|
61
|
+
low[atom] = min(low[atom], discovered[other])
|
|
62
|
+
else:
|
|
63
|
+
stack.pop()
|
|
64
|
+
if stack:
|
|
65
|
+
up = stack[-1][0]
|
|
66
|
+
low[up] = min(low[up], low[atom])
|
|
67
|
+
# Nothing under `atom` reaches `up` or higher, so the
|
|
68
|
+
# only way back out is the edge itself.
|
|
69
|
+
if low[atom] > discovered[up]:
|
|
70
|
+
bridges.add((min(up, atom), max(up, atom)))
|
|
71
|
+
return bridges
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def ring_systems(adjacency) -> List[Tuple[int, ...]]:
|
|
75
|
+
"""Fused ring systems in ``adjacency``, each a sorted tuple of atoms.
|
|
76
|
+
|
|
77
|
+
An edge lies on a cycle exactly when it is NOT a bridge, so deleting
|
|
78
|
+
the bridges leaves the cyclic part and nothing else; each surviving
|
|
79
|
+
connected component is one fused ring system. Anything smaller than a
|
|
80
|
+
three-membered cycle is dropped, which also discards the isolated atoms
|
|
81
|
+
that bridge deletion leaves behind (every hydrogen among them).
|
|
82
|
+
|
|
83
|
+
This replaced a 2-core peel, which was wrong for a reason worth
|
|
84
|
+
recording: an atom survives the 2-core when it merely has two
|
|
85
|
+
neighbours, not when it lies on a cycle. A plain chain joining two
|
|
86
|
+
rings is degree-2 all the way along, so peeling kept it, and the
|
|
87
|
+
"largest ring system" of a macrocycle with a pendant phenyl came back
|
|
88
|
+
as both rings PLUS the chain between them.
|
|
89
|
+
"""
|
|
90
|
+
bridges = bridge_edges(adjacency)
|
|
91
|
+
n = len(adjacency)
|
|
92
|
+
seen = [False] * n
|
|
93
|
+
systems = []
|
|
94
|
+
for root in range(n):
|
|
95
|
+
if seen[root]:
|
|
96
|
+
continue
|
|
97
|
+
seen[root] = True
|
|
98
|
+
component = [root]
|
|
99
|
+
stack = [root]
|
|
100
|
+
while stack:
|
|
101
|
+
atom = stack.pop()
|
|
102
|
+
for other in adjacency[atom]:
|
|
103
|
+
if seen[other] or (min(atom, other), max(atom, other)) in bridges:
|
|
104
|
+
continue
|
|
105
|
+
seen[other] = True
|
|
106
|
+
component.append(other)
|
|
107
|
+
stack.append(other)
|
|
108
|
+
if len(component) >= 3:
|
|
109
|
+
systems.append(tuple(sorted(component)))
|
|
110
|
+
return systems
|
|
30
111
|
|
|
31
|
-
An atom lies on at least one cycle exactly when it survives the graph's
|
|
32
|
-
2-core, so repeatedly stripping terminal atoms leaves the cyclic part and
|
|
33
|
-
nothing else; the largest connected component of that core is the answer.
|
|
34
|
-
Both passes are O(N+E).
|
|
35
112
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
order, so the same molecule written twice can yield different subsets.
|
|
39
|
-
The 2-core does not, and it keeps fused systems (naphthalene, steroids,
|
|
40
|
-
porphyrins) whole -- which is what a rigid-core RMSD subset wants anyway.
|
|
113
|
+
def heavy_adjacency(molecule: Molecule) -> List[set]:
|
|
114
|
+
"""Neighbour sets over heavy atoms only, hydrogens dropped.
|
|
41
115
|
|
|
42
116
|
Explicit bonds are preferred; unnamed formats fall back to the same
|
|
43
117
|
spatially hashed bond perception used by the viewer.
|
|
44
118
|
"""
|
|
45
119
|
n = molecule.n_atoms
|
|
46
|
-
if n < 3:
|
|
47
|
-
return np.empty(0, dtype=np.int64)
|
|
48
|
-
|
|
49
120
|
bonds = molecule.bonds or perceive_bonds(molecule)
|
|
50
121
|
adjacency = [set() for _ in range(n)]
|
|
51
122
|
symbols = molecule.symbols
|
|
@@ -57,46 +128,25 @@ def largest_ring_system(molecule: Molecule) -> np.ndarray:
|
|
|
57
128
|
continue
|
|
58
129
|
adjacency[i].add(j)
|
|
59
130
|
adjacency[j].add(i)
|
|
131
|
+
return adjacency
|
|
60
132
|
|
|
61
|
-
# Peel to the 2-core. Dropping a terminal atom can strand its neighbor,
|
|
62
|
-
# so the walk continues until nothing is left below degree two.
|
|
63
|
-
degree = np.fromiter((len(row) for row in adjacency), dtype=np.int64, count=n)
|
|
64
|
-
alive = degree > 0
|
|
65
|
-
pending = [i for i in range(n) if alive[i] and degree[i] < 2]
|
|
66
|
-
while pending:
|
|
67
|
-
atom = pending.pop()
|
|
68
|
-
if not alive[atom]:
|
|
69
|
-
continue
|
|
70
|
-
alive[atom] = False
|
|
71
|
-
for other in adjacency[atom]:
|
|
72
|
-
if alive[other]:
|
|
73
|
-
degree[other] -= 1
|
|
74
|
-
if degree[other] < 2:
|
|
75
|
-
pending.append(other)
|
|
76
|
-
|
|
77
|
-
# Every remaining component is a ring or a fused ring system; the smallest
|
|
78
|
-
# possible one is a three-membered cycle.
|
|
79
|
-
components = []
|
|
80
|
-
seen = np.zeros(n, dtype=np.bool_)
|
|
81
|
-
for root in range(n):
|
|
82
|
-
if not alive[root] or seen[root]:
|
|
83
|
-
continue
|
|
84
|
-
seen[root] = True
|
|
85
|
-
component = [root]
|
|
86
|
-
stack = [root]
|
|
87
|
-
while stack:
|
|
88
|
-
atom = stack.pop()
|
|
89
|
-
for other in adjacency[atom]:
|
|
90
|
-
if alive[other] and not seen[other]:
|
|
91
|
-
seen[other] = True
|
|
92
|
-
component.append(other)
|
|
93
|
-
stack.append(other)
|
|
94
|
-
components.append(tuple(sorted(component)))
|
|
95
133
|
|
|
96
|
-
|
|
134
|
+
def largest_ring_system(molecule: Molecule) -> np.ndarray:
|
|
135
|
+
"""Return the largest fused ring system in the heavy-atom bond graph.
|
|
136
|
+
|
|
137
|
+
Fused systems (naphthalene, steroids, porphyrins) stay whole, because
|
|
138
|
+
the bonds they share are on cycles and so are never bridges -- which is
|
|
139
|
+
what a rigid-core RMSD subset wants. Systems merely joined by a chain
|
|
140
|
+
or a single bond (biphenyl, a macrocycle with a pendant aryl) are
|
|
141
|
+
separate, and only the larger one comes back. See :func:`ring_systems`.
|
|
142
|
+
"""
|
|
143
|
+
if molecule.n_atoms < 3:
|
|
144
|
+
return np.empty(0, dtype=np.int64)
|
|
145
|
+
systems = ring_systems(heavy_adjacency(molecule))
|
|
146
|
+
if not systems:
|
|
97
147
|
return np.empty(0, dtype=np.int64)
|
|
98
148
|
# Stable tie-break: the earliest system in atom/file order wins.
|
|
99
|
-
chosen = min(
|
|
149
|
+
chosen = min(systems, key=lambda system: (-len(system), system))
|
|
100
150
|
return np.asarray(chosen, dtype=np.int64)
|
|
101
151
|
|
|
102
152
|
|
|
@@ -119,7 +169,7 @@ def peptide_motifs(molecule: Molecule) -> List[List[Tuple[int, int, int, Optiona
|
|
|
119
169
|
|
|
120
170
|
def _topology_backbone(molecule: Molecule, include_beta_carbon: bool) -> np.ndarray:
|
|
121
171
|
"""Infer N-Cα-C(-O) motifs from elements and connectivity in O(N+E)."""
|
|
122
|
-
runs, neighbors, symbols = _peptide_motifs(molecule)
|
|
172
|
+
runs, neighbors, symbols, _carbonyls = _peptide_motifs(molecule)
|
|
123
173
|
selected = set()
|
|
124
174
|
for run in runs:
|
|
125
175
|
for nitrogen, alpha, carbonyl, _oxygen in run:
|
|
@@ -144,7 +194,7 @@ def _peptide_motifs(molecule: Molecule):
|
|
|
144
194
|
"""(runs, neighbors, symbols) -- the shared half of both callers' work."""
|
|
145
195
|
n = molecule.n_atoms
|
|
146
196
|
if n < 4:
|
|
147
|
-
return [], [], molecule.symbols
|
|
197
|
+
return [], [], molecule.symbols, {}
|
|
148
198
|
bonds = molecule.bonds or perceive_bonds(molecule)
|
|
149
199
|
neighbors = [[] for _ in range(n)]
|
|
150
200
|
bond_orders = {}
|
|
@@ -169,12 +219,19 @@ def _peptide_motifs(molecule: Molecule):
|
|
|
169
219
|
return out
|
|
170
220
|
|
|
171
221
|
motifs = []
|
|
222
|
+
# Every carbonyl carbon in the molecule, collected on the way past at no
|
|
223
|
+
# extra cost. Callers that need to recognize an amide (cistrans, for a
|
|
224
|
+
# ring's side-chain lactam) must use THIS rule rather than rolling their
|
|
225
|
+
# own: "terminal oxygen" alone also matches a hydroxyl, which is the
|
|
226
|
+
# hydrogen-free threonine trap carbonyl_oxygens exists to avoid.
|
|
227
|
+
carbonyls = {}
|
|
172
228
|
for carbonyl, symbol in enumerate(symbols):
|
|
173
229
|
if symbol != "C":
|
|
174
230
|
continue
|
|
175
231
|
oxygens = carbonyl_oxygens(carbonyl)
|
|
176
232
|
if not oxygens:
|
|
177
233
|
continue
|
|
234
|
+
carbonyls[carbonyl] = oxygens
|
|
178
235
|
# The Cα is the carbonyl carbon's carbon neighbor that itself touches
|
|
179
236
|
# nitrogen. This tiny condition rejects common side-chain amides:
|
|
180
237
|
# their adjacent CB/CD carbon is not N-bound.
|
|
@@ -237,7 +294,7 @@ def _peptide_motifs(molecule: Molecule):
|
|
|
237
294
|
at = successor.get(at)
|
|
238
295
|
if run:
|
|
239
296
|
runs.append(run)
|
|
240
|
-
return runs, neighbors, symbols
|
|
297
|
+
return runs, neighbors, symbols, carbonyls
|
|
241
298
|
|
|
242
299
|
|
|
243
300
|
def peptide_backbone(molecule: Molecule, include_beta_carbon: bool = False) -> np.ndarray:
|
|
@@ -264,4 +321,5 @@ def peptide_backbone(molecule: Molecule, include_beta_carbon: bool = False) -> n
|
|
|
264
321
|
|
|
265
322
|
|
|
266
323
|
__all__ = ["heavy_atoms", "largest_ring_system", "peptide_backbone",
|
|
324
|
+
"ring_systems", "bridge_edges", "heavy_adjacency",
|
|
267
325
|
"peptide_motifs"]
|
|
@@ -4005,6 +4005,9 @@ class Viewer:
|
|
|
4005
4005
|
change as files are added or removed."""
|
|
4006
4006
|
base = ("cis/trans per peptide bond · ω=Cα-C-N-Cα, "
|
|
4007
4007
|
"|ω|<90° = cis · chains joined with /")
|
|
4008
|
+
ring = cistrans.macrocycle_size(self.structures.active.molecule)
|
|
4009
|
+
if ring:
|
|
4010
|
+
base += f" · ring only (macrocycle, {ring} ring atoms)"
|
|
4008
4011
|
energies = [cistrans.parse_energy(e.molecule.name)
|
|
4009
4012
|
for e in self.structures.entries]
|
|
4010
4013
|
missing = sum(1 for e in energies if e is None)
|
|
@@ -4041,6 +4044,11 @@ class Viewer:
|
|
|
4041
4044
|
for entry in sset.entries]
|
|
4042
4045
|
column.revisions = [entry.revision for entry in sset.entries]
|
|
4043
4046
|
classified = sum(1 for value in column.values if value is not None)
|
|
4047
|
+
# A macrocycle is classified on its ring alone, so its string is
|
|
4048
|
+
# SHORTER than the residue count would suggest. Say so, or the
|
|
4049
|
+
# missing characters read as a bug rather than as the point.
|
|
4050
|
+
ring = cistrans.macrocycle_size(sset.active.molecule)
|
|
4051
|
+
ring_note = f" · ring only (macrocycle, {ring} ring atoms)" if ring else ""
|
|
4044
4052
|
if classified == 0:
|
|
4045
4053
|
# "classified 0 frames" would be technically true and completely
|
|
4046
4054
|
# misleading -- nothing was found, say that.
|
|
@@ -4049,10 +4057,10 @@ class Viewer:
|
|
|
4049
4057
|
# A lone structure never gets a measurement table at all
|
|
4050
4058
|
# (_measure_layout returns [] below two entries), so the message
|
|
4051
4059
|
# is the only place its classification can appear.
|
|
4052
|
-
self._msg = f"{column.header}: {column.values[0]}"
|
|
4060
|
+
self._msg = f"{column.header}: {column.values[0]}{ring_note}"
|
|
4053
4061
|
else:
|
|
4054
4062
|
self._msg = (f"{column.header} classified {classified} of "
|
|
4055
|
-
f"{len(column.values)} frames")
|
|
4063
|
+
f"{len(column.values)} frames{ring_note}")
|
|
4056
4064
|
self._measure_layout_cache = None
|
|
4057
4065
|
self._refresh_measure_w()
|
|
4058
4066
|
return True
|
|
@@ -1092,6 +1092,49 @@ def test_largest_ring_system_strips_substituents_down_to_the_cycle():
|
|
|
1092
1092
|
[0, 1, 2, 3, 4, 5])
|
|
1093
1093
|
|
|
1094
1094
|
|
|
1095
|
+
def test_rings_joined_by_a_chain_are_separate_systems():
|
|
1096
|
+
"""The bug a 2-core peel could not see: an atom survives the 2-core when
|
|
1097
|
+
it merely has two neighbours, not when it lies on a cycle, so a plain
|
|
1098
|
+
chain BETWEEN two rings was kept and the "ring system" came back as both
|
|
1099
|
+
rings plus the linker. Here that answered all 15 atoms; only the larger
|
|
1100
|
+
ring is one."""
|
|
1101
|
+
molecule = _mol(["C"] * 15, np.zeros((15, 3)))
|
|
1102
|
+
molecule.bonds = [
|
|
1103
|
+
# six-ring 0-5, chain 5-6-7-8, five-ring 8-12, plus a tail
|
|
1104
|
+
(0, 1, 1), (1, 2, 1), (2, 3, 1), (3, 4, 1), (4, 5, 1), (5, 0, 1),
|
|
1105
|
+
(5, 6, 1), (6, 7, 1), (7, 8, 1),
|
|
1106
|
+
(8, 9, 1), (9, 10, 1), (10, 11, 1), (11, 12, 1), (12, 8, 1),
|
|
1107
|
+
(12, 13, 1), (13, 14, 1),
|
|
1108
|
+
]
|
|
1109
|
+
systems = vimol.select.ring_systems(vimol.select.heavy_adjacency(molecule))
|
|
1110
|
+
assert sorted(len(s) for s in systems) == [5, 6]
|
|
1111
|
+
assert np.array_equal(vimol.select.largest_ring_system(molecule),
|
|
1112
|
+
[0, 1, 2, 3, 4, 5])
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def test_biphenyl_rings_do_not_fuse_across_their_single_bond():
|
|
1116
|
+
"""A bond between two rings is a bridge, so the rings stay separate --
|
|
1117
|
+
unlike naphthalene's shared edge, which lies on a cycle."""
|
|
1118
|
+
molecule = _mol(["C"] * 12, np.zeros((12, 3)))
|
|
1119
|
+
molecule.bonds = [(i, (i + 1) % 6, 1) for i in range(6)]
|
|
1120
|
+
molecule.bonds += [(6 + i, 6 + (i + 1) % 6, 1) for i in range(6)]
|
|
1121
|
+
molecule.bonds.append((0, 6, 1))
|
|
1122
|
+
systems = vimol.select.ring_systems(vimol.select.heavy_adjacency(molecule))
|
|
1123
|
+
assert sorted(len(s) for s in systems) == [6, 6]
|
|
1124
|
+
assert np.array_equal(vimol.select.largest_ring_system(molecule),
|
|
1125
|
+
[0, 1, 2, 3, 4, 5])
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def test_bridge_detection_survives_a_deep_chain():
|
|
1129
|
+
"""Iterative on purpose: a recursive lowlink walk blows the interpreter
|
|
1130
|
+
stack here, and the selection presets run on whole proteins."""
|
|
1131
|
+
n = 5000
|
|
1132
|
+
molecule = _mol(["C"] * n, np.zeros((n, 3)))
|
|
1133
|
+
molecule.bonds = [(i, i + 1, 1) for i in range(n - 1)]
|
|
1134
|
+
molecule.bonds.append((n - 1, 0, 1)) # one huge cycle
|
|
1135
|
+
assert len(vimol.select.largest_ring_system(molecule)) == n
|
|
1136
|
+
|
|
1137
|
+
|
|
1095
1138
|
def test_largest_ring_system_perceives_bonds_for_unnamed_xyz():
|
|
1096
1139
|
angles = np.arange(6) * (np.pi / 3.0)
|
|
1097
1140
|
positions = np.column_stack((1.4 * np.cos(angles),
|