tetramol 0.0.1__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.
tetramol-0.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Andreas V. Copan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.4
2
+ Name: tetramol
3
+ Version: 0.0.1
4
+ Author: Andreas V. Copan
5
+ Author-email: Andreas V. Copan <avcopan@uga.edu>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Dist: automol>=0.0.28
9
+ Requires-Dist: numpy>=2
10
+ Requires-Dist: py3dmol>=2.5
11
+ Requires-Dist: scipy>=1.13
12
+ Requires-Dist: xyzrender>=0.3.8
13
+ Requires-Python: >=3.13
14
+ Project-URL: Repository, https://github.com/avcopan/tetramol
15
+ Project-URL: Documentation, https://avcopan.github.io/tetramol/
16
+ Description-Content-Type: text/markdown
17
+
18
+ # tetramol
19
+
20
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
21
+ [![Pixi Badge](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/prefix-dev/pixi/main/assets/badge/v0.json)](https://pixi.sh)
22
+ [![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](https://github.com/astral-sh/ruff)
23
+ [![Typing: ty](https://img.shields.io/badge/typing-ty-EFC621.svg)](https://github.com/astral-sh/ty)
24
+ [![Copier](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/copier-org/copier/master/img/badge/badge-grayscale-border.json)](https://github.com/copier-org/copier)
25
+
26
+ Idealized molecular structures for illustrations, rendered as SVGs with
27
+ [xyzrender](https://github.com/aligfellow/xyzrender).
28
+
29
+ Atoms are placed on a tetrahedral "diamond" grid, with ideal bond lengths (1.1 Å to
30
+ hydrogen, 1.5 Å otherwise) and angles (arccos(-1/3) ≈ 109.47°), so neighboring
31
+ centers are always staggered. This is meant for clean, schematic pictures, not for
32
+ real geometries.
33
+
34
+ ## Installation
35
+
36
+ ```sh
37
+ pixi run init # first time only
38
+ pixi run local start # use the local ../automol checkout (see below)
39
+ ```
40
+
41
+ tetramol depends on [automol](https://github.com/avcopan/automol) (from the `avcopan`
42
+ conda channel) for geometry handling, and on xyzrender (from PyPI) for rendering.
43
+ To develop against a local automol checkout next to this repository, toggle it with
44
+ `pixi run local start` and `pixi run local stop`.
45
+
46
+ ## Usage
47
+
48
+ ```python
49
+ from automol import geom
50
+ from tetramol import Builder, orient, render
51
+
52
+ b = Builder(up=(0, 1, 0), left=(-1, 0, 0)) # y is up the page, z toward you
53
+ c = b.add("C") # the first atom goes at the origin
54
+ n = b.add("N", c, toward=(0, 1, 0)) # bond through the slot closest to a direction
55
+ b.add("C", c, toward=(-1, 0, 0)) # ... or through a slot by index: b.add("C", c, 2)
56
+ b.add_hydrogens(avoid=(0, 0, -1)) # fill valences; N's lone pair points back
57
+ geo = b.geometry() # an automol Geometry
58
+
59
+ rot = orient(geo) # optional: rotate by hand in the browser,
60
+ geo = geom.rotate(geo, rot) # ... and apply the rotation
61
+ render(geo, "drawing.svg")
62
+ ```
63
+
64
+ Each atom has four slots, one per tetrahedral direction, and bonded atoms bond back
65
+ through the same slot, which fills in the diamond lattice. Use `automol.geom` for
66
+ anything off the grid (e.g. `geom.rotate(geo, rot, origin=..., keys=[...])`).
67
+
68
+ To draw an umbrella inversion, `b.umbrella_image(n)` adds the mirror image of the
69
+ branch on atom `n` (through the plane of its parent bond, perpendicular to its lone
70
+ pair), keeping both, so that one side can be faded afterwards.
71
+
72
+ Drawings live in [`drawings/`](https://github.com/avcopan/tetramol/tree/main/drawings), one folder per molecule.
73
+
74
+ ## Contributing
75
+
76
+ Pull requests are welcome. For major changes, please open an issue first
77
+ to discuss what you would like to change.
78
+
79
+ ## Credits
80
+
81
+ This package was created with [copier](https://copier.readthedocs.io/) and the [avcopan/copier-pixi-python](https://github.com/avcopan/copier-pixi-python) project template.
@@ -0,0 +1,64 @@
1
+ # tetramol
2
+
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
4
+ [![Pixi Badge](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/prefix-dev/pixi/main/assets/badge/v0.json)](https://pixi.sh)
5
+ [![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](https://github.com/astral-sh/ruff)
6
+ [![Typing: ty](https://img.shields.io/badge/typing-ty-EFC621.svg)](https://github.com/astral-sh/ty)
7
+ [![Copier](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/copier-org/copier/master/img/badge/badge-grayscale-border.json)](https://github.com/copier-org/copier)
8
+
9
+ Idealized molecular structures for illustrations, rendered as SVGs with
10
+ [xyzrender](https://github.com/aligfellow/xyzrender).
11
+
12
+ Atoms are placed on a tetrahedral "diamond" grid, with ideal bond lengths (1.1 Å to
13
+ hydrogen, 1.5 Å otherwise) and angles (arccos(-1/3) ≈ 109.47°), so neighboring
14
+ centers are always staggered. This is meant for clean, schematic pictures, not for
15
+ real geometries.
16
+
17
+ ## Installation
18
+
19
+ ```sh
20
+ pixi run init # first time only
21
+ pixi run local start # use the local ../automol checkout (see below)
22
+ ```
23
+
24
+ tetramol depends on [automol](https://github.com/avcopan/automol) (from the `avcopan`
25
+ conda channel) for geometry handling, and on xyzrender (from PyPI) for rendering.
26
+ To develop against a local automol checkout next to this repository, toggle it with
27
+ `pixi run local start` and `pixi run local stop`.
28
+
29
+ ## Usage
30
+
31
+ ```python
32
+ from automol import geom
33
+ from tetramol import Builder, orient, render
34
+
35
+ b = Builder(up=(0, 1, 0), left=(-1, 0, 0)) # y is up the page, z toward you
36
+ c = b.add("C") # the first atom goes at the origin
37
+ n = b.add("N", c, toward=(0, 1, 0)) # bond through the slot closest to a direction
38
+ b.add("C", c, toward=(-1, 0, 0)) # ... or through a slot by index: b.add("C", c, 2)
39
+ b.add_hydrogens(avoid=(0, 0, -1)) # fill valences; N's lone pair points back
40
+ geo = b.geometry() # an automol Geometry
41
+
42
+ rot = orient(geo) # optional: rotate by hand in the browser,
43
+ geo = geom.rotate(geo, rot) # ... and apply the rotation
44
+ render(geo, "drawing.svg")
45
+ ```
46
+
47
+ Each atom has four slots, one per tetrahedral direction, and bonded atoms bond back
48
+ through the same slot, which fills in the diamond lattice. Use `automol.geom` for
49
+ anything off the grid (e.g. `geom.rotate(geo, rot, origin=..., keys=[...])`).
50
+
51
+ To draw an umbrella inversion, `b.umbrella_image(n)` adds the mirror image of the
52
+ branch on atom `n` (through the plane of its parent bond, perpendicular to its lone
53
+ pair), keeping both, so that one side can be faded afterwards.
54
+
55
+ Drawings live in [`drawings/`](https://github.com/avcopan/tetramol/tree/main/drawings), one folder per molecule.
56
+
57
+ ## Contributing
58
+
59
+ Pull requests are welcome. For major changes, please open an issue first
60
+ to discuss what you would like to change.
61
+
62
+ ## Credits
63
+
64
+ This package was created with [copier](https://copier.readthedocs.io/) and the [avcopan/copier-pixi-python](https://github.com/avcopan/copier-pixi-python) project template.
@@ -0,0 +1,95 @@
1
+ [project]
2
+ name = "tetramol"
3
+ version = "0.0.1"
4
+ readme = "README.md"
5
+ license = "MIT"
6
+ license-files = ["LICENSE"]
7
+ dependencies = [
8
+ "automol>=0.0.28",
9
+ "numpy>=2",
10
+ "py3dmol>=2.5",
11
+ "scipy>=1.13",
12
+ "xyzrender>=0.3.8",
13
+ ]
14
+ requires-python = ">= 3.13"
15
+
16
+ [[project.authors]]
17
+ name = "Andreas V. Copan"
18
+ email = "avcopan@uga.edu"
19
+
20
+ [project.urls]
21
+ Repository = "https://github.com/avcopan/tetramol"
22
+ Documentation = "https://avcopan.github.io/tetramol/"
23
+
24
+ [build-system]
25
+ build-backend = "uv_build"
26
+ requires = ["uv_build"]
27
+
28
+ [tool.ruff]
29
+ extend-exclude = [".claude"]
30
+
31
+ [tool.ruff.lint]
32
+ select = ["ALL"]
33
+ ignore = [
34
+ "COM812",
35
+ "TID252",
36
+ "D203",
37
+ "D213",
38
+ "CPY001",
39
+ ]
40
+
41
+ [tool.ruff.lint.per-file-ignores]
42
+ "tests/**.py" = [
43
+ "S101",
44
+ "PLR2004",
45
+ ]
46
+ "drawings/**.py" = [
47
+ "INP001",
48
+ "F401",
49
+ ]
50
+
51
+ [tool.ruff.lint.pydocstyle]
52
+ convention = "google"
53
+
54
+ [tool.ty.environment]
55
+ python = ".pixi/envs/test/bin/python"
56
+
57
+ [tool.ty.src]
58
+ include = [
59
+ "src",
60
+ "tests",
61
+ ]
62
+
63
+ [tool.importlinter]
64
+ root_package = "tetramol"
65
+
66
+ [[tool.importlinter.contracts]]
67
+ name = "Module Layering"
68
+ type = "layers"
69
+ layers = [
70
+ "tetramol.draw",
71
+ "tetramol.build",
72
+ "tetramol.grid",
73
+ ]
74
+
75
+ [tool.pytest.ini_options]
76
+ testpaths = [
77
+ "tests",
78
+ "src",
79
+ ]
80
+ addopts = [
81
+ "--doctest-modules",
82
+ "--cov=tetramol",
83
+ "--cov-report=term-missing",
84
+ "--cov-report=html",
85
+ ]
86
+ doctest_optionflags = "NORMALIZE_WHITESPACE"
87
+
88
+ [tool.coverage.run]
89
+ branch = true
90
+ source = ["tetramol"]
91
+
92
+ [tool.coverage.report]
93
+ show_missing = true
94
+ skip_covered = true
95
+ fail_under = 80
@@ -0,0 +1,89 @@
1
+ [project]
2
+ name = "tetramol"
3
+ version = "0.0.1"
4
+ readme = "README.md" # the PyPI page
5
+ license = "MIT"
6
+ license-files = ["LICENSE"]
7
+ authors = [{name = "Andreas V. Copan", email = "avcopan@uga.edu"}]
8
+ dependencies = [
9
+ "automol>=0.0.28",
10
+ "numpy>=2",
11
+ "py3dmol>=2.5",
12
+ "scipy>=1.13",
13
+ "xyzrender>=0.3.8",
14
+ ]
15
+ requires-python = ">= 3.13"
16
+
17
+ [project.urls]
18
+ Repository = "https://github.com/avcopan/tetramol"
19
+ Documentation = "https://avcopan.github.io/tetramol/"
20
+
21
+ [build-system]
22
+ build-backend = "uv_build"
23
+ requires = ["uv_build"]
24
+
25
+ # Ruff configurations
26
+ [tool.ruff]
27
+ extend-exclude = [".claude"] # vendored agent skills
28
+
29
+ [tool.ruff.lint]
30
+ select = ["ALL"]
31
+ ignore = [
32
+ "COM812", # missing trailing comma (handled by format)
33
+ "TID252", # relative import
34
+ "D203", # conflict: incorrect blank line before class (uses D211 instead)
35
+ "D213", # conflict: multi-line summary second line (uses D212 instead)
36
+ "CPY001", # copyright notice
37
+ ]
38
+
39
+ [tool.ruff.lint.per-file-ignores]
40
+ "tests/**.py" = ["S101", "PLR2004"] # assert, magic values
41
+ # Drawing scripts: not a package, and `orient` is imported for interactive use
42
+ "drawings/**.py" = ["INP001", "F401"]
43
+
44
+ [tool.ruff.lint.pydocstyle]
45
+ convention = "google"
46
+
47
+ # Ty configurations
48
+ [tool.ty.environment]
49
+ python = ".pixi/envs/test/bin/python"
50
+
51
+ [tool.ty.src]
52
+ include = [
53
+ "src",
54
+ "tests",
55
+ ]
56
+
57
+ # Import-linter configurations
58
+ [tool.importlinter]
59
+ root_package = "tetramol"
60
+
61
+ [[tool.importlinter.contracts]]
62
+ name = "Module Layering"
63
+ type = "layers"
64
+ layers = [
65
+ "tetramol.draw",
66
+ "tetramol.build",
67
+ "tetramol.grid",
68
+ ]
69
+
70
+ # Pytest configurations
71
+ [tool.pytest.ini_options]
72
+ testpaths = ["tests", "src"]
73
+ addopts = [
74
+ "--doctest-modules",
75
+ "--cov=tetramol",
76
+ "--cov-report=term-missing",
77
+ "--cov-report=html",
78
+ ]
79
+ doctest_optionflags = "NORMALIZE_WHITESPACE"
80
+
81
+ # Pytest-cov configurations
82
+ [tool.coverage.run]
83
+ branch = true
84
+ source = ["tetramol"]
85
+
86
+ [tool.coverage.report]
87
+ show_missing = true
88
+ skip_covered = true
89
+ fail_under = 80
@@ -0,0 +1,22 @@
1
+ """tetramol: idealized molecules on a tetrahedral grid, for illustrations."""
2
+
3
+ __version__ = "0.0.1"
4
+
5
+ from . import draw, grid
6
+ from .build import VALENCES, Builder
7
+ from .draw import STYLE, orient, render
8
+ from .grid import TETRAHEDRAL_ANGLE, XH_LENGTH, XY_LENGTH, tetrahedral_basis
9
+
10
+ __all__ = [
11
+ "STYLE",
12
+ "TETRAHEDRAL_ANGLE",
13
+ "VALENCES",
14
+ "XH_LENGTH",
15
+ "XY_LENGTH",
16
+ "Builder",
17
+ "draw",
18
+ "grid",
19
+ "orient",
20
+ "render",
21
+ "tetrahedral_basis",
22
+ ]
@@ -0,0 +1,313 @@
1
+ """Build idealized molecules on a tetrahedral grid."""
2
+
3
+ from collections.abc import Collection, Sequence
4
+
5
+ import numpy as np
6
+ from automol import Geometry, geom
7
+ from automol.utils import element
8
+ from numpy.typing import ArrayLike
9
+
10
+ from .grid import XH_LENGTH, XY_LENGTH, tetrahedral_basis
11
+
12
+ VALENCES = {
13
+ "H": 1,
14
+ "B": 3,
15
+ "C": 4,
16
+ "N": 3,
17
+ "O": 2,
18
+ "F": 1,
19
+ "Si": 4,
20
+ "P": 3,
21
+ "S": 2,
22
+ "Cl": 1,
23
+ "Br": 1,
24
+ "I": 1,
25
+ }
26
+ """Default number of bonds to each element, used by `Builder.add_hydrogens`."""
27
+
28
+ _CLASH_DISTANCE = 0.5
29
+
30
+
31
+ class Builder:
32
+ """Build a molecule atom by atom on a tetrahedral grid.
33
+
34
+ Every atom has four bond slots, one per direction of the
35
+ `tetrahedral_basis`. Bonded atoms have opposite parities: an atom of parity
36
+ +1 bonds along the basis directions, and one of parity -1 along their
37
+ negatives. A child added through slot ``i`` therefore bonds back to its
38
+ parent through its own slot ``i``. Filling in carbons gives the diamond
39
+ lattice, and neighboring centers are always staggered.
40
+
41
+ Example:
42
+ A C-N bond pointing up, with a methyl group to the left of the carbon:
43
+
44
+ >>> b = Builder(up=(0, 0, 1), left=(-1, 0, 0))
45
+ >>> c = b.add("C")
46
+ >>> n = b.add("N", c, toward=(0, 0, 1))
47
+ >>> c2 = b.add("C", c, toward=(-1, 0, 0))
48
+ >>> b.add_hydrogens()
49
+ [3, 4, 5, 6, 7, 8, 9]
50
+ >>> b.geometry().symbols
51
+ ['C', 'N', 'C', 'H', 'H', 'H', 'H', 'H', 'H', 'H']
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ up: ArrayLike = (0.0, 0.0, 1.0),
57
+ left: ArrayLike = (-1.0, 0.0, 0.0),
58
+ *,
59
+ xh_length: float = XH_LENGTH,
60
+ xy_length: float = XY_LENGTH,
61
+ ) -> None:
62
+ """Start an empty molecule.
63
+
64
+ Args:
65
+ up: Direction of slot 0 for the first atom (see `tetrahedral_basis`).
66
+ left: Direction in the plane of slots 0 and 1 for the first atom.
67
+ xh_length: Bond length to hydrogen, in Angstroms.
68
+ xy_length: Bond length between heavy atoms, in Angstroms.
69
+ """
70
+ self.basis = tetrahedral_basis(up, left)
71
+ self.xh_length = xh_length
72
+ self.xy_length = xy_length
73
+ self.symbols: list[str] = []
74
+ self.positions: list[np.ndarray] = []
75
+ self.parities: list[int] = []
76
+ self.bonds: list[dict[int, int]] = [] # slot -> neighbor, per atom
77
+ self.parents: list[int | None] = []
78
+
79
+ @property
80
+ def atom_count(self) -> int:
81
+ """Number of atoms."""
82
+ return len(self.symbols)
83
+
84
+ def slot_direction(self, key: int, slot: int) -> np.ndarray:
85
+ """Get the unit direction of a slot of an atom.
86
+
87
+ Args:
88
+ key: Atom index.
89
+ slot: Slot index, from 0 to 3.
90
+
91
+ Returns:
92
+ The direction.
93
+ """
94
+ return self.parities[key] * self.basis[slot]
95
+
96
+ def open_slots(self, key: int) -> list[int]:
97
+ """List the empty slots of an atom.
98
+
99
+ Args:
100
+ key: Atom index.
101
+
102
+ Returns:
103
+ The empty slots, in order.
104
+ """
105
+ return [s for s in range(4) if s not in self.bonds[key]]
106
+
107
+ def add(
108
+ self,
109
+ symbol: str,
110
+ parent: int | None = None,
111
+ slot: int | None = None,
112
+ *,
113
+ toward: ArrayLike | None = None,
114
+ length: float | None = None,
115
+ ) -> int:
116
+ """Add an atom.
117
+
118
+ Args:
119
+ symbol: Atomic symbol.
120
+ parent: Atom to bond to. Must be `None` for the first atom, which
121
+ goes at the origin.
122
+ slot: Slot of `parent` to bond through. If neither this nor
123
+ `toward` is given, use the first empty slot.
124
+ toward: Direction to bond in. The empty slot of `parent` closest
125
+ to this direction is used.
126
+ length: Bond length. Defaults to `xh_length` for bonds to hydrogen
127
+ and `xy_length` otherwise.
128
+
129
+ Returns:
130
+ Index of the new atom.
131
+
132
+ Raises:
133
+ ValueError: If the parent, slot, or position is invalid.
134
+ """
135
+ symbol = element.symbol(symbol)
136
+ if parent is None:
137
+ if self.atom_count:
138
+ msg = "Only the first atom can be added without a parent."
139
+ raise ValueError(msg)
140
+ return self._append(symbol, np.zeros(3), parity=1)
141
+
142
+ slot = self._choose_slot(parent, slot, toward)
143
+ if length is None:
144
+ has_h = "H" in (symbol, self.symbols[parent])
145
+ length = self.xh_length if has_h else self.xy_length
146
+ position = self.positions[parent] + length * self.slot_direction(parent, slot)
147
+ self._check_clash(position)
148
+
149
+ key = self._append(symbol, position, parity=-self.parities[parent])
150
+ self.parents[key] = parent
151
+ self.bonds[parent][slot] = key
152
+ self.bonds[key][slot] = parent
153
+ return key
154
+
155
+ def add_hydrogens(
156
+ self,
157
+ keys: Collection[int] | None = None,
158
+ *,
159
+ avoid: ArrayLike | None = None,
160
+ ) -> list[int]:
161
+ """Fill atoms up to their default valence (see `VALENCES`) with hydrogens.
162
+
163
+ Args:
164
+ keys: Atoms to fill. Defaults to all heavy atoms.
165
+ avoid: When an atom needs fewer hydrogens than it has empty slots
166
+ (e.g. an amine nitrogen), leave the slots closest to this
167
+ direction empty. By default, the last empty slots are left.
168
+
169
+ Returns:
170
+ Indices of the new hydrogens.
171
+ """
172
+ if keys is None:
173
+ keys = [k for k, s in enumerate(self.symbols) if s != "H"]
174
+
175
+ added = []
176
+ for key in keys:
177
+ count = VALENCES[self.symbols[key]] - len(self.bonds[key])
178
+ slots = self.open_slots(key)
179
+ if avoid is not None:
180
+ slots.sort(key=lambda s: np.dot(self.slot_direction(key, s), avoid))
181
+ added.extend(self.add("H", key, s) for s in slots[: max(count, 0)])
182
+ return added
183
+
184
+ def geometry(self, *, charge: int = 0, spin: int | None = None) -> Geometry:
185
+ """Get the geometry.
186
+
187
+ Args:
188
+ charge: Charge.
189
+ spin: Number of unpaired electrons. Defaults to the lowest spin
190
+ consistent with the electron count (0 or 1).
191
+
192
+ Returns:
193
+ The geometry.
194
+ """
195
+ if spin is None:
196
+ spin = (sum(map(element.number, self.symbols)) - charge) % 2
197
+ return Geometry(
198
+ symbols=self.symbols,
199
+ coordinates=np.reshape(self.positions, (-1, 3)),
200
+ charge=charge,
201
+ spin=spin,
202
+ )
203
+
204
+ def umbrella_image(
205
+ self,
206
+ key: int,
207
+ keys: Sequence[int] | None = None,
208
+ *,
209
+ geo: Geometry | None = None,
210
+ normal: ArrayLike | None = None,
211
+ ) -> tuple[Geometry, list[int]]:
212
+ """Add the mirror image of a branch, e.g. to draw an umbrella inversion.
213
+
214
+ The branch is reflected through a plane that contains the bond from
215
+ `key` to its parent, and that is perpendicular to the empty slot of
216
+ `key` (its lone pair). This swaps the sides of the substituents and
217
+ the lone pair, as in the inversion of an amine. Both the branch and its
218
+ image are kept, so one can be faded afterwards.
219
+
220
+ Args:
221
+ key: Center of the branch, e.g. an amine nitrogen.
222
+ keys: Atoms to reflect. Defaults to everything bonded beyond `key`,
223
+ away from its parent.
224
+ geo: Geometry to add the image to, e.g. one that already has other
225
+ images. Defaults to `geometry()`. Its first atoms must be the
226
+ builder's.
227
+ normal: Normal of the mirror plane through `key`, overriding the
228
+ default.
229
+
230
+ Returns:
231
+ The geometry with the image atoms appended, and their indices.
232
+
233
+ Raises:
234
+ ValueError: If `key` has no parent, or, without `normal`, doesn't
235
+ have exactly one empty slot.
236
+ """
237
+ parent = self.parents[key]
238
+ if parent is None:
239
+ msg = f"Atom {key} has no parent, so there is no bond to mirror about."
240
+ raise ValueError(msg)
241
+ if normal is None:
242
+ empty = self.open_slots(key)
243
+ if len(empty) != 1:
244
+ msg = (
245
+ f"Atom {key} has empty slots {empty}; the mirror plane needs "
246
+ "exactly one (or pass `normal`)."
247
+ )
248
+ raise ValueError(msg)
249
+ axis = self.positions[parent] - self.positions[key]
250
+ axis /= np.linalg.norm(axis)
251
+ lone_pair = self.slot_direction(key, empty[0])
252
+ normal = lone_pair - np.dot(lone_pair, axis) * axis
253
+
254
+ keys = self._branch(key) if keys is None else list(keys)
255
+ geo = self.geometry() if geo is None else geo
256
+ image = geom.reflect(
257
+ geom.select_atoms(geo, keys), normal, origin=self.positions[key]
258
+ )
259
+ image_keys = list(range(geo.atom_count, geo.atom_count + len(keys)))
260
+ return geom.join([geo, image]), image_keys
261
+
262
+ def _branch(self, key: int) -> list[int]:
263
+ """List the atoms bonded beyond `key`, away from its parent."""
264
+ seen = {key, self.parents[key]}
265
+ branch, stack = [], [key]
266
+ while stack:
267
+ for nbr in self.bonds[stack.pop()].values():
268
+ if nbr not in seen:
269
+ seen.add(nbr)
270
+ branch.append(nbr)
271
+ stack.append(nbr)
272
+ return sorted(branch)
273
+
274
+ def _append(self, symbol: str, position: np.ndarray, parity: int) -> int:
275
+ """Append an atom and return its index."""
276
+ self.symbols.append(symbol)
277
+ self.positions.append(position)
278
+ self.parities.append(parity)
279
+ self.bonds.append({})
280
+ self.parents.append(None)
281
+ return self.atom_count - 1
282
+
283
+ def _choose_slot(
284
+ self, parent: int, slot: int | None, toward: ArrayLike | None
285
+ ) -> int:
286
+ """Choose an empty slot of `parent` by index or direction."""
287
+ if not 0 <= parent < self.atom_count:
288
+ msg = f"No atom {parent} in a molecule of {self.atom_count} atoms."
289
+ raise ValueError(msg)
290
+ if slot is not None and toward is not None:
291
+ msg = "Give either a slot or a direction (`toward`), not both."
292
+ raise ValueError(msg)
293
+
294
+ open_slots = self.open_slots(parent)
295
+ if not open_slots:
296
+ msg = f"Atom {parent} has no empty slots."
297
+ raise ValueError(msg)
298
+ if toward is not None:
299
+ dirs = [self.slot_direction(parent, s) for s in open_slots]
300
+ return open_slots[int(np.argmax(np.dot(dirs, toward)))]
301
+ if slot is None:
302
+ return open_slots[0]
303
+ if slot not in open_slots:
304
+ msg = f"Slot {slot} of atom {parent} is not empty (empty: {open_slots})."
305
+ raise ValueError(msg)
306
+ return slot
307
+
308
+ def _check_clash(self, position: np.ndarray) -> None:
309
+ """Raise `ValueError` if `position` is on top of an existing atom."""
310
+ for key, other in enumerate(self.positions):
311
+ if np.linalg.norm(position - other) < _CLASH_DISTANCE:
312
+ msg = f"Position {position} is occupied by atom {key}."
313
+ raise ValueError(msg)
@@ -0,0 +1,167 @@
1
+ """Orient and render geometries as illustrations."""
2
+
3
+ import json
4
+ import threading
5
+ import webbrowser
6
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
7
+ from pathlib import Path
8
+
9
+ import py3Dmol
10
+ import xyzrender
11
+ from automol import Geometry, geom
12
+ from scipy.spatial.transform import Rotation
13
+
14
+ STYLE = xyzrender.build_config(
15
+ "flat",
16
+ orient=False,
17
+ atom_scale=3,
18
+ bond_width=25,
19
+ bond_color="#000000",
20
+ atom_stroke_width=3,
21
+ hy=True,
22
+ bo=False,
23
+ fog=False,
24
+ )
25
+ """House style: flat atoms with black bonds, drawn as oriented (no auto-rotation)."""
26
+
27
+
28
+ def render(
29
+ geo: Geometry,
30
+ out: str | Path | None = None,
31
+ *,
32
+ config: xyzrender.RenderConfig = STYLE,
33
+ ) -> xyzrender.SVGResult:
34
+ """Render a geometry as an SVG, looking down the z axis (x right, y up).
35
+
36
+ Args:
37
+ geo: Geometry.
38
+ out: Output path. The suffix is replaced with ``.svg``.
39
+ config: xyzrender configuration.
40
+
41
+ Returns:
42
+ The SVG, which displays inline in Jupyter-like REPLs.
43
+ """
44
+ return geom.render_svg(geo, out=out, config=config)
45
+
46
+
47
+ def orient(geo: Geometry, *, port: int = 0) -> Rotation:
48
+ """Rotate a geometry by hand in the browser and get the rotation.
49
+
50
+ This opens the geometry in py3Dmol in a web browser. Rotate the molecule
51
+ with the mouse, then click "Done". The view matches `render`: both look
52
+ down the z axis with x to the right and y up.
53
+
54
+ The rotation is also printed as code, to paste into a drawing script so that
55
+ it reproduces the orientation without the viewer.
56
+
57
+ Args:
58
+ geo: Geometry.
59
+ port: Port for the local server behind the page. The default (0) picks a
60
+ free port.
61
+
62
+ Returns:
63
+ The rotation, to apply with ``automol.geom.rotate(geo, rot)``.
64
+ """
65
+ server, result = serve_orient_page(geo, port=port)
66
+ url = f"http://localhost:{server.server_address[1]}"
67
+ print(f"Orient the molecule at {url}, then click Done.") # noqa: T201
68
+ webbrowser.open(url)
69
+ try:
70
+ while not result:
71
+ threading.Event().wait(0.2)
72
+ finally:
73
+ server.shutdown()
74
+ server.server_close()
75
+
76
+ rot = Rotation.from_quat(result[0])
77
+ quat = ", ".join(f"{x:.6f}" for x in rot.as_quat())
78
+ print(f"rot = Rotation.from_quat([{quat}])") # noqa: T201
79
+ return rot
80
+
81
+
82
+ # Browser viewer: py3Dmol can't send data back to Python, so serve the page from
83
+ # a local server, and have it post the view quaternion from 3Dmol.js
84
+ # (`viewer.getView()` -> [x, y, z, zoom, qx, qy, qz, qw]) back when done
85
+ _PAGE = """<!DOCTYPE html>
86
+ <html>
87
+ <head><title>tetramol: orient</title></head>
88
+ <body style="margin: 0; font-family: sans-serif;">
89
+ <div style="padding: 8px; display: flex; gap: 16px; align-items: center;">
90
+ <button id="done" style="font-size: 1.2em;">Done</button>
91
+ <span>Rotate with the mouse, then click Done. Quaternion:
92
+ <code id="quat">0, 0, 0, 1</code></span>
93
+ </div>
94
+ {viewer_html}
95
+ <script>
96
+ $3Dmolpromise.then(function() {{
97
+ const viewer = viewer_{uniqueid};
98
+ const quat = () => viewer.getView().slice(4);
99
+ const show = () => {{
100
+ document.getElementById("quat").textContent =
101
+ quat().map((x) => x.toFixed(4)).join(", ");
102
+ }};
103
+ viewer.setViewChangeCallback(show);
104
+ document.getElementById("done").onclick = () => {{
105
+ fetch("/done", {{method: "POST", body: JSON.stringify(quat())}}).then(() => {{
106
+ document.body.innerHTML = "<p style='padding: 8px'>Done. Close this tab.</p>";
107
+ }});
108
+ }};
109
+ }});
110
+ </script>
111
+ </body>
112
+ </html>
113
+ """
114
+
115
+
116
+ def orient_page(geo: Geometry) -> str:
117
+ """Make the web page for orienting a geometry in the browser.
118
+
119
+ Args:
120
+ geo: Geometry.
121
+
122
+ Returns:
123
+ HTML for a page with a py3Dmol viewer and a "Done" button, which posts
124
+ the view quaternion ``[qx, qy, qz, qw]`` as JSON to ``/done``.
125
+ """
126
+ view = py3Dmol.view(width="100%", height="90vh")
127
+ geom.view(geo, view=view)
128
+ viewer_html = view.write_html()
129
+ return _PAGE.format(viewer_html=viewer_html, uniqueid=view.uniqueid)
130
+
131
+
132
+ def serve_orient_page(
133
+ geo: Geometry, *, port: int = 0
134
+ ) -> tuple[ThreadingHTTPServer, list]:
135
+ """Serve the orientation page from a local server, in a background thread.
136
+
137
+ Args:
138
+ geo: Geometry.
139
+ port: Port to serve on. The default (0) picks a free port.
140
+
141
+ Returns:
142
+ The server, and a list that receives the view quaternion when the page
143
+ posts it. The server shuts itself down after that.
144
+ """
145
+ page = orient_page(geo).encode()
146
+ result: list = []
147
+
148
+ class Handler(BaseHTTPRequestHandler):
149
+ def do_GET(self) -> None:
150
+ self.send_response(200)
151
+ self.send_header("Content-Type", "text/html; charset=utf-8")
152
+ self.end_headers()
153
+ self.wfile.write(page)
154
+
155
+ def do_POST(self) -> None:
156
+ length = int(self.headers.get("Content-Length", 0))
157
+ result.append(json.loads(self.rfile.read(length)))
158
+ self.send_response(200)
159
+ self.end_headers()
160
+ threading.Thread(target=self.server.shutdown).start()
161
+
162
+ def log_message(self, format: str, *args: object) -> None: # noqa: A002
163
+ pass
164
+
165
+ server = ThreadingHTTPServer(("localhost", port), Handler)
166
+ threading.Thread(target=server.serve_forever, daemon=True).start()
167
+ return server, result
@@ -0,0 +1,59 @@
1
+ """The tetrahedral grid: ideal bond directions, angles, and lengths."""
2
+
3
+ import numpy as np
4
+ from numpy.typing import ArrayLike
5
+ from scipy.spatial.transform import Rotation
6
+
7
+ TETRAHEDRAL_ANGLE = float(np.arccos(-1 / 3))
8
+ """The ideal tetrahedral angle, arccos(-1/3) (about 109.47 degrees), in radians."""
9
+
10
+ XH_LENGTH = 1.1
11
+ """Ideal bond length to hydrogen, in Angstroms."""
12
+
13
+ XY_LENGTH = 1.5
14
+ """Ideal bond length between heavy atoms, in Angstroms."""
15
+
16
+
17
+ def tetrahedral_basis(
18
+ up: ArrayLike = (0.0, 0.0, 1.0), left: ArrayLike = (-1.0, 0.0, 0.0)
19
+ ) -> np.ndarray:
20
+ """Get the four bond directions of a tetrahedral grid.
21
+
22
+ Direction 0 points along `up`. Direction 1 lies in the plane of `up` and
23
+ `left`, on the side of `left`. Directions 2 and 3 follow by rotating
24
+ direction 1 about `up` by 120 and 240 degrees (counterclockwise, looking
25
+ down from the tip of `up`).
26
+
27
+ Args:
28
+ up: Direction 0.
29
+ left: Direction in the plane of directions 0 and 1. Only its component
30
+ perpendicular to `up` matters.
31
+
32
+ Returns:
33
+ Unit vectors, one per row, shape ``(4, 3)``. Each pair makes the
34
+ tetrahedral angle.
35
+
36
+ Raises:
37
+ ValueError: If `up` is zero or `left` is parallel to `up`.
38
+
39
+ Example:
40
+ >>> basis = tetrahedral_basis()
41
+ >>> basis.round(6).tolist()[:2]
42
+ [[0.0, 0.0, 1.0], [-0.942809, 0.0, -0.333333]]
43
+ """
44
+ up = _unit(up, "up")
45
+ left = np.asarray(left, dtype=float)
46
+ side = _unit(left - np.dot(left, up) * up, "left perpendicular to up")
47
+ v1 = -up / 3 + 2 * np.sqrt(2) / 3 * side
48
+ rot = Rotation.from_rotvec(2 * np.pi / 3 * up)
49
+ return np.array([up, v1, rot.apply(v1), rot.inv().apply(v1)])
50
+
51
+
52
+ def _unit(vec: ArrayLike, name: str) -> np.ndarray:
53
+ """Normalize a vector, raising `ValueError` if it is zero."""
54
+ vec = np.asarray(vec, dtype=float)
55
+ norm = np.linalg.norm(vec)
56
+ if np.isclose(norm, 0.0):
57
+ msg = f"Vector {name} must be non-zero, got {vec}."
58
+ raise ValueError(msg)
59
+ return vec / norm