chemparseplot 1.9.16__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 (107) hide show
  1. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/PKG-INFO +3 -1
  2. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/_version.py +2 -2
  3. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/api.py +1 -2
  4. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/chemgp_hdf5.py +1 -0
  5. chemparseplot-1.10.0/chemparseplot/parse/eon/__init__.py +22 -0
  6. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/eon/_trajectory_common.py +4 -1
  7. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/eon/dimer_trajectory.py +2 -3
  8. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/eon/frame_series.py +5 -3
  9. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/eon/min_trajectory.py +2 -4
  10. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/eon/saddle_search.py +1 -3
  11. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/grammar/__init__.py +7 -1
  12. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/grammar/orca_text.py +3 -4
  13. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/grammar/xyz.py +11 -8
  14. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/sella/saddle_search.py +4 -3
  15. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/types.py +1 -1
  16. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/plot/__init__.py +8 -1
  17. chemparseplot-1.10.0/chemparseplot/plot/disconnectivity.py +323 -0
  18. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/plot/geomscan.py +7 -1
  19. chemparseplot-1.10.0/chemparseplot/plot/landscape.py +315 -0
  20. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/plot/neb.py +13 -18
  21. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/plot/optimization.py +2 -6
  22. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/plot/structs.py +1 -3
  23. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/pyproject.toml +41 -11
  24. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/fixtures/orca_energy_coords_snippet.out +0 -1
  25. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/_con_fixtures.py +5 -6
  26. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/grammar/test_orca_text_grammar.py +2 -2
  27. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/grammar/test_xyz_grammar.py +1 -1
  28. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/test_con_io.py +1 -1
  29. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/test_eon_trajectory_common.py +17 -8
  30. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/test_frame_series.py +3 -1
  31. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/test_min_trajectory.py +1 -2
  32. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/test_neb_stitch.py +6 -4
  33. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/test_orca_public_api.py +9 -4
  34. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/test_readcon_boundary.py +3 -1
  35. chemparseplot-1.10.0/tests/plot/test_landscape_disconnectivity.py +140 -0
  36. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/plot/test_neb_helpers.py +3 -2
  37. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/plot/test_neb_renderers.py +7 -8
  38. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/plot/test_optimization.py +7 -2
  39. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/plot/test_strip_rendering.py +3 -1
  40. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/test_full_coverage.py +32 -5
  41. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/tutorials/test_chemparseplot.py +3 -2
  42. chemparseplot-1.9.16/chemparseplot/parse/eon/__init__.py +0 -8
  43. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/.gitignore +0 -0
  44. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/LICENSE +0 -0
  45. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/__init__.py +0 -0
  46. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/__init__.py +0 -0
  47. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/chemgp_jsonl.py +0 -0
  48. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/converter.py +0 -0
  49. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/eon/con_io.py +0 -0
  50. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/eon/gprd.py +0 -0
  51. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/eon/minimization.py +0 -0
  52. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/eon/neb.py +0 -0
  53. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/eon/stitch.py +0 -0
  54. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/file_.py +0 -0
  55. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/grammar/_deps.py +0 -0
  56. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/neb_utils.py +0 -0
  57. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/orca/__init__.py +0 -0
  58. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/orca/_opi.py +0 -0
  59. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/orca/geomscan.py +0 -0
  60. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/orca/neb/__init__.py +0 -0
  61. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/orca/neb/interp.py +0 -0
  62. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/orca/neb/opi_parser.py +0 -0
  63. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/patterns.py +0 -0
  64. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/plumed.py +0 -0
  65. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/projection.py +0 -0
  66. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/trajectory/__init__.py +0 -0
  67. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/trajectory/hdf5.py +5 -5
  68. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/parse/trajectory/neb.py +0 -0
  69. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/plot/chemgp.py +0 -0
  70. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/plot/plumed.py +0 -0
  71. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/plot/theme.py +0 -0
  72. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/units.py +0 -0
  73. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/chemparseplot/util.py +0 -0
  74. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/readme.md +0 -0
  75. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/__init__.py +0 -0
  76. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/_optional_imports.py +0 -0
  77. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/conftest.py +0 -0
  78. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/fixtures/h2mol.xyz +0 -0
  79. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/fixtures_geomscan_snippet.txt +0 -0
  80. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/__init__.py +0 -0
  81. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/grammar/__init__.py +0 -0
  82. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/grammar/test_grammar_deps.py +1 -1
  83. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/orca/test_geomscan.py +0 -0
  84. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/orca/test_interp.py +0 -0
  85. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/test_chemgp_hdf5.py +0 -0
  86. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/test_chemgp_jsonl_types.py +0 -0
  87. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/test_converter.py +0 -0
  88. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/test_dimer_trajectory.py +0 -0
  89. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/test_eon_neb_types.py +0 -0
  90. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/test_minimization_result.py +0 -0
  91. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/test_neb_utils.py +0 -0
  92. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/test_parser_types.py +0 -0
  93. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/test_patterns.py +0 -0
  94. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/test_plumed.py +0 -0
  95. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/test_projection.py +0 -0
  96. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/test_saddle_results.py +0 -0
  97. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/test_sella_saddle.py +0 -0
  98. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/test_trajectory_hdf5.py +0 -0
  99. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/parse/test_trajectory_neb.py +0 -0
  100. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/plot/__init__.py +0 -0
  101. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/plot/test_chemgp_utils.py +0 -0
  102. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/plot/test_orca_neb_payload.py +0 -0
  103. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/plot/test_projection_refactor.py +0 -0
  104. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/plot/test_structs_units.py +0 -0
  105. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/plot/test_surface_auto_thin.py +0 -0
  106. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/test_coverage_batch.py +2 -2
  107. {chemparseplot-1.9.16 → chemparseplot-1.10.0}/tests/test_public_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chemparseplot
