chemparseplot 1.9.17__tar.gz → 1.10.0__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.
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/PKG-INFO +1 -1
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/_version.py +2 -2
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/plot/__init__.py +8 -1
- chemparseplot-1.10.0/chemparseplot/plot/disconnectivity.py +323 -0
- chemparseplot-1.10.0/chemparseplot/plot/landscape.py +315 -0
- chemparseplot-1.10.0/tests/plot/test_landscape_disconnectivity.py +140 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/.gitignore +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/LICENSE +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/__init__.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/api.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/__init__.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/chemgp_hdf5.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/chemgp_jsonl.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/converter.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/eon/__init__.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/eon/_trajectory_common.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/eon/con_io.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/eon/dimer_trajectory.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/eon/frame_series.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/eon/gprd.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/eon/min_trajectory.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/eon/minimization.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/eon/neb.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/eon/saddle_search.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/eon/stitch.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/file_.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/grammar/__init__.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/grammar/_deps.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/grammar/orca_text.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/grammar/xyz.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/neb_utils.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/orca/__init__.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/orca/_opi.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/orca/geomscan.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/orca/neb/__init__.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/orca/neb/interp.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/orca/neb/opi_parser.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/patterns.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/plumed.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/projection.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/sella/saddle_search.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/trajectory/__init__.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/trajectory/hdf5.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/trajectory/neb.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/types.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/plot/chemgp.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/plot/geomscan.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/plot/neb.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/plot/optimization.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/plot/plumed.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/plot/structs.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/plot/theme.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/units.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/util.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/pyproject.toml +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/readme.md +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/__init__.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/_optional_imports.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/conftest.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/fixtures/h2mol.xyz +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/fixtures/orca_energy_coords_snippet.out +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/fixtures_geomscan_snippet.txt +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/__init__.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/_con_fixtures.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/grammar/__init__.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/grammar/test_grammar_deps.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/grammar/test_orca_text_grammar.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/grammar/test_xyz_grammar.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/orca/test_geomscan.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/orca/test_interp.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_chemgp_hdf5.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_chemgp_jsonl_types.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_con_io.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_converter.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_dimer_trajectory.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_eon_neb_types.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_eon_trajectory_common.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_frame_series.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_min_trajectory.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_minimization_result.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_neb_stitch.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_neb_utils.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_orca_public_api.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_parser_types.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_patterns.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_plumed.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_projection.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_readcon_boundary.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_saddle_results.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_sella_saddle.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_trajectory_hdf5.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_trajectory_neb.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/plot/__init__.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/plot/test_chemgp_utils.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/plot/test_neb_helpers.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/plot/test_neb_renderers.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/plot/test_optimization.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/plot/test_orca_neb_payload.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/plot/test_projection_refactor.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/plot/test_strip_rendering.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/plot/test_structs_units.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/plot/test_surface_auto_thin.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/test_coverage_batch.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/test_full_coverage.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/test_public_api.py +0 -0
- {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/tutorials/test_chemparseplot.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: chemparseplot
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.10.0
|
|
4
4
|
Summary: Parsers and plotting tools for computational chemistry
|
|
5
5
|
Project-URL: Documentation, https://chemparseplot.rgoswami.me
|
|
6
6
|
Project-URL: Issues, https://github.com/HaoZeke/chemparseplot/issues
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '1.
|
|
22
|
-
__version_tuple__ = version_tuple = (1,
|
|
21
|
+
__version__ = version = '1.10.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 10, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -25,7 +25,14 @@ __all__ = [
|
|
|
25
25
|
def __getattr__(name):
|
|
26
26
|
import importlib
|
|
27
27
|
|
|
28
|
-
lazy_submodules = {
|
|
28
|
+
lazy_submodules = {
|
|
29
|
+
"geomscan",
|
|
30
|
+
"structs",
|
|
31
|
+
"chemgp",
|
|
32
|
+
"optimization",
|
|
33
|
+
"landscape",
|
|
34
|
+
"disconnectivity",
|
|
35
|
+
}
|
|
29
36
|
if name in lazy_submodules:
|
|
30
37
|
return importlib.import_module(f".{name}", __name__)
|
|
31
38
|
if name == "ureg":
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2023-present Rohit Goswami <rog32@hi.is>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
"""Approximate disconnectivity graphs from minima databases.
|
|
5
|
+
|
|
6
|
+
The classic disconnectivity graph needs transition states; a hopping
|
|
7
|
+
campaign records only quenched minima. These helpers draw the superbasin
|
|
8
|
+
merge tree of a descriptor-space k-nearest-neighbour graph instead: at each
|
|
9
|
+
energy level the minima below it split into connected components, and
|
|
10
|
+
components merge as the level rises. That approximation is a rendering of
|
|
11
|
+
the database made for reading the figure; it should never feed a reported
|
|
12
|
+
statistic.
|
|
13
|
+
|
|
14
|
+
Basins can be lumped for display by spectral clustering on the same graph.
|
|
15
|
+
Everything is iterative: with a thousand minima the merge chains grow
|
|
16
|
+
deeper than any recursion limit.
|
|
17
|
+
|
|
18
|
+
```{versionadded} 1.10.0
|
|
19
|
+
```
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
from scipy.sparse import coo_matrix
|
|
26
|
+
from scipy.sparse.csgraph import connected_components, laplacian
|
|
27
|
+
|
|
28
|
+
from chemparseplot.plot.theme import RUHI_COLORS
|
|
29
|
+
|
|
30
|
+
BASIN_PALETTE = [
|
|
31
|
+
RUHI_COLORS[c] for c in ["teal", "sky", "magenta", "coral", "sunshine"]
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def dedup(matrix, energies, tol):
|
|
36
|
+
"""Merge near-identical minima, keeping the lowest-energy representative.
|
|
37
|
+
|
|
38
|
+
Returns the kept row indices, lowest energy first.
|
|
39
|
+
|
|
40
|
+
```{versionadded} 1.10.0
|
|
41
|
+
```
|
|
42
|
+
"""
|
|
43
|
+
order = np.argsort(energies)
|
|
44
|
+
rep_idx = []
|
|
45
|
+
for i in order:
|
|
46
|
+
if any(np.linalg.norm(matrix[i] - matrix[j]) < tol for j in rep_idx):
|
|
47
|
+
continue
|
|
48
|
+
rep_idx.append(i)
|
|
49
|
+
return np.array(rep_idx)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def knn_graph(matrix, k=6):
|
|
53
|
+
"""Symmetric k-nearest-neighbour adjacency in descriptor space.
|
|
54
|
+
|
|
55
|
+
```{versionadded} 1.10.0
|
|
56
|
+
```
|
|
57
|
+
"""
|
|
58
|
+
d = np.linalg.norm(matrix[:, None, :] - matrix[None, :, :], axis=-1)
|
|
59
|
+
np.fill_diagonal(d, np.inf)
|
|
60
|
+
n = len(matrix)
|
|
61
|
+
rows, cols = [], []
|
|
62
|
+
for i in range(n):
|
|
63
|
+
for j in np.argsort(d[i])[:k]:
|
|
64
|
+
rows += [i, j]
|
|
65
|
+
cols += [j, i]
|
|
66
|
+
data = np.ones(len(rows))
|
|
67
|
+
return coo_matrix((data, (rows, cols)), shape=(n, n)).tocsr()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def bridge_components(adjacency, matrix, weight=0.1):
|
|
71
|
+
"""Weakly connect graph components through their closest point pairs.
|
|
72
|
+
|
|
73
|
+
Spectral clustering assumes a connected graph; a k-NN graph over a
|
|
74
|
+
minima database often fragments. One weak edge per component pair
|
|
75
|
+
(closest points in descriptor space) restores the geometry without
|
|
76
|
+
drowning the strong intra-basin structure.
|
|
77
|
+
|
|
78
|
+
```{versionadded} 1.10.0
|
|
79
|
+
```
|
|
80
|
+
"""
|
|
81
|
+
n_comp, labels = connected_components(adjacency, directed=False)
|
|
82
|
+
if n_comp == 1:
|
|
83
|
+
return adjacency
|
|
84
|
+
adjacency = adjacency.tolil()
|
|
85
|
+
for a in range(n_comp):
|
|
86
|
+
for b in range(a + 1, n_comp):
|
|
87
|
+
ia = np.where(labels == a)[0]
|
|
88
|
+
ib = np.where(labels == b)[0]
|
|
89
|
+
d = np.linalg.norm(
|
|
90
|
+
matrix[ia][:, None, :] - matrix[ib][None, :, :], axis=-1
|
|
91
|
+
)
|
|
92
|
+
i, j = np.unravel_index(int(np.argmin(d)), d.shape)
|
|
93
|
+
adjacency[ia[i], ib[j]] = weight
|
|
94
|
+
adjacency[ib[j], ia[i]] = weight
|
|
95
|
+
return adjacency.tocsr()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def spectral_basins(adjacency, n_basins, rng, matrix=None):
|
|
99
|
+
"""Display-only basin assignment by spectral clustering on the graph.
|
|
100
|
+
|
|
101
|
+
With ``matrix`` given, disconnected components are weakly bridged
|
|
102
|
+
through their closest descriptor pairs first.
|
|
103
|
+
|
|
104
|
+
```{versionadded} 1.10.0
|
|
105
|
+
```
|
|
106
|
+
"""
|
|
107
|
+
if matrix is not None:
|
|
108
|
+
adjacency = bridge_components(adjacency, matrix)
|
|
109
|
+
lap = laplacian(adjacency.astype(float), normed=True)
|
|
110
|
+
k = min(n_basins, adjacency.shape[0] - 1)
|
|
111
|
+
# Dense eigendecomposition: minima databases stay around a thousand
|
|
112
|
+
# nodes, and iterative smallest-magnitude solvers are unreliable on
|
|
113
|
+
# the near-singular Laplacians of disconnected graphs.
|
|
114
|
+
_, vecs = np.linalg.eigh(np.asarray(lap.todense()))
|
|
115
|
+
x = vecs[:, :k]
|
|
116
|
+
x = x / (np.linalg.norm(x, axis=1, keepdims=True) + 1e-12)
|
|
117
|
+
# Farthest-point seeding: deterministic given the first pick, and it
|
|
118
|
+
# cannot drop both centers into one embedded cloud.
|
|
119
|
+
first = int(rng.integers(len(x)))
|
|
120
|
+
centers = [x[first]]
|
|
121
|
+
while len(centers) < k:
|
|
122
|
+
d = np.min(
|
|
123
|
+
[np.linalg.norm(x - c, axis=1) for c in centers], axis=0
|
|
124
|
+
)
|
|
125
|
+
centers.append(x[int(np.argmax(d))])
|
|
126
|
+
centers = np.array(centers)
|
|
127
|
+
for _ in range(60):
|
|
128
|
+
assign = np.argmin(
|
|
129
|
+
np.linalg.norm(x[:, None, :] - centers[None, :, :], axis=-1), axis=1
|
|
130
|
+
)
|
|
131
|
+
new = np.array(
|
|
132
|
+
[
|
|
133
|
+
x[assign == c].mean(axis=0) if (assign == c).any() else centers[c]
|
|
134
|
+
for c in range(k)
|
|
135
|
+
]
|
|
136
|
+
)
|
|
137
|
+
if np.allclose(new, centers):
|
|
138
|
+
break
|
|
139
|
+
centers = new
|
|
140
|
+
return assign
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class Node:
|
|
144
|
+
"""A merge-tree node; leaves carry their minimum's index.
|
|
145
|
+
|
|
146
|
+
```{versionadded} 1.10.0
|
|
147
|
+
```
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
__slots__ = ("level", "children", "leaf", "x", "emin", "argmin")
|
|
151
|
+
|
|
152
|
+
def __init__(self, level, children=None, leaf=None):
|
|
153
|
+
self.level = level
|
|
154
|
+
self.children = children or []
|
|
155
|
+
self.leaf = leaf
|
|
156
|
+
self.x = None
|
|
157
|
+
if self.leaf is not None:
|
|
158
|
+
self.emin = level
|
|
159
|
+
self.argmin = leaf
|
|
160
|
+
else:
|
|
161
|
+
self.emin = min(c.emin for c in self.children)
|
|
162
|
+
self.argmin = min(self.children, key=lambda c: c.emin).argmin
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def merge_tree(adjacency, energies, levels):
|
|
166
|
+
"""Superbasin merge tree over an energy-threshold sweep.
|
|
167
|
+
|
|
168
|
+
```{versionadded} 1.10.0
|
|
169
|
+
```
|
|
170
|
+
"""
|
|
171
|
+
n = len(energies)
|
|
172
|
+
leaves = [Node(energies[i], leaf=i) for i in range(n)]
|
|
173
|
+
active = {}
|
|
174
|
+
prev_sets = []
|
|
175
|
+
for lev in levels:
|
|
176
|
+
mask = energies <= lev
|
|
177
|
+
if not mask.any():
|
|
178
|
+
continue
|
|
179
|
+
sub = np.where(mask)[0]
|
|
180
|
+
_, labels = connected_components(
|
|
181
|
+
adjacency[sub][:, sub], directed=False
|
|
182
|
+
)
|
|
183
|
+
comps = {}
|
|
184
|
+
for local, g in enumerate(sub):
|
|
185
|
+
comps.setdefault(labels[local], []).append(g)
|
|
186
|
+
new_sets = [frozenset(v) for v in comps.values()]
|
|
187
|
+
for s in new_sets:
|
|
188
|
+
if s in active:
|
|
189
|
+
continue
|
|
190
|
+
parts = [p for p in prev_sets if p <= s and p in active]
|
|
191
|
+
merged = [active[p] for p in parts]
|
|
192
|
+
covered = frozenset().union(*parts) if parts else frozenset()
|
|
193
|
+
for p in parts:
|
|
194
|
+
del active[p]
|
|
195
|
+
extra = sorted(s - covered, key=lambda i: energies[i])
|
|
196
|
+
if not merged:
|
|
197
|
+
node = leaves[extra[0]]
|
|
198
|
+
for m in extra[1:]:
|
|
199
|
+
node = Node(
|
|
200
|
+
max(energies[m], node.level), children=[node, leaves[m]]
|
|
201
|
+
)
|
|
202
|
+
else:
|
|
203
|
+
node = merged[0] if len(merged) == 1 else Node(lev, children=merged)
|
|
204
|
+
for m in extra:
|
|
205
|
+
node = Node(lev, children=[node, leaves[m]])
|
|
206
|
+
active[s] = node
|
|
207
|
+
prev_sets = new_sets
|
|
208
|
+
roots = list(active.values())
|
|
209
|
+
if len(roots) > 1:
|
|
210
|
+
return Node(levels[-1], children=roots)
|
|
211
|
+
return roots[0]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def iter_nodes(root):
|
|
215
|
+
"""Post-order traversal without recursion.
|
|
216
|
+
|
|
217
|
+
```{versionadded} 1.10.0
|
|
218
|
+
```
|
|
219
|
+
"""
|
|
220
|
+
out, stack = [], [(root, False)]
|
|
221
|
+
while stack:
|
|
222
|
+
node, done = stack.pop()
|
|
223
|
+
if done or node.leaf is not None:
|
|
224
|
+
out.append(node)
|
|
225
|
+
continue
|
|
226
|
+
stack.append((node, True))
|
|
227
|
+
for c in reversed(node.children):
|
|
228
|
+
stack.append((c, False))
|
|
229
|
+
return out
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def layout(root):
|
|
233
|
+
"""Leaf x positions in tree order, children sorted deepest-first.
|
|
234
|
+
|
|
235
|
+
```{versionadded} 1.10.0
|
|
236
|
+
```
|
|
237
|
+
"""
|
|
238
|
+
for node in iter_nodes(root):
|
|
239
|
+
if node.leaf is None:
|
|
240
|
+
node.children.sort(key=lambda c: c.emin)
|
|
241
|
+
x = 0.0
|
|
242
|
+
for node in iter_nodes(root):
|
|
243
|
+
if node.leaf is not None:
|
|
244
|
+
node.x = x
|
|
245
|
+
x += 1.0
|
|
246
|
+
else:
|
|
247
|
+
node.x = float(np.mean([c.x for c in node.children]))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def paint(root, assign, palette=None):
|
|
251
|
+
"""Colour each subtree by the spectral basin of its deepest minimum.
|
|
252
|
+
|
|
253
|
+
```{versionadded} 1.10.0
|
|
254
|
+
```
|
|
255
|
+
"""
|
|
256
|
+
palette = palette or BASIN_PALETTE
|
|
257
|
+
colors = {}
|
|
258
|
+
for node in iter_nodes(root):
|
|
259
|
+
colors[id(node)] = palette[int(assign[node.argmin]) % len(palette)]
|
|
260
|
+
return colors
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def draw(ax, root, colors, lw=1.1):
|
|
264
|
+
"""Vertical stems to the merge level, horizontal bars at merges.
|
|
265
|
+
|
|
266
|
+
```{versionadded} 1.10.0
|
|
267
|
+
```
|
|
268
|
+
"""
|
|
269
|
+
for node in iter_nodes(root):
|
|
270
|
+
if node.leaf is not None:
|
|
271
|
+
continue
|
|
272
|
+
for c in node.children:
|
|
273
|
+
ax.plot(
|
|
274
|
+
[c.x, c.x],
|
|
275
|
+
[c.level, node.level],
|
|
276
|
+
color=colors.get(id(c), "#444444"),
|
|
277
|
+
lw=lw,
|
|
278
|
+
solid_capstyle="round",
|
|
279
|
+
zorder=2,
|
|
280
|
+
)
|
|
281
|
+
xs = [c.x for c in node.children]
|
|
282
|
+
ax.plot(
|
|
283
|
+
[min(xs), max(xs)],
|
|
284
|
+
[node.level, node.level],
|
|
285
|
+
color="#444444",
|
|
286
|
+
lw=0.9,
|
|
287
|
+
zorder=1,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def plot_disconnectivity(
|
|
292
|
+
ax,
|
|
293
|
+
matrix,
|
|
294
|
+
energies,
|
|
295
|
+
*,
|
|
296
|
+
dedup_tol=0.15,
|
|
297
|
+
knn=6,
|
|
298
|
+
n_levels=48,
|
|
299
|
+
n_basins=5,
|
|
300
|
+
rng=None,
|
|
301
|
+
):
|
|
302
|
+
"""Full pipeline: dedup, graph, merge tree, spectral paint, draw.
|
|
303
|
+
|
|
304
|
+
Returns ``(kept_indices, root)`` so callers can label leaves.
|
|
305
|
+
|
|
306
|
+
```{versionadded} 1.10.0
|
|
307
|
+
```
|
|
308
|
+
"""
|
|
309
|
+
rng = rng or np.random.default_rng(11)
|
|
310
|
+
reps = dedup(matrix, energies, dedup_tol)
|
|
311
|
+
m = matrix[reps]
|
|
312
|
+
en = energies[reps]
|
|
313
|
+
adjacency = knn_graph(m, knn)
|
|
314
|
+
levels = np.linspace(en.min(), en.max(), n_levels)[1:]
|
|
315
|
+
root = merge_tree(adjacency, en, levels)
|
|
316
|
+
layout(root)
|
|
317
|
+
assign = spectral_basins(adjacency, n_basins, rng, matrix=m)
|
|
318
|
+
colors = paint(root, assign)
|
|
319
|
+
draw(ax, root, colors)
|
|
320
|
+
ax.set_xticks([])
|
|
321
|
+
ax.set_ylabel("quenched energy")
|
|
322
|
+
ax.spines["bottom"].set_visible(False)
|
|
323
|
+
return reps, root
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2023-present Rohit Goswami <rog32@hi.is>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
"""Sketch-map landscape figures over minima databases.
|
|
5
|
+
|
|
6
|
+
A campaign of quenched minima becomes one figure: a sketch-map plane
|
|
7
|
+
(lab-cosmo ``dimlandmark``/``dimred`` binaries) over a permutation-invariant
|
|
8
|
+
descriptor, a filled-contour energy surface with thin contour lines, per-arm
|
|
9
|
+
scatter overlays, pinned reference markers, and rendered structure insets in
|
|
10
|
+
the figure margins tied to their map points.
|
|
11
|
+
|
|
12
|
+
The default descriptor is the sorted smoothed-coordination-number vector,
|
|
13
|
+
the discriminant of the sketch-map literature for Lennard-Jones clusters:
|
|
14
|
+
it separates the fcc funnel from the icosahedral one. SOAP power spectra
|
|
15
|
+
(through ``featomic``) remain available for molecular systems where
|
|
16
|
+
coordination alone degenerates.
|
|
17
|
+
|
|
18
|
+
```{versionadded} 1.10.0
|
|
19
|
+
```
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import subprocess
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import matplotlib as mpl
|
|
28
|
+
import matplotlib.pyplot as plt
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
from chemparseplot.plot.theme import RUHI_COLORS, RUHI_THEME
|
|
32
|
+
|
|
33
|
+
ARM_MARKERS = ["o", "^", "s", "P", "X"]
|
|
34
|
+
|
|
35
|
+
SOAP_HYPERS = {
|
|
36
|
+
"cutoff": {"radius": 4.0, "smoothing": {"type": "ShiftedCosine", "width": 0.5}},
|
|
37
|
+
"density": {"type": "Gaussian", "width": 0.35},
|
|
38
|
+
"basis": {
|
|
39
|
+
"type": "TensorProduct",
|
|
40
|
+
"max_angular": 4,
|
|
41
|
+
"radial": {"type": "Gto", "max_radial": 5},
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cn_matrix(structures, symbols, cutoff=1.5, cn_species=None):
|
|
47
|
+
"""Sorted smoothed coordination numbers, permutation invariant.
|
|
48
|
+
|
|
49
|
+
A Fermi switching function at the cutoff keeps the vector continuous,
|
|
50
|
+
so near-degenerate minima do not collapse onto identical rows.
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
structures : sequence of array-like
|
|
55
|
+
Flat ``3N`` coordinate rows.
|
|
56
|
+
symbols : list of str
|
|
57
|
+
One chemical symbol per atom.
|
|
58
|
+
cutoff : float
|
|
59
|
+
Coordination cutoff in the coordinate units.
|
|
60
|
+
cn_species : str, optional
|
|
61
|
+
Restrict the descriptor to atoms of one species.
|
|
62
|
+
|
|
63
|
+
```{versionadded} 1.10.0
|
|
64
|
+
```
|
|
65
|
+
"""
|
|
66
|
+
width = 0.1 * cutoff
|
|
67
|
+
keep = None
|
|
68
|
+
if cn_species:
|
|
69
|
+
keep = np.array([s == cn_species for s in symbols])
|
|
70
|
+
rows = []
|
|
71
|
+
for x in structures:
|
|
72
|
+
pos = np.asarray(x, dtype=float).reshape(-1, 3)
|
|
73
|
+
if keep is not None:
|
|
74
|
+
pos = pos[keep]
|
|
75
|
+
d = np.linalg.norm(pos[:, None, :] - pos[None, :, :], axis=-1)
|
|
76
|
+
np.fill_diagonal(d, np.inf)
|
|
77
|
+
cn = (1.0 / (1.0 + np.exp((d - cutoff) / width))).sum(axis=1)
|
|
78
|
+
rows.append(np.sort(cn))
|
|
79
|
+
return np.vstack(rows)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def soap_matrix(structures, symbols, cutoff=4.0, hypers=None):
|
|
83
|
+
"""Structure-averaged SOAP power spectra through featomic.
|
|
84
|
+
|
|
85
|
+
```{versionadded} 1.10.0
|
|
86
|
+
```
|
|
87
|
+
"""
|
|
88
|
+
import ase
|
|
89
|
+
from featomic import SoapPowerSpectrum
|
|
90
|
+
|
|
91
|
+
hy = dict(hypers or SOAP_HYPERS)
|
|
92
|
+
hy["cutoff"] = dict(hy["cutoff"], radius=cutoff)
|
|
93
|
+
calc = SoapPowerSpectrum(**hy)
|
|
94
|
+
rows = []
|
|
95
|
+
for x in structures:
|
|
96
|
+
pos = np.asarray(x, dtype=float).reshape(-1, 3)
|
|
97
|
+
pos = pos - pos.mean(axis=0)
|
|
98
|
+
atoms = ase.Atoms(symbols=symbols, positions=pos)
|
|
99
|
+
ps = calc.compute(atoms)
|
|
100
|
+
ps = ps.keys_to_samples("center_type").keys_to_properties(
|
|
101
|
+
["neighbor_1_type", "neighbor_2_type"]
|
|
102
|
+
)
|
|
103
|
+
rows.append(ps.block().values.mean(axis=0))
|
|
104
|
+
return np.vstack(rows)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def median_sigma(matrix, rng, sample=400):
|
|
108
|
+
"""The sketch-map switching scale: median pairwise descriptor distance.
|
|
109
|
+
|
|
110
|
+
```{versionadded} 1.10.0
|
|
111
|
+
```
|
|
112
|
+
"""
|
|
113
|
+
sub = matrix[rng.choice(len(matrix), size=min(len(matrix), sample), replace=False)]
|
|
114
|
+
d = np.linalg.norm(sub[:, None, :] - sub[None, :, :], axis=-1)
|
|
115
|
+
return float(np.median(d[np.triu_indices(len(sub), 1)]))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def run_sketchmap(
|
|
119
|
+
matrix,
|
|
120
|
+
smbin,
|
|
121
|
+
lapack_lib,
|
|
122
|
+
n_landmark,
|
|
123
|
+
sigma,
|
|
124
|
+
workdir,
|
|
125
|
+
n_pinned=0,
|
|
126
|
+
):
|
|
127
|
+
"""Landmark selection and the published sketch-map projection schedule.
|
|
128
|
+
|
|
129
|
+
``dimlandmark`` picks minmax landmarks (the first ``n_pinned`` rows are
|
|
130
|
+
pinned); ``dimred`` runs sigmoid exponents 8,8 against 2,8, conjugate
|
|
131
|
+
gradient pre-optimization, then the pointwise global grid stage. No
|
|
132
|
+
``-pi``: descriptor space is not periodic.
|
|
133
|
+
|
|
134
|
+
Returns
|
|
135
|
+
-------
|
|
136
|
+
coords, idx : ndarray
|
|
137
|
+
Projected landmark coordinates and their row indices in ``matrix``.
|
|
138
|
+
|
|
139
|
+
```{versionadded} 1.10.0
|
|
140
|
+
```
|
|
141
|
+
"""
|
|
142
|
+
workdir = Path(workdir)
|
|
143
|
+
workdir.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
smbin = Path(smbin)
|
|
145
|
+
mat = workdir / "desc.dat"
|
|
146
|
+
np.savetxt(mat, matrix, fmt="%.8f")
|
|
147
|
+
env = {"LD_LIBRARY_PATH": str(lapack_lib), "PATH": "/usr/bin:/bin"}
|
|
148
|
+
dim = matrix.shape[1]
|
|
149
|
+
lm_raw = subprocess.run( # noqa: S603
|
|
150
|
+
[
|
|
151
|
+
str(smbin / "dimlandmark"),
|
|
152
|
+
"-D",
|
|
153
|
+
str(dim),
|
|
154
|
+
"-n",
|
|
155
|
+
str(n_landmark),
|
|
156
|
+
"-mode",
|
|
157
|
+
"minmax",
|
|
158
|
+
"-i",
|
|
159
|
+
"-ifirst",
|
|
160
|
+
str(n_pinned),
|
|
161
|
+
],
|
|
162
|
+
stdin=mat.open(),
|
|
163
|
+
capture_output=True,
|
|
164
|
+
text=True,
|
|
165
|
+
env=env,
|
|
166
|
+
check=True,
|
|
167
|
+
).stdout
|
|
168
|
+
rows = [
|
|
169
|
+
line.split()
|
|
170
|
+
for line in lm_raw.splitlines()
|
|
171
|
+
if line.strip() and not line.startswith("#")
|
|
172
|
+
]
|
|
173
|
+
idx = np.array([int(r[0]) for r in rows])
|
|
174
|
+
(workdir / "landmarks.dat").write_text(
|
|
175
|
+
"\n".join(" ".join(r[1:]) for r in rows) + "\n"
|
|
176
|
+
)
|
|
177
|
+
proj = subprocess.run( # noqa: S603
|
|
178
|
+
[
|
|
179
|
+
str(smbin / "dimred"),
|
|
180
|
+
"-D",
|
|
181
|
+
str(dim),
|
|
182
|
+
"-d",
|
|
183
|
+
"2",
|
|
184
|
+
"-center",
|
|
185
|
+
"-fun-hd",
|
|
186
|
+
f"{sigma},8,8",
|
|
187
|
+
"-fun-ld",
|
|
188
|
+
f"{sigma},2,8",
|
|
189
|
+
"-preopt",
|
|
190
|
+
"500",
|
|
191
|
+
"-grid",
|
|
192
|
+
f"{10 * sigma:.4f},51,501",
|
|
193
|
+
"-gopt",
|
|
194
|
+
"10",
|
|
195
|
+
],
|
|
196
|
+
stdin=(workdir / "landmarks.dat").open(),
|
|
197
|
+
capture_output=True,
|
|
198
|
+
text=True,
|
|
199
|
+
env=env,
|
|
200
|
+
check=True,
|
|
201
|
+
).stdout
|
|
202
|
+
coords = np.array(
|
|
203
|
+
[
|
|
204
|
+
[float(v) for v in line.split()[:2]]
|
|
205
|
+
for line in proj.splitlines()
|
|
206
|
+
if line.strip() and not line.startswith("#")
|
|
207
|
+
]
|
|
208
|
+
)
|
|
209
|
+
if len(idx) != len(coords):
|
|
210
|
+
msg = (
|
|
211
|
+
f"dimlandmark gave {len(idx)} indices, dimred {len(coords)} "
|
|
212
|
+
"points; refusing to plot with mismatched labels"
|
|
213
|
+
)
|
|
214
|
+
raise RuntimeError(msg)
|
|
215
|
+
return coords, idx
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def plot_landscape_surface(ax, coords, energies, cmap=None, levels=20):
|
|
219
|
+
"""Filled-contour energy surface with thin contour lines.
|
|
220
|
+
|
|
221
|
+
Returns the contour set for colorbar wiring.
|
|
222
|
+
|
|
223
|
+
```{versionadded} 1.10.0
|
|
224
|
+
```
|
|
225
|
+
"""
|
|
226
|
+
from scipy.interpolate import griddata
|
|
227
|
+
|
|
228
|
+
cmap = cmap or mpl.colormaps["ruhi_diverging"]
|
|
229
|
+
vmin = float(np.nanmin(energies))
|
|
230
|
+
vmax = float(np.nanpercentile(energies, 97))
|
|
231
|
+
gx, gy = np.meshgrid(
|
|
232
|
+
np.linspace(coords[:, 0].min(), coords[:, 0].max(), 240),
|
|
233
|
+
np.linspace(coords[:, 1].min(), coords[:, 1].max(), 240),
|
|
234
|
+
)
|
|
235
|
+
surface = griddata(
|
|
236
|
+
coords, np.clip(energies, vmin, vmax), (gx, gy), method="linear"
|
|
237
|
+
)
|
|
238
|
+
lv = np.linspace(vmin, vmax, levels)
|
|
239
|
+
cf = ax.contourf(gx, gy, surface, levels=lv, cmap=cmap, vmin=vmin, vmax=vmax,
|
|
240
|
+
alpha=0.85)
|
|
241
|
+
ax.contour(gx, gy, surface, levels=lv, colors="black", linewidths=0.3,
|
|
242
|
+
alpha=0.45)
|
|
243
|
+
ax.set_facecolor(RUHI_THEME.gridcolor)
|
|
244
|
+
return cf
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def margin_inset_slots(n):
|
|
248
|
+
"""Axes-fraction slots down the left and right figure margins.
|
|
249
|
+
|
|
250
|
+
```{versionadded} 1.10.0
|
|
251
|
+
```
|
|
252
|
+
"""
|
|
253
|
+
slots = [(-0.26, 0.82), (-0.26, 0.22), (1.30, 0.82), (1.30, 0.22),
|
|
254
|
+
(-0.26, 0.52), (1.30, 0.52)]
|
|
255
|
+
return slots[:n]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def place_margin_insets(fig, ax, entries, zoom=0.14, renderer="xyzrender"):
|
|
259
|
+
"""Structure insets at margin slots, arrowed to their map points.
|
|
260
|
+
|
|
261
|
+
Parameters
|
|
262
|
+
----------
|
|
263
|
+
entries : list of dict
|
|
264
|
+
Each carries ``atoms`` (ASE Atoms), ``xy`` (map point), and
|
|
265
|
+
optionally ``label`` drawn under the slot.
|
|
266
|
+
|
|
267
|
+
```{versionadded} 1.10.0
|
|
268
|
+
```
|
|
269
|
+
"""
|
|
270
|
+
from chemparseplot.plot.neb import plot_structure_inset
|
|
271
|
+
|
|
272
|
+
slots = margin_inset_slots(len(entries))
|
|
273
|
+
fig.canvas.draw()
|
|
274
|
+
remaining = list(range(len(slots)))
|
|
275
|
+
for entry in entries:
|
|
276
|
+
x, y = entry["xy"]
|
|
277
|
+
pt_disp = ax.transData.transform((x, y))
|
|
278
|
+
if not remaining:
|
|
279
|
+
break
|
|
280
|
+
best = min(
|
|
281
|
+
remaining,
|
|
282
|
+
key=lambda s: np.linalg.norm(ax.transAxes.transform(slots[s]) - pt_disp),
|
|
283
|
+
)
|
|
284
|
+
remaining.remove(best)
|
|
285
|
+
slot_disp = ax.transAxes.transform(slots[best])
|
|
286
|
+
offset_pts = (slot_disp - pt_disp) * 72.0 / fig.dpi
|
|
287
|
+
plot_structure_inset(
|
|
288
|
+
ax,
|
|
289
|
+
entry["atoms"],
|
|
290
|
+
x,
|
|
291
|
+
y,
|
|
292
|
+
xybox=tuple(offset_pts),
|
|
293
|
+
rad=0.08,
|
|
294
|
+
zoom=zoom,
|
|
295
|
+
rotation="auto",
|
|
296
|
+
renderer=renderer,
|
|
297
|
+
xyzrender_config="paton",
|
|
298
|
+
)
|
|
299
|
+
ax.scatter([x], [y], marker="o", s=120, facecolors="none",
|
|
300
|
+
edgecolors="black", linewidths=1.4, zorder=7)
|
|
301
|
+
if entry.get("label"):
|
|
302
|
+
ax.annotate(
|
|
303
|
+
entry["label"],
|
|
304
|
+
xy=slots[best],
|
|
305
|
+
xycoords="axes fraction",
|
|
306
|
+
xytext=(0, -58),
|
|
307
|
+
textcoords="offset points",
|
|
308
|
+
ha="center",
|
|
309
|
+
va="top",
|
|
310
|
+
fontsize=plt.rcParams["font.size"] - 3,
|
|
311
|
+
color="black",
|
|
312
|
+
weight="bold",
|
|
313
|
+
zorder=81,
|
|
314
|
+
annotation_clip=False,
|
|
315
|
+
)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2023-present Rohit Goswami <rog32@hi.is>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
"""Pure-python coverage of the landscape descriptors and the merge tree.
|
|
5
|
+
|
|
6
|
+
The sketch-map binaries and featomic are external; everything here
|
|
7
|
+
exercises the descriptor math and the disconnectivity pipeline only.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import matplotlib
|
|
11
|
+
|
|
12
|
+
matplotlib.use("Agg")
|
|
13
|
+
|
|
14
|
+
import matplotlib.pyplot as plt
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from chemparseplot.plot.disconnectivity import (
|
|
19
|
+
Node,
|
|
20
|
+
dedup,
|
|
21
|
+
iter_nodes,
|
|
22
|
+
knn_graph,
|
|
23
|
+
layout,
|
|
24
|
+
merge_tree,
|
|
25
|
+
plot_disconnectivity,
|
|
26
|
+
spectral_basins,
|
|
27
|
+
)
|
|
28
|
+
from chemparseplot.plot.landscape import cn_matrix, median_sigma
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def dimer_chain(n, spacing):
|
|
32
|
+
"""A 1D chain of n atoms as a flat coordinate row."""
|
|
33
|
+
pos = np.zeros((n, 3))
|
|
34
|
+
pos[:, 0] = np.arange(n) * spacing
|
|
35
|
+
return pos.ravel()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_cn_matrix_permutation_invariant():
|
|
39
|
+
rng = np.random.default_rng(3)
|
|
40
|
+
pos = rng.normal(size=(8, 3))
|
|
41
|
+
perm = rng.permutation(8)
|
|
42
|
+
a = cn_matrix([pos.ravel()], ["X"] * 8, cutoff=1.5)
|
|
43
|
+
b = cn_matrix([pos[perm].ravel()], ["X"] * 8, cutoff=1.5)
|
|
44
|
+
np.testing.assert_allclose(a, b, atol=1e-10)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_cn_matrix_species_restriction():
|
|
48
|
+
pos = np.zeros((4, 3))
|
|
49
|
+
pos[:, 0] = [0.0, 1.0, 5.0, 6.0]
|
|
50
|
+
full = cn_matrix([pos.ravel()], ["O", "H", "O", "H"], cutoff=1.5)
|
|
51
|
+
only_o = cn_matrix(
|
|
52
|
+
[pos.ravel()], ["O", "H", "O", "H"], cutoff=1.5, cn_species="O"
|
|
53
|
+
)
|
|
54
|
+
assert full.shape[1] == 4
|
|
55
|
+
assert only_o.shape[1] == 2
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_cn_matrix_distinguishes_compact_from_stretched():
|
|
59
|
+
tight = dimer_chain(6, 1.1)
|
|
60
|
+
loose = dimer_chain(6, 3.0)
|
|
61
|
+
m = cn_matrix([tight, loose], ["X"] * 6, cutoff=1.5)
|
|
62
|
+
assert m[0].sum() > m[1].sum()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_median_sigma_positive():
|
|
66
|
+
rng = np.random.default_rng(5)
|
|
67
|
+
m = rng.normal(size=(50, 4))
|
|
68
|
+
sigma = median_sigma(m, rng)
|
|
69
|
+
assert sigma > 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_dedup_keeps_lowest_energy_representative():
|
|
73
|
+
m = np.array([[0.0], [0.01], [5.0]])
|
|
74
|
+
es = np.array([2.0, 1.0, 3.0])
|
|
75
|
+
reps = dedup(m, es, tol=0.5)
|
|
76
|
+
assert 1 in reps # the lower-energy twin survives
|
|
77
|
+
assert 0 not in reps
|
|
78
|
+
assert 2 in reps
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_merge_tree_two_basins_merge_once():
|
|
82
|
+
# Two well-separated pairs: components must merge only at the top.
|
|
83
|
+
m = np.array([[0.0], [0.1], [10.0], [10.1]])
|
|
84
|
+
es = np.array([-4.0, -3.0, -3.9, -2.9])
|
|
85
|
+
adjacency = knn_graph(m, k=1)
|
|
86
|
+
levels = np.linspace(es.min(), es.max() + 1.0, 20)[1:]
|
|
87
|
+
root = merge_tree(adjacency, es, levels)
|
|
88
|
+
members = sorted(n.leaf for n in iter_nodes(root) if n.leaf is not None)
|
|
89
|
+
assert members == [0, 1, 2, 3]
|
|
90
|
+
assert root.emin == -4.0
|
|
91
|
+
layout(root)
|
|
92
|
+
xs = [n.x for n in iter_nodes(root) if n.leaf is not None]
|
|
93
|
+
assert sorted(xs) == [0.0, 1.0, 2.0, 3.0]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_merge_tree_deep_chain_no_recursion():
|
|
97
|
+
n = 3000
|
|
98
|
+
m = np.arange(n, dtype=float).reshape(-1, 1)
|
|
99
|
+
es = np.linspace(-10.0, -1.0, n)
|
|
100
|
+
adjacency = knn_graph(m, k=2)
|
|
101
|
+
levels = np.linspace(es.min(), es.max(), 40)[1:]
|
|
102
|
+
root = merge_tree(adjacency, es, levels)
|
|
103
|
+
layout(root)
|
|
104
|
+
assert len([x for x in iter_nodes(root) if x.leaf is not None]) == n
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_spectral_basins_shapes():
|
|
108
|
+
rng = np.random.default_rng(7)
|
|
109
|
+
m = np.vstack(
|
|
110
|
+
[rng.normal(0, 0.1, size=(10, 2)), rng.normal(5, 0.1, size=(10, 2))]
|
|
111
|
+
)
|
|
112
|
+
adjacency = knn_graph(m, k=3)
|
|
113
|
+
assign = spectral_basins(adjacency, 2, rng, matrix=m)
|
|
114
|
+
assert assign.shape == (20,)
|
|
115
|
+
# The two clouds separate into distinct basins.
|
|
116
|
+
assert len({int(a) for a in assign[:10]}) == 1
|
|
117
|
+
assert assign[0] != assign[-1]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_plot_disconnectivity_end_to_end():
|
|
121
|
+
rng = np.random.default_rng(9)
|
|
122
|
+
m = np.vstack(
|
|
123
|
+
[rng.normal(0, 0.2, size=(15, 3)), rng.normal(4, 0.2, size=(15, 3))]
|
|
124
|
+
)
|
|
125
|
+
es = np.concatenate(
|
|
126
|
+
[rng.uniform(-10, -8, size=15), rng.uniform(-9, -7, size=15)]
|
|
127
|
+
)
|
|
128
|
+
fig, ax = plt.subplots()
|
|
129
|
+
reps, root = plot_disconnectivity(ax, m, es, dedup_tol=0.01, knn=3)
|
|
130
|
+
assert len(reps) <= 30
|
|
131
|
+
assert root.emin == pytest.approx(es.min())
|
|
132
|
+
plt.close(fig)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_node_tracks_argmin():
|
|
136
|
+
a = Node(-5.0, leaf=0)
|
|
137
|
+
b = Node(-7.0, leaf=1)
|
|
138
|
+
parent = Node(-4.0, children=[a, b])
|
|
139
|
+
assert parent.emin == -7.0
|
|
140
|
+
assert parent.argmin == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|