vimol 0.1.3__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 (64) hide show
  1. vimol-0.1.6/PKG-INFO +142 -0
  2. vimol-0.1.6/README.md +106 -0
  3. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/__init__.py +1 -1
  4. vimol-0.1.6/src/vimol/cistrans.py +266 -0
  5. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/select.py +111 -53
  6. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/viewer.py +378 -110
  7. vimol-0.1.6/src/vimol.egg-info/PKG-INFO +142 -0
  8. {vimol-0.1.3 → vimol-0.1.6}/src/vimol.egg-info/SOURCES.txt +2 -0
  9. {vimol-0.1.3 → vimol-0.1.6}/tests/test_align.py +43 -0
  10. vimol-0.1.6/tests/test_cistrans.py +281 -0
  11. {vimol-0.1.3 → vimol-0.1.6}/tests/test_vimol.py +505 -115
  12. vimol-0.1.3/PKG-INFO +0 -199
  13. vimol-0.1.3/README.md +0 -163
  14. vimol-0.1.3/src/vimol.egg-info/PKG-INFO +0 -199
  15. {vimol-0.1.3 → vimol-0.1.6}/LICENSE +0 -0
  16. {vimol-0.1.3 → vimol-0.1.6}/pyproject.toml +0 -0
  17. {vimol-0.1.3 → vimol-0.1.6}/setup.cfg +0 -0
  18. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/__main__.py +0 -0
  19. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/_render_fast.py +0 -0
  20. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/align.py +0 -0
  21. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/app.py +0 -0
  22. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/arrows.py +0 -0
  23. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/bonds.py +0 -0
  24. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/camera.py +0 -0
  25. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/data/c60.xyz +0 -0
  26. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/editor.py +0 -0
  27. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/elements.py +0 -0
  28. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/file_browser.py +0 -0
  29. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/gl_adapter.py +0 -0
  30. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/gl_render.py +0 -0
  31. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/glyph_font.py +0 -0
  32. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/glyph_mesh.py +0 -0
  33. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/glyph_outlines.py +0 -0
  34. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/glyphs.py +0 -0
  35. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/input.py +0 -0
  36. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/kitty.py +0 -0
  37. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/molecule.py +0 -0
  38. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/parsers/__init__.py +0 -0
  39. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/parsers/mol.py +0 -0
  40. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/parsers/pdb.py +0 -0
  41. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/parsers/xyz.py +0 -0
  42. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/periodic_table.py +0 -0
  43. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/py.typed +0 -0
  44. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/render.py +0 -0
  45. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/render_glyph.py +0 -0
  46. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/residues.py +0 -0
  47. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/scene.py +0 -0
  48. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/structures.py +0 -0
  49. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/templates.py +0 -0
  50. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/theme.py +0 -0
  51. {vimol-0.1.3 → vimol-0.1.6}/src/vimol/widget.py +0 -0
  52. {vimol-0.1.3 → vimol-0.1.6}/src/vimol.egg-info/dependency_links.txt +0 -0
  53. {vimol-0.1.3 → vimol-0.1.6}/src/vimol.egg-info/entry_points.txt +0 -0
  54. {vimol-0.1.3 → vimol-0.1.6}/src/vimol.egg-info/requires.txt +0 -0
  55. {vimol-0.1.3 → vimol-0.1.6}/src/vimol.egg-info/top_level.txt +0 -0
  56. {vimol-0.1.3 → vimol-0.1.6}/tests/test_add_file.py +0 -0
  57. {vimol-0.1.3 → vimol-0.1.6}/tests/test_bonds.py +0 -0
  58. {vimol-0.1.3 → vimol-0.1.6}/tests/test_editing.py +0 -0
  59. {vimol-0.1.3 → vimol-0.1.6}/tests/test_file_browser.py +0 -0
  60. {vimol-0.1.3 → vimol-0.1.6}/tests/test_gl_render.py +0 -0
  61. {vimol-0.1.3 → vimol-0.1.6}/tests/test_glyphs.py +0 -0
  62. {vimol-0.1.3 → vimol-0.1.6}/tests/test_render_fast.py +0 -0
  63. {vimol-0.1.3 → vimol-0.1.6}/tests/test_scene_structures.py +0 -0
  64. {vimol-0.1.3 → vimol-0.1.6}/tests/test_structures.py +0 -0
