vimol 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vimol/__init__.py +63 -0
- vimol/__main__.py +4 -0
- vimol/app.py +218 -0
- vimol/arrows.py +91 -0
- vimol/bonds.py +79 -0
- vimol/camera.py +77 -0
- vimol/editor.py +646 -0
- vimol/elements.py +123 -0
- vimol/gl_adapter.py +114 -0
- vimol/gl_render.py +742 -0
- vimol/input.py +252 -0
- vimol/kitty.py +297 -0
- vimol/molecule.py +159 -0
- vimol/parsers/__init__.py +88 -0
- vimol/parsers/mol.py +57 -0
- vimol/parsers/pdb.py +97 -0
- vimol/parsers/xyz.py +68 -0
- vimol/periodic_table.py +97 -0
- vimol/py.typed +0 -0
- vimol/render.py +600 -0
- vimol/scene.py +239 -0
- vimol/templates.py +205 -0
- vimol/viewer.py +1159 -0
- vimol/widget.py +611 -0
- vimol-0.1.0.dist-info/METADATA +302 -0
- vimol-0.1.0.dist-info/RECORD +30 -0
- vimol-0.1.0.dist-info/WHEEL +5 -0
- vimol-0.1.0.dist-info/entry_points.txt +2 -0
- vimol-0.1.0.dist-info/licenses/LICENSE +20 -0
- vimol-0.1.0.dist-info/top_level.txt +1 -0
vimol/molecule.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""The core molecular data model.
|
|
2
|
+
|
|
3
|
+
A :class:`Molecule` stores atoms as parallel numpy arrays (positions, colors,
|
|
4
|
+
radii) plus a bond list. Keeping everything as numpy arrays lets the renderer
|
|
5
|
+
operate on the whole structure vectorized.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import List, Optional, Sequence, Set, Tuple
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from . import elements
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class VectorField:
|
|
19
|
+
"""A set of per-atom vectors (e.g. dipoles, forces) drawn as arrows.
|
|
20
|
+
|
|
21
|
+
``vectors`` is atom-aligned with :attr:`Molecule.positions` -- an atom
|
|
22
|
+
with a near-zero vector simply has no arrow drawn. Color is the only
|
|
23
|
+
thing distinguishing one field from another (this layer has no notion
|
|
24
|
+
of what the vectors represent); magnitudes are never normalized, so
|
|
25
|
+
arrow length is ``|vector| * scale``.
|
|
26
|
+
"""
|
|
27
|
+
vectors: np.ndarray # (N, 3) angstrom, atom-aligned
|
|
28
|
+
color: Tuple[float, float, float] = (0.15, 0.95, 0.85)
|
|
29
|
+
scale: float = 1.0 # arrow length = |vector| * scale
|
|
30
|
+
radius: float = 0.05 # shaft radius, angstrom
|
|
31
|
+
head_scale: float = 2.5 # head base radius = radius * head_scale
|
|
32
|
+
head_length_frac: float = 0.25 # fraction of arrow length that's the head
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Molecule:
|
|
37
|
+
"""A set of atoms and the bonds between them.
|
|
38
|
+
|
|
39
|
+
Attributes
|
|
40
|
+
----------
|
|
41
|
+
symbols: list of element symbols, length N
|
|
42
|
+
positions: (N, 3) float array, angstrom
|
|
43
|
+
bonds: list of (i, j, order) tuples, i < j
|
|
44
|
+
name: free-form label
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
symbols: List[str] = field(default_factory=list)
|
|
48
|
+
positions: np.ndarray = field(default_factory=lambda: np.zeros((0, 3), np.float64))
|
|
49
|
+
bonds: List[Tuple[int, int, int]] = field(default_factory=list)
|
|
50
|
+
name: str = ""
|
|
51
|
+
vector_fields: List[VectorField] = field(default_factory=list)
|
|
52
|
+
# Explicit bonds (e.g. drawn by option-drag) that survive re-perception
|
|
53
|
+
# even beyond the auto distance threshold; (i, j, order) with i < j, like
|
|
54
|
+
# `bonds`. `editor._reperceive` unions these into `bonds` on every rebuild.
|
|
55
|
+
manual_bonds: List[Tuple[int, int, int]] = field(default_factory=list)
|
|
56
|
+
# Indices of atoms created by editing since load (or since the last
|
|
57
|
+
# `editor.cleanup` accepted the relaxed geometry). `editor.py` marks every
|
|
58
|
+
# atom it creates; `editor.cleanup` reads this to weight its relaxation
|
|
59
|
+
# and clears it once the geometry is "accepted".
|
|
60
|
+
new_atoms: Set[int] = field(default_factory=set)
|
|
61
|
+
|
|
62
|
+
# -- construction -----------------------------------------------------
|
|
63
|
+
def add_atom(self, symbol: str, x: float, y: float, z: float) -> int:
|
|
64
|
+
idx = len(self.symbols)
|
|
65
|
+
self.symbols.append(elements.normalize_symbol(symbol))
|
|
66
|
+
self.positions = np.vstack([self.positions, [x, y, z]]) if len(self.positions) else np.array([[x, y, z]], float)
|
|
67
|
+
return idx
|
|
68
|
+
|
|
69
|
+
def add_bond(self, i: int, j: int, order: int = 1) -> None:
|
|
70
|
+
if i > j:
|
|
71
|
+
i, j = j, i
|
|
72
|
+
self.bonds.append((i, j, order))
|
|
73
|
+
|
|
74
|
+
def add_vector_field(self, vectors, color=(0.15, 0.95, 0.85), scale: float = 1.0,
|
|
75
|
+
radius: float = 0.05, head_scale: float = 2.5,
|
|
76
|
+
head_length_frac: float = 0.25) -> VectorField:
|
|
77
|
+
"""Attach a set of per-atom vectors (e.g. dipoles) to be drawn as arrows."""
|
|
78
|
+
vectors = np.asarray(vectors, np.float64)
|
|
79
|
+
if vectors.shape != (self.n_atoms, 3):
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"vectors must have shape ({self.n_atoms}, 3), got {vectors.shape}")
|
|
82
|
+
vf = VectorField(vectors=vectors, color=tuple(color), scale=scale, radius=radius,
|
|
83
|
+
head_scale=head_scale, head_length_frac=head_length_frac)
|
|
84
|
+
self.vector_fields.append(vf)
|
|
85
|
+
return vf
|
|
86
|
+
|
|
87
|
+
# -- derived quantities ----------------------------------------------
|
|
88
|
+
@property
|
|
89
|
+
def n_atoms(self) -> int:
|
|
90
|
+
return len(self.symbols)
|
|
91
|
+
|
|
92
|
+
def centroid(self) -> np.ndarray:
|
|
93
|
+
if self.n_atoms == 0:
|
|
94
|
+
return np.zeros(3)
|
|
95
|
+
return self.positions.mean(axis=0)
|
|
96
|
+
|
|
97
|
+
def radius_of_gyration_extent(self) -> float:
|
|
98
|
+
"""A robust size estimate: max distance of any atom from the centroid."""
|
|
99
|
+
if self.n_atoms == 0:
|
|
100
|
+
return 1.0
|
|
101
|
+
d = np.linalg.norm(self.positions - self.centroid(), axis=1)
|
|
102
|
+
return float(max(d.max(), 1e-3))
|
|
103
|
+
|
|
104
|
+
def vector_extent(self) -> float:
|
|
105
|
+
"""Max centroid-to-arrow-tip distance across all vector fields, 0 if none.
|
|
106
|
+
|
|
107
|
+
Mirrors :meth:`radius_of_gyration_extent` so the camera's auto-fit can
|
|
108
|
+
pad for long arrows the same way it pads for atom radii.
|
|
109
|
+
"""
|
|
110
|
+
if not self.vector_fields or self.n_atoms == 0:
|
|
111
|
+
return 0.0
|
|
112
|
+
c = self.centroid()
|
|
113
|
+
best = 0.0
|
|
114
|
+
for vf in self.vector_fields:
|
|
115
|
+
# A field can go stale if atoms are added after it was attached;
|
|
116
|
+
# skip it here exactly as build_arrow_geometry does (rather than
|
|
117
|
+
# letting the (N+1,3)+(N,3) broadcast raise mid-render).
|
|
118
|
+
if np.asarray(vf.vectors).shape != (self.n_atoms, 3):
|
|
119
|
+
continue
|
|
120
|
+
tips = self.positions + vf.vectors * vf.scale
|
|
121
|
+
# pad by the cone-head base radius so a fat arrowhead at the edge
|
|
122
|
+
# of the scene isn't clipped by auto-fit.
|
|
123
|
+
d = np.linalg.norm(tips - c, axis=1) + vf.radius * vf.head_scale
|
|
124
|
+
best = max(best, float(d.max()))
|
|
125
|
+
return best
|
|
126
|
+
|
|
127
|
+
def element_colors(self) -> np.ndarray:
|
|
128
|
+
return np.array([elements.element_color(s) for s in self.symbols], np.float64)
|
|
129
|
+
|
|
130
|
+
def covalent_radii(self) -> np.ndarray:
|
|
131
|
+
return np.array([elements.covalent_radius(s) for s in self.symbols], np.float64)
|
|
132
|
+
|
|
133
|
+
def vdw_radii(self) -> np.ndarray:
|
|
134
|
+
return np.array([elements.vdw_radius(s) for s in self.symbols], np.float64)
|
|
135
|
+
|
|
136
|
+
def atomic_numbers(self) -> np.ndarray:
|
|
137
|
+
return np.array([elements.symbol_to_z(s) for s in self.symbols], np.int32)
|
|
138
|
+
|
|
139
|
+
def recenter(self) -> "Molecule":
|
|
140
|
+
"""Translate so the centroid sits at the origin (in place)."""
|
|
141
|
+
if self.n_atoms:
|
|
142
|
+
self.positions = self.positions - self.centroid()
|
|
143
|
+
return self
|
|
144
|
+
|
|
145
|
+
def formula(self) -> str:
|
|
146
|
+
"""Hill-system molecular formula, e.g. 'C8H10N4O2'."""
|
|
147
|
+
from collections import Counter
|
|
148
|
+
c = Counter(self.symbols)
|
|
149
|
+
parts = []
|
|
150
|
+
for el in ("C", "H"):
|
|
151
|
+
if c.get(el):
|
|
152
|
+
parts.append(el + (str(c[el]) if c[el] > 1 else ""))
|
|
153
|
+
del c[el]
|
|
154
|
+
for el in sorted(c):
|
|
155
|
+
parts.append(el + (str(c[el]) if c[el] > 1 else ""))
|
|
156
|
+
return "".join(parts)
|
|
157
|
+
|
|
158
|
+
def __repr__(self) -> str:
|
|
159
|
+
return f"<Molecule {self.name!r} atoms={self.n_atoms} bonds={len(self.bonds)} formula={self.formula()}>"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Molecular file-format parsers and a format-dispatching loader."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from ..molecule import Molecule
|
|
8
|
+
from . import xyz as _xyz
|
|
9
|
+
from . import pdb as _pdb
|
|
10
|
+
from . import mol as _mol
|
|
11
|
+
|
|
12
|
+
_EXT_PARSERS = {
|
|
13
|
+
".xyz": _xyz.parse,
|
|
14
|
+
".pdb": _pdb.parse,
|
|
15
|
+
".ent": _pdb.parse,
|
|
16
|
+
".mol": _mol.parse,
|
|
17
|
+
".sdf": _mol.parse,
|
|
18
|
+
".mdl": _mol.parse,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
SUPPORTED_EXTENSIONS = tuple(sorted(_EXT_PARSERS))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load(path: str, fmt: str | None = None) -> Molecule:
|
|
25
|
+
"""Load a single molecule from a file, dispatching on extension (or *fmt*)."""
|
|
26
|
+
mols = load_all(path, fmt=fmt)
|
|
27
|
+
if not mols:
|
|
28
|
+
raise ValueError(f"No molecules found in {path!r}")
|
|
29
|
+
return mols[0]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_all(path: str, fmt: str | None = None) -> List[Molecule]:
|
|
33
|
+
"""Load every model/frame from a file (e.g. multi-record SDF, NMR PDB)."""
|
|
34
|
+
if fmt is None:
|
|
35
|
+
ext = os.path.splitext(path)[1].lower()
|
|
36
|
+
else:
|
|
37
|
+
ext = fmt if fmt.startswith(".") else "." + fmt.lower()
|
|
38
|
+
parser = _EXT_PARSERS.get(ext)
|
|
39
|
+
if parser is None:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"Unsupported format {ext!r}. Supported: {', '.join(SUPPORTED_EXTENSIONS)}"
|
|
42
|
+
)
|
|
43
|
+
with open(path, "r", errors="replace") as fh:
|
|
44
|
+
text = fh.read()
|
|
45
|
+
mols = parser(text)
|
|
46
|
+
if isinstance(mols, Molecule):
|
|
47
|
+
mols = [mols]
|
|
48
|
+
for m in mols:
|
|
49
|
+
if not m.name:
|
|
50
|
+
m.name = os.path.splitext(os.path.basename(path))[0]
|
|
51
|
+
return mols
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_EXT_WRITERS = {
|
|
55
|
+
".xyz": _xyz.write,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
SAVE_EXTENSIONS = tuple(sorted(_EXT_WRITERS))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def save(mol: Molecule, path: str, fmt: str | None = None) -> None:
|
|
62
|
+
"""Write a molecule to *path*, dispatching on extension (or *fmt*).
|
|
63
|
+
|
|
64
|
+
Only formats in :data:`SAVE_EXTENSIONS` can be written (currently XYZ); a
|
|
65
|
+
path with any other extension raises :class:`ValueError`.
|
|
66
|
+
"""
|
|
67
|
+
if fmt is None:
|
|
68
|
+
ext = os.path.splitext(path)[1].lower()
|
|
69
|
+
else:
|
|
70
|
+
ext = fmt if fmt.startswith(".") else "." + fmt.lower()
|
|
71
|
+
writer = _EXT_WRITERS.get(ext)
|
|
72
|
+
if writer is None:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"Cannot write {ext!r}. Writable formats: {', '.join(SAVE_EXTENSIONS)}"
|
|
75
|
+
)
|
|
76
|
+
writer(mol, path)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def loads(text: str, fmt: str) -> Molecule:
|
|
80
|
+
"""Parse a molecule from an in-memory string given an explicit format."""
|
|
81
|
+
ext = fmt if fmt.startswith(".") else "." + fmt.lower()
|
|
82
|
+
parser = _EXT_PARSERS.get(ext)
|
|
83
|
+
if parser is None:
|
|
84
|
+
raise ValueError(f"Unsupported format {fmt!r}")
|
|
85
|
+
mols = parser(text)
|
|
86
|
+
if isinstance(mols, Molecule):
|
|
87
|
+
return mols
|
|
88
|
+
return mols[0]
|
vimol/parsers/mol.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""MDL MOL (V2000) and SDF parser (SDF = one or more MOL records)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from ..molecule import Molecule
|
|
9
|
+
from .. import elements
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _parse_one(block: str) -> Molecule | None:
|
|
13
|
+
lines = block.splitlines()
|
|
14
|
+
if len(lines) < 4:
|
|
15
|
+
return None
|
|
16
|
+
name = lines[0].strip()
|
|
17
|
+
counts = lines[3]
|
|
18
|
+
try:
|
|
19
|
+
n_atoms = int(counts[0:3])
|
|
20
|
+
n_bonds = int(counts[3:6])
|
|
21
|
+
except ValueError:
|
|
22
|
+
return None
|
|
23
|
+
symbols: List[str] = []
|
|
24
|
+
coords: List[list] = []
|
|
25
|
+
for k in range(n_atoms):
|
|
26
|
+
row = lines[4 + k]
|
|
27
|
+
x = float(row[0:10])
|
|
28
|
+
y = float(row[10:20])
|
|
29
|
+
z = float(row[20:30])
|
|
30
|
+
sym = row[31:34].strip()
|
|
31
|
+
symbols.append(elements.normalize_symbol(sym))
|
|
32
|
+
coords.append([x, y, z])
|
|
33
|
+
bonds: List[tuple] = []
|
|
34
|
+
for k in range(n_bonds):
|
|
35
|
+
row = lines[4 + n_atoms + k]
|
|
36
|
+
a = int(row[0:3]) - 1
|
|
37
|
+
b = int(row[3:6]) - 1
|
|
38
|
+
order = int(row[6:9]) if row[6:9].strip() else 1
|
|
39
|
+
lo, hi = (a, b) if a < b else (b, a)
|
|
40
|
+
bonds.append((lo, hi, order))
|
|
41
|
+
return Molecule(
|
|
42
|
+
symbols=symbols,
|
|
43
|
+
positions=np.array(coords, float) if coords else np.zeros((0, 3)),
|
|
44
|
+
bonds=bonds,
|
|
45
|
+
name=name[:60],
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse(text: str) -> List[Molecule]:
|
|
50
|
+
mols: List[Molecule] = []
|
|
51
|
+
for block in text.split("$$$$"):
|
|
52
|
+
if not block.strip():
|
|
53
|
+
continue
|
|
54
|
+
m = _parse_one(block)
|
|
55
|
+
if m is not None and m.n_atoms:
|
|
56
|
+
mols.append(m)
|
|
57
|
+
return mols
|
vimol/parsers/pdb.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Minimal but practical PDB parser.
|
|
2
|
+
|
|
3
|
+
Handles ATOM/HETATM records with fixed-column layout, CONECT bonds, TER, and
|
|
4
|
+
MODEL/ENDMDL multi-model files. Element is taken from columns 77-78 when
|
|
5
|
+
present, otherwise guessed from the atom name.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import List
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from ..molecule import Molecule
|
|
14
|
+
from .. import elements
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _guess_element(atom_name: str, col_element: str) -> str:
|
|
18
|
+
col_element = col_element.strip()
|
|
19
|
+
if col_element:
|
|
20
|
+
return elements.normalize_symbol(col_element)
|
|
21
|
+
name = atom_name.strip()
|
|
22
|
+
# PDB atom names: element is usually the leading alphabetic chunk; names
|
|
23
|
+
# starting in column 13 with a digit are hydrogens/remoteness-indexed.
|
|
24
|
+
if not name:
|
|
25
|
+
return "X"
|
|
26
|
+
if name[0].isdigit():
|
|
27
|
+
name = name[1:]
|
|
28
|
+
# Two-letter elements only for the known set to avoid e.g. 'CA' (calcium)
|
|
29
|
+
# vs 'CA' (alpha carbon) confusion -> default to single letter for protein.
|
|
30
|
+
letters = "".join(c for c in name if c.isalpha())
|
|
31
|
+
if not letters:
|
|
32
|
+
return "X"
|
|
33
|
+
return elements.normalize_symbol(letters[0])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse(text: str) -> List[Molecule]:
|
|
37
|
+
models: List[Molecule] = []
|
|
38
|
+
cur = Molecule()
|
|
39
|
+
serial_to_index: dict = {}
|
|
40
|
+
have_atoms = False
|
|
41
|
+
|
|
42
|
+
def flush():
|
|
43
|
+
nonlocal cur, serial_to_index, have_atoms
|
|
44
|
+
if have_atoms:
|
|
45
|
+
models.append(cur)
|
|
46
|
+
cur = Molecule()
|
|
47
|
+
serial_to_index = {}
|
|
48
|
+
have_atoms = False
|
|
49
|
+
|
|
50
|
+
for line in text.splitlines():
|
|
51
|
+
rec = line[:6].strip()
|
|
52
|
+
if rec in ("ATOM", "HETATM"):
|
|
53
|
+
try:
|
|
54
|
+
serial = int(line[6:11])
|
|
55
|
+
except ValueError:
|
|
56
|
+
serial = len(cur.symbols)
|
|
57
|
+
atom_name = line[12:16]
|
|
58
|
+
x = float(line[30:38])
|
|
59
|
+
y = float(line[38:46])
|
|
60
|
+
z = float(line[46:54])
|
|
61
|
+
col_el = line[76:78] if len(line) >= 78 else ""
|
|
62
|
+
el = _guess_element(atom_name, col_el)
|
|
63
|
+
idx = len(cur.symbols)
|
|
64
|
+
cur.symbols.append(el)
|
|
65
|
+
serial_to_index[serial] = idx
|
|
66
|
+
cur.positions = (
|
|
67
|
+
np.vstack([cur.positions, [x, y, z]]) if len(cur.positions) else np.array([[x, y, z]], float)
|
|
68
|
+
)
|
|
69
|
+
have_atoms = True
|
|
70
|
+
elif rec == "CONECT":
|
|
71
|
+
try:
|
|
72
|
+
a = serial_to_index.get(int(line[6:11]))
|
|
73
|
+
except ValueError:
|
|
74
|
+
continue
|
|
75
|
+
if a is None:
|
|
76
|
+
continue
|
|
77
|
+
for c in (11, 16, 21, 26):
|
|
78
|
+
frag = line[c:c + 5].strip()
|
|
79
|
+
if not frag:
|
|
80
|
+
continue
|
|
81
|
+
try:
|
|
82
|
+
b = serial_to_index.get(int(frag))
|
|
83
|
+
except ValueError:
|
|
84
|
+
continue
|
|
85
|
+
if b is not None and a != b:
|
|
86
|
+
lo, hi = (a, b) if a < b else (b, a)
|
|
87
|
+
if (lo, hi, 1) not in cur.bonds:
|
|
88
|
+
cur.bonds.append((lo, hi, 1))
|
|
89
|
+
elif rec == "ENDMDL":
|
|
90
|
+
flush()
|
|
91
|
+
elif rec == "END":
|
|
92
|
+
flush()
|
|
93
|
+
|
|
94
|
+
flush()
|
|
95
|
+
if not models:
|
|
96
|
+
models.append(cur)
|
|
97
|
+
return models
|
vimol/parsers/xyz.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""XYZ / extended-XYZ parser (supports multi-frame trajectories)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from ..molecule import Molecule
|
|
9
|
+
from .. import elements
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse(text: str) -> List[Molecule]:
|
|
13
|
+
lines = text.splitlines()
|
|
14
|
+
mols: List[Molecule] = []
|
|
15
|
+
i = 0
|
|
16
|
+
n = len(lines)
|
|
17
|
+
while i < n:
|
|
18
|
+
# Skip blank lines between frames.
|
|
19
|
+
while i < n and not lines[i].strip():
|
|
20
|
+
i += 1
|
|
21
|
+
if i >= n:
|
|
22
|
+
break
|
|
23
|
+
try:
|
|
24
|
+
count = int(lines[i].strip().split()[0])
|
|
25
|
+
except (ValueError, IndexError):
|
|
26
|
+
break
|
|
27
|
+
comment = lines[i + 1] if i + 1 < n else ""
|
|
28
|
+
symbols: List[str] = []
|
|
29
|
+
coords: List[list] = []
|
|
30
|
+
for k in range(count):
|
|
31
|
+
row = i + 2 + k
|
|
32
|
+
if row >= n:
|
|
33
|
+
break
|
|
34
|
+
parts = lines[row].split()
|
|
35
|
+
if len(parts) < 4:
|
|
36
|
+
continue
|
|
37
|
+
sym = parts[0]
|
|
38
|
+
# Extended XYZ may use atomic numbers instead of symbols.
|
|
39
|
+
if sym.isdigit():
|
|
40
|
+
sym = elements.SYMBOLS[int(sym)] if 0 < int(sym) < len(elements.SYMBOLS) else "X"
|
|
41
|
+
symbols.append(elements.normalize_symbol(sym))
|
|
42
|
+
coords.append([float(parts[1]), float(parts[2]), float(parts[3])])
|
|
43
|
+
mol = Molecule(
|
|
44
|
+
symbols=symbols,
|
|
45
|
+
positions=np.array(coords, float) if coords else np.zeros((0, 3)),
|
|
46
|
+
name=comment.strip()[:60],
|
|
47
|
+
)
|
|
48
|
+
mols.append(mol)
|
|
49
|
+
i += 2 + count
|
|
50
|
+
return mols
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def dumps(mol: Molecule) -> str:
|
|
54
|
+
"""Serialize a molecule to XYZ text (single frame).
|
|
55
|
+
|
|
56
|
+
The comment line carries the molecule name. Coordinates are written with
|
|
57
|
+
six decimals, which round-trips through :func:`parse` without drift.
|
|
58
|
+
"""
|
|
59
|
+
lines = [str(mol.n_atoms), mol.name or ""]
|
|
60
|
+
for sym, p in zip(mol.symbols, mol.positions):
|
|
61
|
+
lines.append(f"{sym:<2s} {p[0]:>14.6f} {p[1]:>14.6f} {p[2]:>14.6f}")
|
|
62
|
+
return "\n".join(lines) + "\n"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def write(mol: Molecule, path: str) -> None:
|
|
66
|
+
"""Write a molecule to *path* as XYZ."""
|
|
67
|
+
with open(path, "w") as fh:
|
|
68
|
+
fh.write(dumps(mol))
|
vimol/periodic_table.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Standard 18-column periodic-table layout, derived from vimol.elements.
|
|
2
|
+
|
|
3
|
+
Grid coordinates here drive the interactive element picker in the viewer:
|
|
4
|
+
cursor navigation, click hit-testing, and rendering all key off :data:`GRID`.
|
|
5
|
+
The layout follows the common convention of pulling the lanthanides and
|
|
6
|
+
actinides out of the main table into two extra rows below, leaving a
|
|
7
|
+
non-selectable placeholder cell in the gap each leaves behind.
|
|
8
|
+
|
|
9
|
+
Element placement is computed from atomic-number ranges against
|
|
10
|
+
:data:`vimol.elements.SYMBOLS` rather than typed out by hand, so it can't
|
|
11
|
+
drift out of sync with that table.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Dict, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
from . import elements
|
|
19
|
+
|
|
20
|
+
N_COLS = 18
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Cell:
|
|
25
|
+
row: int
|
|
26
|
+
col: int
|
|
27
|
+
symbol: Optional[str] # None for a non-selectable placeholder
|
|
28
|
+
z: Optional[int] = None
|
|
29
|
+
label: Optional[str] = None # display text; defaults to the symbol
|
|
30
|
+
note: Optional[str] = None # shown in the info line for a placeholder
|
|
31
|
+
jump_row: Optional[int] = None # row Enter/click jumps to, for a placeholder
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def text(self) -> str:
|
|
35
|
+
if self.label is not None:
|
|
36
|
+
return self.label
|
|
37
|
+
return self.symbol or ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _add_row(rows: List[List[Optional[Cell]]], row_idx: int, placements) -> None:
|
|
41
|
+
"""placements: (col, z) for an element, or (col, None, label, note, jump_row)
|
|
42
|
+
for a non-selectable placeholder."""
|
|
43
|
+
cells: List[Optional[Cell]] = [None] * N_COLS
|
|
44
|
+
for item in placements:
|
|
45
|
+
if len(item) == 2:
|
|
46
|
+
col, z = item
|
|
47
|
+
cells[col] = Cell(row_idx, col, elements.SYMBOLS[z], z)
|
|
48
|
+
else:
|
|
49
|
+
col, _, label, note, jump_row = item
|
|
50
|
+
cells[col] = Cell(row_idx, col, None, None, label, note, jump_row)
|
|
51
|
+
rows.append(cells)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _build() -> List[List[Optional[Cell]]]:
|
|
55
|
+
rows: List[List[Optional[Cell]]] = []
|
|
56
|
+
# Period 1: H(1) .. He(2)
|
|
57
|
+
_add_row(rows, 0, [(0, 1), (17, 2)])
|
|
58
|
+
# Period 2: Li(3) Be(4) ... B(5)..Ne(10)
|
|
59
|
+
_add_row(rows, 1, [(0, 3), (1, 4)] + [(12 + i, 5 + i) for i in range(6)])
|
|
60
|
+
# Period 3: Na(11) Mg(12) ... Al(13)..Ar(18)
|
|
61
|
+
_add_row(rows, 2, [(0, 11), (1, 12)] + [(12 + i, 13 + i) for i in range(6)])
|
|
62
|
+
# Period 4: K(19)..Kr(36), 18 consecutive
|
|
63
|
+
_add_row(rows, 3, [(c, 19 + c) for c in range(18)])
|
|
64
|
+
# Period 5: Rb(37)..Xe(54), 18 consecutive
|
|
65
|
+
_add_row(rows, 4, [(c, 37 + c) for c in range(18)])
|
|
66
|
+
# Period 6: Cs(55) Ba(56) [gap: La-Lu below] Hf(72)..Rn(86)
|
|
67
|
+
_add_row(rows, 5, [(0, 55), (1, 56), (2, None, "**", "Lanthanides — see the row below", 7)]
|
|
68
|
+
+ [(3 + c, 72 + c) for c in range(15)])
|
|
69
|
+
# Period 7: Fr(87) Ra(88) [gap: Ac-Lr below] Rf(104)..Og(118)
|
|
70
|
+
_add_row(rows, 6, [(0, 87), (1, 88), (2, None, "**", "Actinides — see the row below", 8)]
|
|
71
|
+
+ [(3 + c, 104 + c) for c in range(15)])
|
|
72
|
+
# Lanthanides: La(57)..Lu(71), indented under the gap above
|
|
73
|
+
_add_row(rows, 7, [(3 + c, 57 + c) for c in range(15)])
|
|
74
|
+
# Actinides: Ac(89)..Lr(103)
|
|
75
|
+
_add_row(rows, 8, [(3 + c, 89 + c) for c in range(15)])
|
|
76
|
+
return rows
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
GRID: List[List[Optional[Cell]]] = _build()
|
|
80
|
+
|
|
81
|
+
POSITIONS: Dict[str, Tuple[int, int]] = {
|
|
82
|
+
cell.symbol: (r, c)
|
|
83
|
+
for r, row in enumerate(GRID)
|
|
84
|
+
for c, cell in enumerate(row)
|
|
85
|
+
if cell is not None and cell.symbol is not None
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cell_at(row: int, col: int) -> Optional[Cell]:
|
|
90
|
+
if 0 <= row < len(GRID) and 0 <= col < N_COLS:
|
|
91
|
+
return GRID[row][col]
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def position_of(symbol: str) -> Tuple[int, int]:
|
|
96
|
+
"""Grid (row, col) for *symbol*, defaulting to Carbon if not placed."""
|
|
97
|
+
return POSITIONS.get(elements.normalize_symbol(symbol), POSITIONS["C"])
|
vimol/py.typed
ADDED
|
File without changes
|