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.
Files changed (106) hide show
  1. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/PKG-INFO +1 -1
  2. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/_version.py +2 -2
  3. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/plot/__init__.py +8 -1
  4. chemparseplot-1.10.0/chemparseplot/plot/disconnectivity.py +323 -0
  5. chemparseplot-1.10.0/chemparseplot/plot/landscape.py +315 -0
  6. chemparseplot-1.10.0/tests/plot/test_landscape_disconnectivity.py +140 -0
  7. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/.gitignore +0 -0
  8. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/LICENSE +0 -0
  9. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/__init__.py +0 -0
  10. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/api.py +0 -0
  11. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/__init__.py +0 -0
  12. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/chemgp_hdf5.py +0 -0
  13. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/chemgp_jsonl.py +0 -0
  14. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/converter.py +0 -0
  15. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/eon/__init__.py +0 -0
  16. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/eon/_trajectory_common.py +0 -0
  17. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/eon/con_io.py +0 -0
  18. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/eon/dimer_trajectory.py +0 -0
  19. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/eon/frame_series.py +0 -0
  20. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/eon/gprd.py +0 -0
  21. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/eon/min_trajectory.py +0 -0
  22. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/eon/minimization.py +0 -0
  23. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/eon/neb.py +0 -0
  24. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/eon/saddle_search.py +0 -0
  25. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/eon/stitch.py +0 -0
  26. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/file_.py +0 -0
  27. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/grammar/__init__.py +0 -0
  28. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/grammar/_deps.py +0 -0
  29. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/grammar/orca_text.py +0 -0
  30. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/grammar/xyz.py +0 -0
  31. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/neb_utils.py +0 -0
  32. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/orca/__init__.py +0 -0
  33. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/orca/_opi.py +0 -0
  34. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/orca/geomscan.py +0 -0
  35. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/orca/neb/__init__.py +0 -0
  36. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/orca/neb/interp.py +0 -0
  37. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/orca/neb/opi_parser.py +0 -0
  38. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/patterns.py +0 -0
  39. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/plumed.py +0 -0
  40. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/projection.py +0 -0
  41. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/sella/saddle_search.py +0 -0
  42. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/trajectory/__init__.py +0 -0
  43. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/trajectory/hdf5.py +0 -0
  44. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/trajectory/neb.py +0 -0
  45. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/parse/types.py +0 -0
  46. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/plot/chemgp.py +0 -0
  47. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/plot/geomscan.py +0 -0
  48. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/plot/neb.py +0 -0
  49. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/plot/optimization.py +0 -0
  50. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/plot/plumed.py +0 -0
  51. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/plot/structs.py +0 -0
  52. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/plot/theme.py +0 -0
  53. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/units.py +0 -0
  54. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/chemparseplot/util.py +0 -0
  55. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/pyproject.toml +0 -0
  56. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/readme.md +0 -0
  57. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/__init__.py +0 -0
  58. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/_optional_imports.py +0 -0
  59. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/conftest.py +0 -0
  60. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/fixtures/h2mol.xyz +0 -0
  61. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/fixtures/orca_energy_coords_snippet.out +0 -0
  62. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/fixtures_geomscan_snippet.txt +0 -0
  63. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/__init__.py +0 -0
  64. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/_con_fixtures.py +0 -0
  65. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/grammar/__init__.py +0 -0
  66. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/grammar/test_grammar_deps.py +0 -0
  67. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/grammar/test_orca_text_grammar.py +0 -0
  68. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/grammar/test_xyz_grammar.py +0 -0
  69. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/orca/test_geomscan.py +0 -0
  70. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/orca/test_interp.py +0 -0
  71. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_chemgp_hdf5.py +0 -0
  72. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_chemgp_jsonl_types.py +0 -0
  73. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_con_io.py +0 -0
  74. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_converter.py +0 -0
  75. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_dimer_trajectory.py +0 -0
  76. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_eon_neb_types.py +0 -0
  77. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_eon_trajectory_common.py +0 -0
  78. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_frame_series.py +0 -0
  79. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_min_trajectory.py +0 -0
  80. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_minimization_result.py +0 -0
  81. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_neb_stitch.py +0 -0
  82. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_neb_utils.py +0 -0
  83. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_orca_public_api.py +0 -0
  84. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_parser_types.py +0 -0
  85. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_patterns.py +0 -0
  86. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_plumed.py +0 -0
  87. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_projection.py +0 -0
  88. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_readcon_boundary.py +0 -0
  89. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_saddle_results.py +0 -0
  90. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_sella_saddle.py +0 -0
  91. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_trajectory_hdf5.py +0 -0
  92. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/parse/test_trajectory_neb.py +0 -0
  93. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/plot/__init__.py +0 -0
  94. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/plot/test_chemgp_utils.py +0 -0
  95. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/plot/test_neb_helpers.py +0 -0
  96. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/plot/test_neb_renderers.py +0 -0
  97. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/plot/test_optimization.py +0 -0
  98. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/plot/test_orca_neb_payload.py +0 -0
  99. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/plot/test_projection_refactor.py +0 -0
  100. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/plot/test_strip_rendering.py +0 -0
  101. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/plot/test_structs_units.py +0 -0
  102. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/plot/test_surface_auto_thin.py +0 -0
  103. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/test_coverage_batch.py +0 -0
  104. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/test_full_coverage.py +0 -0
  105. {chemparseplot-1.9.17 → chemparseplot-1.10.0}/tests/test_public_api.py +0 -0
  106. {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.9.17
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.9.17'
22
- __version_tuple__ = version_tuple = (1, 9, 17)
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 = {"geomscan", "structs", "chemgp", "optimization"}
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