3
- Version: 1.9.16
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
@@ -65,6 +65,7 @@ Requires-Dist: wheel>=0.40; extra == 'release'
65
65
  Provides-Extra: test
66
66
  Requires-Dist: ase>=3.22; extra == 'test'
67
67
  Requires-Dist: h5py>=3.0; extra == 'test'
68
+ Requires-Dist: jax[cpu]>=0.4; extra == 'test'
68
69
  Requires-Dist: matplotlib>=3.8.2; extra == 'test'
69
70
  Requires-Dist: parsimonious>=0.10.0; extra == 'test'
70
71
  Requires-Dist: polars>=0.20; extra == 'test'
@@ -73,6 +74,7 @@ Requires-Dist: pytest>=7.4.3; extra == 'test'
73
74
  Requires-Dist: readcon>=0.13.1; extra == 'test'
74
75
  Requires-Dist: rgpycrumbs; extra == 'test'
75
76
  Requires-Dist: scipy>=1.11; extra == 'test'
77
+ Requires-Dist: xyzrender>=0.1.3; extra == 'test'
76
78
  Provides-Extra: xyzrender
77
79
  Requires-Dist: xyzrender>=0.1.2; extra == 'xyzrender'
78
80
  Description-Content-Type: text/markdown
@@ -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.16'
22
- __version_tuple__ = version_tuple = (1, 9, 16)
21
+ __version__ = version = '1.10.0'
22
+ __version_tuple__ = version_tuple = (1, 10, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -101,7 +101,6 @@ def parse_orca_final_energy(path_or_text: str):
101
101
  return summary.final_energy
102
102
 
103
103
 
104
-
105
104
  def extract_orca_geomscan_energy(
106
105
  data: str, energy_type: str = "Actual Energy"
107
106
  ) -> tuple[Q_, Q_]:
@@ -138,6 +137,6 @@ def suite_pins() -> dict[str, str]:
138
137
  try:
139
138
  cfg = load_config()
140
139
  pins.update(cfg.merged_package_pins_normalized())
141
- except Exception: # noqa: BLE001 — soft fail for consumers
140
+ except Exception:
142
141
  pass
143
142
  return pins
@@ -22,6 +22,7 @@ HDF5 Layout
22
22
  from typing import Any
23
23
 
24
24
  import numpy as np
25
+
25
26
  from chemparseplot.parse.types import ArrayGroup, ParserAttrs
26
27
 
27
28
 
@@ -0,0 +1,22 @@
1
+ # SPDX-FileCopyrightText: 2023-present Rohit Goswami <rog32@hi.is>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """eOn trajectory parsers.
5
+
6
+ ``con_io`` is always importable (readcon loads on first CON call).
7
+ ``frame_series`` needs the ``neb`` extra (readcon + polars + ase) and is
8
+ lazy so bare ``import chemparseplot.parse`` stays light.
9
+ """
10
+
11
+ from chemparseplot.parse.eon import con_io
12
+
13
+ __all__ = ["con_io", "frame_series"]
14
+
15
+
16
+ def __getattr__(name: str):
17
+ if name == "frame_series":
18
+ from chemparseplot.parse.eon import frame_series as _frame_series
19
+
20
+ return _frame_series
21
+ msg = f"module {__name__!r} has no attribute {name!r}"
22
+ raise AttributeError(msg)
@@ -141,7 +141,10 @@ def frame_rows_to_table(
141
141
  rows: list[dict[str, Any]] = []
142
142
  saw_complete_row = False
143
143
  for frame in frames:
144
- row = {column: coerce_metadata_value(column, metadata_value(frame, column)) for column in columns}
144
+ row = {
145
+ column: coerce_metadata_value(column, metadata_value(frame, column))
146
+ for column in columns
147
+ }
145
148
  if any(value is None for value in row.values()):
146
149
  if saw_complete_row or not allow_leading_incomplete:
147
150
  return pl.DataFrame()
@@ -16,11 +16,10 @@ from pathlib import Path
16
16
  import numpy as np
17
17
  import polars as pl
18
18
  import readcon
19
-
20
- from chemparseplot.parse.eon import con_io
21
19
  from ase import Atoms
22
20
 
23
- from ._trajectory_common import (
21
+ from chemparseplot.parse.eon import con_io
22
+ from chemparseplot.parse.eon._trajectory_common import (
24
23
  frame_rows_to_table,
25
24
  load_movie_and_table,
26
25
  load_optional_payload,
@@ -25,12 +25,10 @@ from chemparseplot.parse.eon._trajectory_common import (
25
25
  metadata_value,
26
26
  )
27
27
  from chemparseplot.parse.eon.dimer_trajectory import (
28
- DIMER_METADATA_COLUMNS,
29
28
  DimerTrajectoryData,
30
29
  table_from_dimer_metadata,
31
30
  )
32
31
  from chemparseplot.parse.eon.min_trajectory import (
33
- MIN_METADATA_COLUMNS,
34
32
  MinTrajectoryData,
35
33
  table_from_min_metadata,
36
34
  )
@@ -121,7 +119,11 @@ def neb_path_arrays(frames: Sequence[Any]) -> dict[str, Any]:
121
119
  ok = True
122
120
  for frame in frames:
123
121
  v = metadata_value(frame, key)
124
- if v is None and key in ("reaction_coordinate", "relative_energy", "parallel_force"):
122
+ if v is None and key in (
123
+ "reaction_coordinate",
124
+ "relative_energy",
125
+ "parallel_force",
126
+ ):
125
127
  # optional NEB stamps
126
128
  ok = False
127
129
  break
@@ -15,14 +15,12 @@ from pathlib import Path
15
15
 
16
16
  import polars as pl
17
17
  import readcon
18
-
19
- from chemparseplot.parse.eon import con_io
20
18
  from ase import Atoms
21
19
 
22
- from ._trajectory_common import (
20
+ from chemparseplot.parse.eon import con_io
21
+ from chemparseplot.parse.eon._trajectory_common import (
23
22
  frame_rows_to_table,
24
23
  load_movie_and_table,
25
- read_first_structure,
26
24
  read_optional_first,
27
25
  )
28
26
 
@@ -369,9 +369,7 @@ def parse_eon_saddle(eresp: Path, rloc: "SpinID") -> "SaddleMeasure":
369
369
  dimer_trans=meth.trans,
370
370
  init_energy=init_energy,
371
371
  barrier=(
372
- results_data.saddle_energy - init_energy
373
- if init_energy is not None
374
- else None
372
+ results_data.saddle_energy - init_energy if init_energy is not None else None
375
373
  ),
376
374
  mol_id=getattr(rloc, "mol_id", None),
377
375
  spin=getattr(rloc, "spin", None),
@@ -30,7 +30,13 @@ __all__ = [
30
30
 
31
31
 
32
32
  def __getattr__(name: str):
33
- if name in ("parse_xyz_text", "parse_xyz_file", "XyzFrame", "XyzAtom", "write_xyz_text"):
33
+ if name in (
34
+ "parse_xyz_text",
35
+ "parse_xyz_file",
36
+ "XyzFrame",
37
+ "XyzAtom",
38
+ "write_xyz_text",
39
+ ):
34
40
  from chemparseplot.parse.grammar import xyz as _xyz
35
41
 
36
42
  return getattr(_xyz, name)
@@ -151,7 +151,7 @@ def extract_final_energies_hartree(text: str) -> list[float]:
151
151
  line = m.group(0).strip()
152
152
  try:
153
153
  tree = g.parse(line)
154
- except Exception: # noqa: BLE001 — skip non-conforming noise
154
+ except Exception:
155
155
  continue
156
156
  v = EnergyVisitor()
157
157
  v.visit(tree)
@@ -192,14 +192,13 @@ def extract_cartesian_angstrom_blocks(text: str) -> list[list[OrcaAtomCoord]]:
192
192
  for m in _CART_BLOCK_RE.finditer(text):
193
193
  chunk = (
194
194
  "CARTESIAN COORDINATES (ANGSTROEM)\n"
195
- "---------------------------------\n"
196
- + m.group(1)
195
+ "---------------------------------\n" + m.group(1)
197
196
  )
198
197
  if not chunk.endswith("\n"):
199
198
  chunk += "\n"
200
199
  try:
201
200
  tree = g.parse(chunk)
202
- except Exception: # noqa: BLE001
201
+ except Exception:
203
202
  continue
204
203
  v = CartVisitor()
205
204
  v.visit(tree)
@@ -29,8 +29,8 @@ _XYZ_GRAMMAR_SRC = r"""
29
29
  meta = natoms newline comment_line newline coord_block ws?
30
30
  natoms = ~r"\d+"
31
31
  comment_line = ~r"[^\n]*"
32
- coord_block = (aline newline?)+
33
- aline = ws? atype ws float_ ws float_ ws float_ ws?
32
+ coord_block = (atom_line newline?)+
33
+ atom_line = ws? atype ws float_ ws float_ ws float_ ws?
34
34
  atype = ~r"[A-Za-z][a-zA-Z0-9]{0,2}"
35
35
  float_ = ~r"[+-]?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?"
36
36
  newline = ~r"\n"
@@ -107,26 +107,29 @@ def _make_visitor() -> Any:
107
107
  self.comment: str = ""
108
108
  self.atoms: list[XyzAtom] = []
109
109
 
110
- def visit_natoms(self, node: Any, visited_children: Any) -> int:
110
+ def visit_natoms(self, node: Any, _visited_children: Any) -> int:
111
111
  self.natoms = int(node.text)
112
112
  return self.natoms
113
113
 
114
- def visit_comment_line(self, node: Any, visited_children: Any) -> str:
114
+ def visit_comment_line(self, node: Any, _visited_children: Any) -> str:
115
115
  self.comment = node.text
116
116
  return self.comment
117
117
 
118
- def visit_aline(self, node: Any, visited_children: Any) -> XyzAtom:
118
+ def visit_atom_line(self, node: Any, visited_children: Any) -> XyzAtom:
119
+ del node # structure from visited_children
119
120
  flat = _flatten(visited_children)
120
- symbol = next(x for x in flat if isinstance(x, str) and x.strip() and x[0].isalpha())
121
+ symbol = next(
122
+ x for x in flat if isinstance(x, str) and x.strip() and x[0].isalpha()
123
+ )
121
124
  nums = [x for x in flat if isinstance(x, float)]
122
125
  atom = XyzAtom(symbol=symbol, x=nums[0], y=nums[1], z=nums[2])
123
126
  self.atoms.append(atom)
124
127
  return atom
125
128
 
126
- def visit_atype(self, node: Any, visited_children: Any) -> str:
129
+ def visit_atype(self, node: Any, _visited_children: Any) -> str:
127
130
  return node.text
128
131
 
129
- def visit_float_(self, node: Any, visited_children: Any) -> float:
132
+ def visit_float_(self, node: Any, _visited_children: Any) -> float:
130
133
  return float(node.text)
131
134
 
132
135
  def generic_visit(self, node: Any, visited_children: Any) -> Any:
@@ -18,13 +18,14 @@ except ImportError:
18
18
 
19
19
  log = logging.getLogger(__name__)
20
20
 
21
+
21
22
  @dataclass(frozen=True, slots=True)
22
23
  class SellaLog:
23
24
  """Typed record for a single Sella geometry-step log entry.
24
25
 
25
- ```{versionadded} 0.0.3
26
- ```
27
- """
26
+ ```{versionadded} 0.0.3
27
+ ```
28
+ """
28
29
 
29
30
  step_id: int
30
31
  time_float: float
@@ -84,7 +84,7 @@ class OrcaNebResult(DataclassMapping):
84
84
 
85
85
  def __post_init__(self) -> None:
86
86
  if self.n_images is None:
87
- object.__setattr__(self, "n_images", int(len(self.energies)))
87
+ object.__setattr__(self, "n_images", len(self.energies))
88
88
 
89
89
  @classmethod
90
90
  def from_mapping(cls, data: Mapping[str, Any]) -> OrcaNebResult:
@@ -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
@@ -1,6 +1,12 @@
1
1
  import matplotlib.pyplot as plt
2
2
 
3
- from chemparseplot.plot.structs import AxisUnits, BasePlotter, EnergyPath, axis_label, to_magnitude
3
+ from chemparseplot.plot.structs import (
4
+ AxisUnits,
5
+ BasePlotter,
6
+ EnergyPath,
7
+ axis_label,
8
+ to_magnitude,
9
+ )
4
10
 
5
11
 
6
12
  def plot_energy_paths(