vimol-0.1.6/PKG-INFO ADDED
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: vimol
3
+ Version: 0.1.6
4
+ Summary: A terminal molecular viewer and editor using the Kitty graphics protocol
5
+ Author: vimol contributors
6
+ License-Expression: MIT
7
+ Keywords: molecular,viewer,editor,terminal,kitty,chemistry,visualization,xyz,pdb,sdf,cli,tui
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering :: Chemistry
22
+ Classifier: Topic :: Scientific/Engineering :: Visualization
23
+ Classifier: Topic :: Multimedia :: Graphics :: Viewers
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.8
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: numpy>=1.20
29
+ Requires-Dist: moderngl>=5.6
30
+ Requires-Dist: numba>=0.59
31
+ Provides-Extra: test
32
+ Requires-Dist: pytest>=7; extra == "test"
33
+ Provides-Extra: align
34
+ Requires-Dist: scipy>=1.4; extra == "align"
35
+ Dynamic: license-file
36
+
37
+ # vimol
38
+
39
+ A molecular viewer and editor that runs **in your terminal**. Open an `.xyz`,
40
+ `.pdb`, `.mol` or `.sdf` file and get a shaded, rotatable, editable 3D
41
+ structure right where you are already working — over SSH, in tmux, beside your
42
+ editor.
43
+
44
+ ```bash
45
+ pip install vimol
46
+ vimol molecule.xyz
47
+ ```
48
+
49
+ ![C60 spinning in the terminal, rendered by vimol](https://raw.githubusercontent.com/hanimustafa/vimol/main/docs/media/spin.gif)
50
+
51
+ That is the C60 bundled with vimol, which `vimol` opens with no arguments.
52
+ Drag to rotate, scroll to zoom, option-drag empty space to pan, hover an atom
53
+ to identify it, `?` for every binding.
54
+
55
+ You need Python 3.8 or newer and a terminal that speaks the
56
+ [Kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/) —
57
+ **kitty, Ghostty or WezTerm**. Rendering is pure software, with an OpenGL path
58
+ used automatically where a GPU is available. MIT licensed.
59
+
60
+ ## Proteins out of bare coordinates
61
+
62
+ ![vimol opening a bare xyz protein and switching to the lettered residue view](https://raw.githubusercontent.com/hanimustafa/vimol/main/docs/media/protein.gif)
63
+
64
+ An `.xyz` file has no residue names, no atom names and no chains. vimol finds
65
+ the backbone anyway, opens the file as a ribbon, and on `6` letters every
66
+ residue onto it. The trp-cage above is PDB 1RIJ stripped to bare coordinates,
67
+ which vimol reads back as `ALQELLGQWLKDGGPSSGRPPPS` — character for character
68
+ the sequence in that entry.
69
+
70
+ ## Overlays and RMSD
71
+
72
+ ![vimol overlaying 72 CREST conformers on a reference structure and reporting each RMSD](https://raw.githubusercontent.com/hanimustafa/vimol/main/docs/media/align.gif)
73
+
74
+ Open several files, or a trajectory, and every structure lands in one session,
75
+ overlaid and colour-coded down the left. `r` aligns the whole overlay onto the
76
+ active structure and reports each RMSD in a column beside the list — above, a
77
+ chignolin crystal structure and 72 CREST conformers of it. `R` runs the same
78
+ fit on only the atoms you pick.
79
+
80
+ ## Editing
81
+
82
+ ![vimol growing a methyl group onto threonine's side-chain hydroxyl](https://raw.githubusercontent.com/hanimustafa/vimol/main/docs/media/methyl.gif)
83
+
84
+ Editing is on by default. Click a hydrogen to grow a fragment in its place, a
85
+ heavy atom to swap the element, empty space to start something new — one click
86
+ turns threonine into O-methylthreonine. Bonds, deletion, clash cleanup, undo
87
+ and save are a keystroke each.
88
+
89
+ ## As a library
90
+
91
+ ![a C60 painted into the middle of a terminal by three lines of vimol](https://raw.githubusercontent.com/hanimustafa/vimol/main/docs/media/library.png)
92
+
93
+ Three lines put a molecule anywhere on the screen:
94
+
95
+ ```python
96
+ import os, vimol
97
+
98
+ scene = vimol.Scene(vimol.load("c60.xyz"), 640, 380) # render it off-screen
99
+ scene.style.transparent = True # let the terminal through
100
+ os.write(1, b"\x1b[6;19H" + scene.to_kitty()) # paint at row 6, col 19
101
+ ```
102
+
103
+ `scene.render()` hands you the pixels as a numpy array instead and
104
+ `scene.to_png()` writes a file. To let the reader turn the molecule,
105
+ `MoleculeWidget` takes the input events you hand it and leaves the loop to you;
106
+ `examples/embed_demo.py` is a complete host application built that way.
107
+
108
+ ## Reference
109
+
110
+ | Key | Does |
111
+ |---|---|
112
+ | Drag / arrows / `hjkl` | rotate |
113
+ | Wheel, `+` `-` | zoom |
114
+ | Right or middle drag | pan |
115
+ | `[` `]` | roll |
116
+ | `1` `2` `3` `4` | ball-and-stick, space-filling, licorice, wireframe |
117
+ | `5` `6` | ribbon, lettered residues (proteins) |
118
+ | `f` `z` | re-fit, reset the view |
119
+ | `m` | measure — click 2, 3 or 4 atoms for distance, angle, dihedral |
120
+ | `a` `x` `c` `u` `s` | append, delete, cleanup clashes, undo, save |
121
+ | `r` `R` | align an overlay, or align on picked atoms |
122
+ | `Shift+S` | ready-made selections: backbone, heavy atoms, ring system |
123
+ | `A` `n` `p` | add a file, next and previous structure |
124
+ | `Tab` | focus the structure list |
125
+ | `d` `g` `t` `ctrl-t` | depth cue, hi-quality, transparent background, theme |
126
+ | `?` `q` | help, quit |
127
+
128
+ ```bash
129
+ vimol traj.xyz --spin --style spacefill # spin a trajectory, space-filling
130
+ vimol protein.pdb --style glyph # lettered residues on a ribbon
131
+ vimol a.xyz b.pdb --style ribbon # overlay two proteins
132
+ ```
133
+
134
+ Several files always open in ball-and-stick, so pass `--style ribbon` for
135
+ proteins. Also `--backend cpu|gl|auto`, `--theme dark|light|auto`,
136
+ `--rotate YAW PITCH`, `--frame N`, `--background`,
137
+ `--transparent`/`--opaque`, `--atom-scale`, `--bond-radius`,
138
+ `--no-depth-cue`, `--no-bonds`, `--bond-tolerance`, `--list-formats`.
139
+ `pip install vimol[align]` adds scipy, which speeds up subset alignment.
140
+
141
+ The protein, alignment and editing animations are recorded by
142
+ `scripts/record_demos.py`.
vimol-0.1.6/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # vimol
2
+
3
+ A molecular viewer and editor that runs **in your terminal**. Open an `.xyz`,
4
+ `.pdb`, `.mol` or `.sdf` file and get a shaded, rotatable, editable 3D
5
+ structure right where you are already working — over SSH, in tmux, beside your
6
+ editor.
7
+
8
+ ```bash
9
+ pip install vimol
10
+ vimol molecule.xyz
11
+ ```
12
+
13
+ ![C60 spinning in the terminal, rendered by vimol](https://raw.githubusercontent.com/hanimustafa/vimol/main/docs/media/spin.gif)
14
+
15
+ That is the C60 bundled with vimol, which `vimol` opens with no arguments.
16
+ Drag to rotate, scroll to zoom, option-drag empty space to pan, hover an atom
17
+ to identify it, `?` for every binding.
18
+
19
+ You need Python 3.8 or newer and a terminal that speaks the
20
+ [Kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/) —
21
+ **kitty, Ghostty or WezTerm**. Rendering is pure software, with an OpenGL path
22
+ used automatically where a GPU is available. MIT licensed.
23
+
24
+ ## Proteins out of bare coordinates
25
+
26
+ ![vimol opening a bare xyz protein and switching to the lettered residue view](https://raw.githubusercontent.com/hanimustafa/vimol/main/docs/media/protein.gif)
27
+
28
+ An `.xyz` file has no residue names, no atom names and no chains. vimol finds
29
+ the backbone anyway, opens the file as a ribbon, and on `6` letters every
30
+ residue onto it. The trp-cage above is PDB 1RIJ stripped to bare coordinates,
31
+ which vimol reads back as `ALQELLGQWLKDGGPSSGRPPPS` — character for character
32
+ the sequence in that entry.
33
+
34
+ ## Overlays and RMSD
35
+
36
+ ![vimol overlaying 72 CREST conformers on a reference structure and reporting each RMSD](https://raw.githubusercontent.com/hanimustafa/vimol/main/docs/media/align.gif)
37
+
38
+ Open several files, or a trajectory, and every structure lands in one session,
39
+ overlaid and colour-coded down the left. `r` aligns the whole overlay onto the
40
+ active structure and reports each RMSD in a column beside the list — above, a
41
+ chignolin crystal structure and 72 CREST conformers of it. `R` runs the same
42
+ fit on only the atoms you pick.
43
+
44
+ ## Editing
45
+
46
+ ![vimol growing a methyl group onto threonine's side-chain hydroxyl](https://raw.githubusercontent.com/hanimustafa/vimol/main/docs/media/methyl.gif)
47
+
48
+ Editing is on by default. Click a hydrogen to grow a fragment in its place, a
49
+ heavy atom to swap the element, empty space to start something new — one click
50
+ turns threonine into O-methylthreonine. Bonds, deletion, clash cleanup, undo
51
+ and save are a keystroke each.
52
+
53
+ ## As a library
54
+
55
+ ![a C60 painted into the middle of a terminal by three lines of vimol](https://raw.githubusercontent.com/hanimustafa/vimol/main/docs/media/library.png)
56
+
57
+ Three lines put a molecule anywhere on the screen:
58
+
59
+ ```python
60
+ import os, vimol
61
+
62
+ scene = vimol.Scene(vimol.load("c60.xyz"), 640, 380) # render it off-screen
63
+ scene.style.transparent = True # let the terminal through
64
+ os.write(1, b"\x1b[6;19H" + scene.to_kitty()) # paint at row 6, col 19
65
+ ```
66
+
67
+ `scene.render()` hands you the pixels as a numpy array instead and
68
+ `scene.to_png()` writes a file. To let the reader turn the molecule,
69
+ `MoleculeWidget` takes the input events you hand it and leaves the loop to you;
70
+ `examples/embed_demo.py` is a complete host application built that way.
71
+
72
+ ## Reference
73
+
74
+ | Key | Does |
75
+ |---|---|
76
+ | Drag / arrows / `hjkl` | rotate |
77
+ | Wheel, `+` `-` | zoom |
78
+ | Right or middle drag | pan |
79
+ | `[` `]` | roll |
80
+ | `1` `2` `3` `4` | ball-and-stick, space-filling, licorice, wireframe |
81
+ | `5` `6` | ribbon, lettered residues (proteins) |
82
+ | `f` `z` | re-fit, reset the view |
83
+ | `m` | measure — click 2, 3 or 4 atoms for distance, angle, dihedral |
84
+ | `a` `x` `c` `u` `s` | append, delete, cleanup clashes, undo, save |
85
+ | `r` `R` | align an overlay, or align on picked atoms |
86
+ | `Shift+S` | ready-made selections: backbone, heavy atoms, ring system |
87
+ | `A` `n` `p` | add a file, next and previous structure |
88
+ | `Tab` | focus the structure list |
89
+ | `d` `g` `t` `ctrl-t` | depth cue, hi-quality, transparent background, theme |
90
+ | `?` `q` | help, quit |
91
+
92
+ ```bash
93
+ vimol traj.xyz --spin --style spacefill # spin a trajectory, space-filling
94
+ vimol protein.pdb --style glyph # lettered residues on a ribbon
95
+ vimol a.xyz b.pdb --style ribbon # overlay two proteins
96
+ ```
97
+
98
+ Several files always open in ball-and-stick, so pass `--style ribbon` for
99
+ proteins. Also `--backend cpu|gl|auto`, `--theme dark|light|auto`,
100
+ `--rotate YAW PITCH`, `--frame N`, `--background`,
101
+ `--transparent`/`--opaque`, `--atom-scale`, `--bond-radius`,
102
+ `--no-depth-cue`, `--no-bonds`, `--bond-tolerance`, `--list-formats`.
103
+ `pip install vimol[align]` adds scipy, which speeds up subset alignment.
104
+
105
+ The protein, alignment and editing animations are recorded by
106
+ `scripts/record_demos.py`.
@@ -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.3"
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"]