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.
Files changed (63) hide show
  1. {vimol-0.1.4/src/vimol.egg-info → vimol-0.1.6}/PKG-INFO +1 -1
  2. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/__init__.py +1 -1
  3. vimol-0.1.6/src/vimol/cistrans.py +266 -0
  4. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/select.py +111 -53
  5. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/viewer.py +10 -2
  6. {vimol-0.1.4 → vimol-0.1.6/src/vimol.egg-info}/PKG-INFO +1 -1
  7. {vimol-0.1.4 → vimol-0.1.6}/tests/test_align.py +43 -0
  8. vimol-0.1.6/tests/test_cistrans.py +281 -0
  9. vimol-0.1.4/src/vimol/cistrans.py +0 -142
  10. vimol-0.1.4/tests/test_cistrans.py +0 -146
  11. {vimol-0.1.4 → vimol-0.1.6}/LICENSE +0 -0
  12. {vimol-0.1.4 → vimol-0.1.6}/README.md +0 -0
  13. {vimol-0.1.4 → vimol-0.1.6}/pyproject.toml +0 -0
  14. {vimol-0.1.4 → vimol-0.1.6}/setup.cfg +0 -0
  15. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/__main__.py +0 -0
  16. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/_render_fast.py +0 -0
  17. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/align.py +0 -0
  18. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/app.py +0 -0
  19. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/arrows.py +0 -0
  20. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/bonds.py +0 -0
  21. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/camera.py +0 -0
  22. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/data/c60.xyz +0 -0
  23. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/editor.py +0 -0
  24. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/elements.py +0 -0
  25. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/file_browser.py +0 -0
  26. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/gl_adapter.py +0 -0
  27. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/gl_render.py +0 -0
  28. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/glyph_font.py +0 -0
  29. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/glyph_mesh.py +0 -0
  30. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/glyph_outlines.py +0 -0
  31. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/glyphs.py +0 -0
  32. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/input.py +0 -0
  33. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/kitty.py +0 -0
  34. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/molecule.py +0 -0
  35. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/parsers/__init__.py +0 -0
  36. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/parsers/mol.py +0 -0
  37. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/parsers/pdb.py +0 -0
  38. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/parsers/xyz.py +0 -0
  39. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/periodic_table.py +0 -0
  40. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/py.typed +0 -0
  41. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/render.py +0 -0
  42. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/render_glyph.py +0 -0
  43. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/residues.py +0 -0
  44. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/scene.py +0 -0
  45. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/structures.py +0 -0
  46. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/templates.py +0 -0
  47. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/theme.py +0 -0
  48. {vimol-0.1.4 → vimol-0.1.6}/src/vimol/widget.py +0 -0
  49. {vimol-0.1.4 → vimol-0.1.6}/src/vimol.egg-info/SOURCES.txt +0 -0
  50. {vimol-0.1.4 → vimol-0.1.6}/src/vimol.egg-info/dependency_links.txt +0 -0
  51. {vimol-0.1.4 → vimol-0.1.6}/src/vimol.egg-info/entry_points.txt +0 -0
  52. {vimol-0.1.4 → vimol-0.1.6}/src/vimol.egg-info/requires.txt +0 -0
  53. {vimol-0.1.4 → vimol-0.1.6}/src/vimol.egg-info/top_level.txt +0 -0
  54. {vimol-0.1.4 → vimol-0.1.6}/tests/test_add_file.py +0 -0
  55. {vimol-0.1.4 → vimol-0.1.6}/tests/test_bonds.py +0 -0
  56. {vimol-0.1.4 → vimol-0.1.6}/tests/test_editing.py +0 -0
  57. {vimol-0.1.4 → vimol-0.1.6}/tests/test_file_browser.py +0 -0
  58. {vimol-0.1.4 → vimol-0.1.6}/tests/test_gl_render.py +0 -0
  59. {vimol-0.1.4 → vimol-0.1.6}/tests/test_glyphs.py +0 -0
  60. {vimol-0.1.4 → vimol-0.1.6}/tests/test_render_fast.py +0 -0
  61. {vimol-0.1.4 → vimol-0.1.6}/tests/test_scene_structures.py +0 -0
  62. {vimol-0.1.4 → vimol-0.1.6}/tests/test_structures.py +0 -0
  63. {vimol-0.1.4 → vimol-0.1.6}/tests/test_vimol.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vimol
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: A terminal molecular viewer and editor using the Kitty graphics protocol
5
5
  Author: vimol contributors
6
6
  License-Expression: MIT
@@ -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.4"
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 largest_ring_system(molecule: Molecule) -> np.ndarray:
29
- """Return the largest fused ring system in the heavy-atom bond graph.
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
- A depth-first cycle basis would be just as cheap but is not a well-defined
37
- answer: which fundamental cycles it finds depends on the file's atom
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
- if not components:
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(components, key=lambda system: (-len(system), system))
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vimol
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: A terminal molecular viewer and editor using the Kitty graphics protocol
5
5
  Author: vimol contributors
6
6
  License-Expression: MIT
@@ -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),