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 +21 -0
- tetramol-0.0.1/PKG-INFO +81 -0
- tetramol-0.0.1/README.md +64 -0
- tetramol-0.0.1/pyproject.toml +95 -0
- tetramol-0.0.1/pyproject.toml.orig +89 -0
- tetramol-0.0.1/src/tetramol/__init__.py +22 -0
- tetramol-0.0.1/src/tetramol/build.py +313 -0
- tetramol-0.0.1/src/tetramol/draw.py +167 -0
- tetramol-0.0.1/src/tetramol/grid.py +59 -0
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.
|
tetramol-0.0.1/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://opensource.org/licenses/MIT)
|
|
21
|
+
[](https://pixi.sh)
|
|
22
|
+
[](https://github.com/astral-sh/ruff)
|
|
23
|
+
[](https://github.com/astral-sh/ty)
|
|
24
|
+
[](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.
|
tetramol-0.0.1/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# tetramol
|
|
2
|
+
|
|
3
|
+
[](https://opensource.org/licenses/MIT)
|
|
4
|
+
[](https://pixi.sh)
|
|
5
|
+
[](https://github.com/astral-sh/ruff)
|
|
6
|
+
[](https://github.com/astral-sh/ty)
|
|
7
|
+
[](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
|