chemparseplot 1.8.0__tar.gz → 1.9.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 (88) hide show
  1. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/PKG-INFO +4 -4
  2. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/_version.py +2 -2
  3. chemparseplot-1.9.0/chemparseplot/__init__.py +28 -0
  4. {chemparseplot-1.8.0/chemparseplot → chemparseplot-1.9.0/chemparseplot/parse/eon}/__init__.py +3 -2
  5. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/eon/_trajectory_common.py +7 -6
  6. chemparseplot-1.9.0/chemparseplot/parse/eon/con_io.py +136 -0
  7. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/eon/dimer_trajectory.py +3 -1
  8. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/eon/min_trajectory.py +3 -1
  9. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/eon/stitch.py +29 -28
  10. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/plot/optimization.py +158 -0
  11. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/plot/structs.py +24 -3
  12. chemparseplot-1.9.0/chemparseplot/units.py +129 -0
  13. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/util.py +2 -1
  14. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/pyproject.toml +4 -2
  15. chemparseplot-1.9.0/tests/parse/__init__.py +0 -0
  16. chemparseplot-1.9.0/tests/parse/_con_fixtures.py +89 -0
  17. chemparseplot-1.9.0/tests/parse/test_con_io.py +237 -0
  18. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/parse/test_dimer_trajectory.py +3 -3
  19. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/parse/test_eon_trajectory_common.py +73 -0
  20. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/parse/test_min_trajectory.py +3 -3
  21. chemparseplot-1.9.0/tests/parse/test_neb_stitch.py +167 -0
  22. chemparseplot-1.9.0/tests/parse/test_readcon_boundary.py +78 -0
  23. chemparseplot-1.9.0/tests/plot/__init__.py +0 -0
  24. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/plot/test_optimization.py +43 -0
  25. chemparseplot-1.9.0/tests/plot/test_structs_units.py +48 -0
  26. chemparseplot-1.8.0/chemparseplot/parse/eon/__init__.py +0 -1
  27. chemparseplot-1.8.0/chemparseplot/units.py +0 -23
  28. chemparseplot-1.8.0/tests/parse/test_neb_stitch.py +0 -70
  29. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/.gitignore +0 -0
  30. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/LICENSE +0 -0
  31. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/__init__.py +0 -0
  32. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/chemgp_hdf5.py +0 -0
  33. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/chemgp_jsonl.py +0 -0
  34. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/converter.py +0 -0
  35. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/eon/gprd.py +0 -0
  36. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/eon/minimization.py +0 -0
  37. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/eon/neb.py +0 -0
  38. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/eon/saddle_search.py +0 -0
  39. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/file_.py +0 -0
  40. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/neb_utils.py +0 -0
  41. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/orca/__init__.py +0 -0
  42. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/orca/geomscan.py +0 -0
  43. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/orca/neb/__init__.py +0 -0
  44. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/orca/neb/interp.py +0 -0
  45. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/orca/neb/opi_parser.py +0 -0
  46. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/patterns.py +0 -0
  47. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/plumed.py +0 -0
  48. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/projection.py +0 -0
  49. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/sella/saddle_search.py +0 -0
  50. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/trajectory/__init__.py +0 -0
  51. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/trajectory/hdf5.py +0 -0
  52. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/trajectory/neb.py +0 -0
  53. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/parse/types.py +0 -0
  54. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/plot/__init__.py +0 -0
  55. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/plot/chemgp.py +0 -0
  56. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/plot/geomscan.py +0 -0
  57. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/plot/neb.py +0 -0
  58. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/plot/plumed.py +0 -0
  59. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/chemparseplot/plot/theme.py +0 -0
  60. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/readme.md +0 -0
  61. {chemparseplot-1.8.0/tests/plot → chemparseplot-1.9.0/tests}/__init__.py +0 -0
  62. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/_optional_imports.py +0 -0
  63. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/conftest.py +0 -0
  64. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/parse/orca/test_geomscan.py +0 -0
  65. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/parse/orca/test_interp.py +0 -0
  66. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/parse/test_chemgp_hdf5.py +0 -0
  67. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/parse/test_chemgp_jsonl_types.py +0 -0
  68. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/parse/test_converter.py +0 -0
  69. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/parse/test_eon_neb_types.py +0 -0
  70. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/parse/test_minimization_result.py +0 -0
  71. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/parse/test_neb_utils.py +0 -0
  72. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/parse/test_parser_types.py +0 -0
  73. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/parse/test_patterns.py +0 -0
  74. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/parse/test_plumed.py +0 -0
  75. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/parse/test_projection.py +0 -0
  76. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/parse/test_saddle_results.py +0 -0
  77. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/parse/test_sella_saddle.py +0 -0
  78. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/parse/test_trajectory_hdf5.py +0 -0
  79. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/parse/test_trajectory_neb.py +0 -0
  80. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/plot/test_chemgp_utils.py +0 -0
  81. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/plot/test_neb_helpers.py +0 -0
  82. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/plot/test_neb_renderers.py +0 -0
  83. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/plot/test_orca_neb_payload.py +0 -0
  84. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/plot/test_projection_refactor.py +0 -0
  85. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/plot/test_strip_rendering.py +0 -0
  86. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/test_coverage_batch.py +0 -0
  87. {chemparseplot-1.8.0 → chemparseplot-1.9.0}/tests/test_full_coverage.py +0 -0
  88. {chemparseplot-1.8.0 → chemparseplot-1.9.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.8.0
3
+ Version: 1.9.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
@@ -26,7 +26,7 @@ Requires-Dist: cmcrameri>=1.7; extra == 'all'
26
26
  Requires-Dist: h5py>=3.0; extra == 'all'
27
27
  Requires-Dist: matplotlib>=3.8.2; extra == 'all'
28
28
  Requires-Dist: polars>=0.20; extra == 'all'
29
- Requires-Dist: readcon>=0.7.0; extra == 'all'
29
+ Requires-Dist: readcon>=0.13.1; extra == 'all'
30
30
  Requires-Dist: xyzrender>=0.1.2; extra == 'all'
31
31
  Provides-Extra: doc
32
32
  Requires-Dist: mdit-py-plugins>=0.3.4; extra == 'doc'
@@ -45,7 +45,7 @@ Provides-Extra: neb
45
45
  Requires-Dist: ase>=3.22; extra == 'neb'
46
46
  Requires-Dist: h5py>=3.0; extra == 'neb'
47
47
  Requires-Dist: polars>=0.20; extra == 'neb'
48
- Requires-Dist: readcon>=0.7.0; extra == 'neb'
48
+ Requires-Dist: readcon>=0.13.1; extra == 'neb'
49
49
  Provides-Extra: plot
50
50
  Requires-Dist: cmcrameri>=1.7; extra == 'plot'
51
51
  Requires-Dist: matplotlib>=3.8.2; extra == 'plot'
@@ -61,7 +61,7 @@ Requires-Dist: matplotlib>=3.8.2; extra == 'test'
61
61
  Requires-Dist: polars>=0.20; extra == 'test'
62
62
  Requires-Dist: pytest-cov>=4.1.0; extra == 'test'
63
63
  Requires-Dist: pytest>=7.4.3; extra == 'test'
64
- Requires-Dist: readcon>=0.7.0; extra == 'test'
64
+ Requires-Dist: readcon>=0.13.1; extra == 'test'
65
65
  Requires-Dist: rgpycrumbs; extra == 'test'
66
66
  Requires-Dist: scipy>=1.11; extra == 'test'
67
67
  Provides-Extra: xyzrender
@@ -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.8.0'
22
- __version_tuple__ = version_tuple = (1, 8, 0)
21
+ __version__ = version = '1.9.0'
22
+ __version_tuple__ = version_tuple = (1, 9, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -0,0 +1,28 @@
1
+ # SPDX-FileCopyrightText: 2023-present Rohit Goswami <rog32@hi.is>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """chemparseplot public surface with deferred heavy submodules.
5
+
6
+ ``parse`` and ``units`` remain attributes; they load via importlib on first
7
+ access so a bare ``import chemparseplot`` does not construct the pint registry
8
+ or pull the full parse package graph until needed.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import importlib
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ __all__ = ["parse", "units"]
17
+
18
+ if TYPE_CHECKING:
19
+ from chemparseplot import parse as parse
20
+ from chemparseplot import units as units
21
+
22
+
23
+ def __getattr__(name: str) -> Any:
24
+ if name == "parse":
25
+ return importlib.import_module("chemparseplot.parse")
26
+ if name == "units":
27
+ return importlib.import_module("chemparseplot.units")
28
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -1,7 +1,8 @@
1
1
  # SPDX-FileCopyrightText: 2023-present Rohit Goswami <rog32@hi.is>
2
2
  #
3
3
  # SPDX-License-Identifier: MIT
4
+ """eOn trajectory parsers."""
4
5
 
5
- from chemparseplot import parse, units
6
+ from chemparseplot.parse.eon import con_io
6
7
 
7
- __all__ = ["parse", "units"]
8
+ __all__ = ["con_io"]
@@ -8,9 +8,10 @@ from pathlib import Path
8
8
  from typing import Any, TypeVar
9
9
 
10
10
  import polars as pl
11
- import readcon
12
11
  from ase import Atoms
13
12
 
13
+ from chemparseplot.parse.eon import con_io
14
+
14
15
  log = logging.getLogger(__name__)
15
16
 
16
17
  T = TypeVar("T")
@@ -67,7 +68,7 @@ def require_dat_file(job_dir: Path, name: str) -> Path:
67
68
  def read_first_structure(path: Path) -> Atoms:
68
69
  """Read the first structure from an eOn CON file."""
69
70
 
70
- return readcon.read_con_as_ase(str(path))[0]
71
+ return con_io.read_first_atoms(path)
71
72
 
72
73
 
73
74
  def read_optional_first(job_dir: Path, names: Sequence[str]) -> Atoms | None:
@@ -87,14 +88,14 @@ def load_movie_and_table(
87
88
  dat_name: str,
88
89
  parse_dat: Callable[[Path], pl.DataFrame],
89
90
  metadata_columns: Sequence[str],
90
- build_table_from_metadata: Callable[[Sequence[readcon.ConFrame]], pl.DataFrame],
91
+ build_table_from_metadata: Callable[[Sequence[object]], pl.DataFrame],
91
92
  log_label: str,
92
93
  ) -> tuple[list[Atoms], pl.DataFrame]:
93
94
  """Load the shared movie/data payload for an eOn trajectory parser."""
94
95
 
95
96
  movie_file = resolve_movie_file(job_dir, movie_stem)
96
97
  log.info("Loading %s trajectory from %s", log_label, job_dir)
97
- frames = readcon.read_con(str(movie_file))
98
+ frames = con_io.read_con_frames(movie_file)
98
99
  atoms_list = [frame.to_ase() for frame in frames]
99
100
 
100
101
  dat_df = build_table_from_metadata(frames)
@@ -119,7 +120,7 @@ def load_movie_and_table(
119
120
  return atoms_list, dat_df
120
121
 
121
122
 
122
- def metadata_value(frame: readcon.ConFrame, key: str) -> Any:
123
+ def metadata_value(frame: object, key: str) -> Any:
123
124
  """Return a typed metadata value from a readcon frame."""
124
125
 
125
126
  if key == "frame_index":
@@ -130,7 +131,7 @@ def metadata_value(frame: readcon.ConFrame, key: str) -> Any:
130
131
 
131
132
 
132
133
  def frame_rows_to_table(
133
- frames: Sequence[readcon.ConFrame],
134
+ frames: Sequence[object],
134
135
  columns: Sequence[str],
135
136
  *,
136
137
  allow_leading_incomplete: bool = False,
@@ -0,0 +1,136 @@
1
+ # SPDX-FileCopyrightText: 2023-present Rohit Goswami <rog32@hi.is>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """Public readcon-backed CON/convel helpers for eOn parsers.
5
+
6
+ All ``.con`` / ``.convel`` I/O in chemparseplot should go through this module so
7
+ metadata-native energies (``readcon>=0.7``) stay on one code path. ``readcon``
8
+ remains an optional extra (``chemparseplot[neb]``); import errors surface only
9
+ when these helpers are called.
10
+
11
+ ```{versionadded} 1.8.1
12
+ ```
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Sequence
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ __all__ = [
22
+ "CON_SUFFIXES",
23
+ "frame_with_energy",
24
+ "is_con_path",
25
+ "read_con_as_ase",
26
+ "read_con_frames",
27
+ "read_first_atoms",
28
+ "write_atoms_as_con",
29
+ "write_con_frames",
30
+ ]
31
+
32
+ CON_SUFFIXES = (".con", ".convel")
33
+
34
+
35
+ def is_con_path(path: str | Path) -> bool:
36
+ """Return True when *path* looks like an eOn CON/convel file."""
37
+ return Path(path).suffix.lower() in CON_SUFFIXES
38
+
39
+
40
+ def _readcon():
41
+ try:
42
+ import readcon
43
+ except ImportError as exc: # pragma: no cover - exercised when extra missing
44
+ msg = (
45
+ "readcon is required for CON I/O. Install with: "
46
+ "pip install 'chemparseplot[neb]' (readcon>=0.7.0)"
47
+ )
48
+ raise ImportError(msg) from exc
49
+ return readcon
50
+
51
+
52
+ def read_con_frames(path: str | Path) -> list[Any]:
53
+ """Read all frames from a CON/convel file as ``readcon.ConFrame`` objects."""
54
+ readcon = _readcon()
55
+ return list(readcon.read_con(str(path)))
56
+
57
+
58
+ def read_con_as_ase(path: str | Path) -> list:
59
+ """Read all frames from a CON/convel file as ASE ``Atoms`` objects."""
60
+ readcon = _readcon()
61
+ return list(readcon.read_con_as_ase(str(path)))
62
+
63
+
64
+ def read_first_atoms(path: str | Path):
65
+ """Read the first frame of a CON file as an ASE ``Atoms`` object."""
66
+ return read_con_as_ase(path)[0]
67
+
68
+
69
+ def frame_with_energy(frame: Any, energy: float) -> Any:
70
+ """Return a ``ConFrame`` copy with per-frame total ``energy`` set (eV).
71
+
72
+ Uses ``ConFrame.set_energy`` when available (readcon>=0.7) so metadata stays
73
+ schema-valid; falls back to reconstructing the frame with string metadata.
74
+ """
75
+ readcon = _readcon()
76
+ ConFrame = readcon.ConFrame
77
+ metadata = frame.metadata
78
+ if hasattr(metadata, "items"):
79
+ metadata_dict = {str(k): v for k, v in metadata.items()}
80
+ else:
81
+ metadata_dict = dict(metadata or {})
82
+ clone = ConFrame(
83
+ frame.cell,
84
+ frame.angles,
85
+ frame.atoms,
86
+ frame.prebox_header,
87
+ frame.postbox_header,
88
+ metadata_dict,
89
+ )
90
+ value = float(energy)
91
+ setter = getattr(clone, "set_energy", None)
92
+ if callable(setter):
93
+ setter(value)
94
+ return clone
95
+ scalar = getattr(clone, "set_scalar_metadata", None)
96
+ if callable(scalar):
97
+ scalar("energy", value)
98
+ return clone
99
+ metadata_dict["energy"] = str(value)
100
+ return ConFrame(
101
+ frame.cell,
102
+ frame.angles,
103
+ frame.atoms,
104
+ frame.prebox_header,
105
+ frame.postbox_header,
106
+ metadata_dict,
107
+ )
108
+
109
+
110
+ def write_con_frames(path: str | Path, frames: Sequence[Any]) -> Path:
111
+ """Write ``ConFrame`` objects to *path* via ``readcon.write_con``."""
112
+ readcon = _readcon()
113
+ out = Path(path)
114
+ out.parent.mkdir(parents=True, exist_ok=True)
115
+ readcon.write_con(str(out), list(frames))
116
+ return out
117
+
118
+
119
+ def write_atoms_as_con(
120
+ path: str | Path,
121
+ atoms_list: Sequence[Any],
122
+ *,
123
+ energies: Sequence[float | None] | None = None,
124
+ ) -> Path:
125
+ """Convert ASE ``Atoms`` to ``ConFrame`` and write a CON file.
126
+
127
+ Optional *energies* (eV) are attached per frame with :func:`frame_with_energy`.
128
+ """
129
+ readcon = _readcon()
130
+ frames = []
131
+ for idx, atoms in enumerate(atoms_list):
132
+ frame = readcon.ConFrame.from_ase(atoms)
133
+ if energies is not None and idx < len(energies) and energies[idx] is not None:
134
+ frame = frame_with_energy(frame, float(energies[idx]))
135
+ frames.append(frame)
136
+ return write_con_frames(path, frames)
@@ -16,6 +16,8 @@ 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
19
21
  from ase import Atoms
20
22
 
21
23
  from ._trajectory_common import (
@@ -95,7 +97,7 @@ def parse_climb_con(path: Path) -> list[Atoms]:
95
97
  List of ASE Atoms objects, one per iteration.
96
98
  """
97
99
  # eOn .con files may not have a .con extension for movie files
98
- return readcon.read_con_as_ase(str(path))
100
+ return con_io.read_con_as_ase(path)
99
101
 
100
102
 
101
103
  def _load_mode_dat(path: Path) -> np.ndarray | None:
@@ -15,6 +15,8 @@ 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
18
20
  from ase import Atoms
19
21
 
20
22
  from ._trajectory_common import (
@@ -80,7 +82,7 @@ def parse_min_con(path: Path) -> list[Atoms]:
80
82
  list[Atoms]
81
83
  List of ASE Atoms objects, one per iteration.
82
84
  """
83
- return readcon.read_con_as_ase(str(path))
85
+ return con_io.read_con_as_ase(path)
84
86
 
85
87
 
86
88
  def table_from_min_metadata(frames: list[readcon.ConFrame]) -> pl.DataFrame:
@@ -37,19 +37,10 @@ log = logging.getLogger(__name__)
37
37
 
38
38
 
39
39
  def _frame_with_energy(frame, energy: float):
40
- """Return a ConFrame copy with ``energy`` set in metadata (readcon is read-only)."""
41
- from readcon import ConFrame
42
-
43
- metadata = {str(k): str(v) for k, v in (frame.metadata or {}).items()}
44
- metadata["energy"] = str(float(energy))
45
- return ConFrame(
46
- frame.cell,
47
- frame.angles,
48
- frame.atoms,
49
- frame.prebox_header,
50
- frame.postbox_header,
51
- metadata,
52
- )
40
+ """Return a ConFrame copy with ``energy`` set via :mod:`con_io`."""
41
+ from chemparseplot.parse.eon.con_io import frame_with_energy
42
+
43
+ return frame_with_energy(frame, energy)
53
44
 
54
45
 
55
46
  @dataclass(frozen=True, slots=True)
@@ -90,11 +81,20 @@ def _cartesian_rmsd(atoms_a, atoms_b) -> float:
90
81
 
91
82
 
92
83
  def _cumulative_rmsd(atoms_list) -> list[float]:
93
- """Cumulative consecutive-frame RMSD, starting at 0 for the first frame."""
94
- coords = [0.0]
95
- for prev, cur in zip(atoms_list[:-1], atoms_list[1:], strict=True):
96
- coords.append(coords[-1] + _cartesian_rmsd(prev, cur))
97
- return coords
84
+ """Cumulative consecutive-frame RMSD, starting at 0 for the first frame.
85
+
86
+ Stacks positions once and uses vectorized pairwise steps instead of a
87
+ Python loop over ASE frames (hot path for multi-segment stitch bands).
88
+ """
89
+ if not atoms_list:
90
+ return []
91
+ if len(atoms_list) == 1:
92
+ return [0.0]
93
+ # Materialize positions in one array: (n_frames, n_atoms, 3)
94
+ pos = np.stack([np.asarray(a.get_positions(), dtype=float) for a in atoms_list])
95
+ deltas = pos[1:] - pos[:-1]
96
+ step = np.sqrt(np.mean(np.sum(deltas * deltas, axis=2), axis=1))
97
+ return [0.0, *np.cumsum(step).tolist()]
98
98
 
99
99
 
100
100
  def stitch_neb_segments(
@@ -129,7 +129,11 @@ def stitch_neb_segments(
129
129
  StitchSummary
130
130
  Boundary indices, per-segment barriers, and the overall highest point.
131
131
  """
132
- from readcon import read_con, write_con
132
+ from chemparseplot.parse.eon.con_io import (
133
+ read_con_frames,
134
+ write_atoms_as_con,
135
+ write_con_frames,
136
+ )
133
137
 
134
138
  saddle_overrides = saddle_overrides or {}
135
139
  out_dir = Path(out_dir)
@@ -144,7 +148,7 @@ def stitch_neb_segments(
144
148
  sp_best: tuple[float, object] | None = None # (referenced energy, saddle atoms)
145
149
 
146
150
  for seg_idx, (label, con_path, start, end) in enumerate(segments):
147
- frames = read_con(str(con_path))
151
+ frames = read_con_frames(con_path)
148
152
  seg_frames = list(frames[start:end])
149
153
  if not seg_frames:
150
154
  msg = f"Segment '{label}' ({con_path}) sliced to zero frames."
@@ -181,7 +185,7 @@ def stitch_neb_segments(
181
185
  if label in saddle_overrides:
182
186
  sad_path, sad_abs = saddle_overrides[label]
183
187
  peak_energy = float(sad_abs) + shift - ref
184
- sad_atoms = read_con(str(sad_path))[0].to_ase()
188
+ sad_atoms = read_con_frames(sad_path)[0].to_ase()
185
189
  if sp_best is None or peak_energy > sp_best[0]:
186
190
  sp_best = (peak_energy, sad_atoms)
187
191
  else:
@@ -199,8 +203,8 @@ def stitch_neb_segments(
199
203
  )
200
204
 
201
205
  # --- Write the combined band ---
202
- write_con(str(out_dir / "neb.con"), out_frames)
203
- write_con(str(out_dir / "neb_path_000.con"), out_frames)
206
+ write_con_frames(out_dir / "neb.con", out_frames)
207
+ write_con_frames(out_dir / "neb_path_000.con", out_frames)
204
208
 
205
209
  atoms_list = [f.to_ase() for f in out_frames]
206
210
  rxn_coord = _cumulative_rmsd(atoms_list)
@@ -214,14 +218,11 @@ def stitch_neb_segments(
214
218
 
215
219
  # --- Saddle overlay (sp.con) ---
216
220
  if sp_best is not None:
217
- from readcon import ConFrame
218
-
219
- sp_frame = _frame_with_energy(ConFrame.from_ase(sp_best[1]), float(sp_best[0]))
220
- write_con(str(out_dir / "sp.con"), [sp_frame])
221
+ write_atoms_as_con(out_dir / "sp.con", [sp_best[1]], energies=[float(sp_best[0])])
221
222
  else:
222
223
  # Fallback: the global band maximum so the landscape overlay still works.
223
224
  peak_idx = int(np.argmax(energies))
224
- write_con(str(out_dir / "sp.con"), [out_frames[peak_idx]])
225
+ write_con_frames(out_dir / "sp.con", [out_frames[peak_idx]])
225
226
 
226
227
  highest_index = int(np.argmax(energies))
227
228
  summary = StitchSummary(
@@ -14,6 +14,7 @@ The key semantic difference from NEB plots:
14
14
  from __future__ import annotations
15
15
 
16
16
  import logging
17
+ from collections.abc import Sequence
17
18
  from pathlib import Path
18
19
  from typing import Any
19
20
 
@@ -298,6 +299,163 @@ def plot_single_ended_convergence(trajs, labels, output: Path, dpi: int) -> None
298
299
  save_standard_figure(fig, output, dpi=dpi)
299
300
 
300
301
 
302
+ def render_single_ended_landscape(
303
+ *,
304
+ atoms_list: Sequence[Any],
305
+ energies_eV: np.ndarray,
306
+ ref_a: Any,
307
+ ref_b: Any,
308
+ overlay_atom_lists: Sequence[Sequence[Any]] | None = None,
309
+ overlay_labels: Sequence[str] | None = None,
310
+ ira_instance: Any | None = None,
311
+ ira_kmax: float = 14.0,
312
+ project_path: bool = True,
313
+ surface_type: str = "grad_matern",
314
+ energy_unit: str = "eV",
315
+ energy_cap: float | None = None,
316
+ energy_cap_window: float | None = None,
317
+ cmap: str = "viridis",
318
+ output: Path,
319
+ dpi: int = 150,
320
+ theme: Any | None = None,
321
+ plot_structures: str = "none",
322
+ strip_structs: Sequence[Any] | None = None,
323
+ strip_labels: Sequence[str] | None = None,
324
+ endpoint_start_label: str = "Init",
325
+ endpoint_end_label: str = "Min",
326
+ endpoint_boxed: bool = True,
327
+ annotate_overlay_starts: bool = False,
328
+ overlay_start_label: str = "R",
329
+ strip_renderer: str = "xyzrender",
330
+ xyzrender_config: str = "paton",
331
+ strip_spacing: float = 1.5,
332
+ strip_zoom: float | None = None,
333
+ strip_dividers: bool = False,
334
+ rotation: str = "auto",
335
+ perspective_tilt: float = 0.0,
336
+ ) -> None:
337
+ """Full single-ended landscape pipeline shared by min/saddle CLIs.
338
+
339
+ Coordinates use :func:`~chemparseplot.parse.neb_utils.calculate_landscape_coords`
340
+ and synthetic gradients; the surface/path overlay uses
341
+ :func:`plot_optimization_landscape`. Energies are passed in **eV** and
342
+ converted once for display (avoids double conversion when ``energy_unit``
343
+ is not eV).
344
+
345
+ ```{versionadded} 1.8.1
346
+ ```
347
+ """
348
+ from chemparseplot.parse.neb_utils import (
349
+ calculate_landscape_coords,
350
+ compute_synthetic_gradients,
351
+ )
352
+
353
+ has_strip = plot_structures == "endpoints" and strip_structs is not None
354
+ fig, ax, ax_strip = create_landscape_axes(dpi=dpi, has_strip=has_strip, theme=theme)
355
+
356
+ rmsd_a, rmsd_b = calculate_landscape_coords(
357
+ atoms_list,
358
+ ira_instance,
359
+ ira_kmax,
360
+ ref_a=ref_a,
361
+ ref_b=ref_b,
362
+ )
363
+ energies = np.asarray(energies_eV, dtype=float)
364
+ n = min(len(rmsd_a), len(energies))
365
+ rmsd_a, rmsd_b, energies = rmsd_a[:n], rmsd_b[:n], energies[:n]
366
+ display_e = convert_energy(energies, energy_unit)
367
+ cap = energy_cap
368
+ if cap is None and energy_cap_window is not None:
369
+ cap = float(np.min(display_e)) + energy_cap_window
370
+ if cap is not None:
371
+ # Cap in display units, map back to eV for the surface routine which
372
+ # converts from eV once.
373
+ factor = float(convert_energy(np.array([1.0]), energy_unit)[0])
374
+ energies = np.minimum(energies, cap / factor if factor else cap)
375
+ display_e = convert_energy(energies, energy_unit)
376
+
377
+ f_para = -np.gradient(energies)
378
+ grad_a, grad_b = compute_synthetic_gradients(rmsd_a, rmsd_b, f_para)
379
+
380
+ plot_optimization_landscape(
381
+ ax,
382
+ rmsd_a,
383
+ rmsd_b,
384
+ grad_a,
385
+ grad_b,
386
+ energies,
387
+ project_path=project_path,
388
+ method=surface_type,
389
+ cmap=cmap,
390
+ label_mode="optimization",
391
+ energy_unit=energy_unit,
392
+ )
393
+
394
+ basis = None
395
+ if project_path:
396
+ _, _, basis = project_landscape_path(rmsd_a, rmsd_b, project_path=True)
397
+
398
+ overlays = overlay_atom_lists or ()
399
+ labels = list(overlay_labels or ())
400
+ for idx, atoms_ov in enumerate(overlays):
401
+ lbl = labels[idx] if idx < len(labels) else f"path{idx}"
402
+ ra, rb = calculate_landscape_coords(
403
+ atoms_ov,
404
+ ira_instance,
405
+ ira_kmax,
406
+ ref_a=ref_a,
407
+ ref_b=ref_b,
408
+ )
409
+ m = min(len(ra), len(atoms_ov))
410
+ ra, rb = ra[:m], rb[:m]
411
+ px, py, _ = project_landscape_path(ra, rb, project_path=project_path, basis=basis)
412
+ color = OVERLAY_COLORS[idx % len(OVERLAY_COLORS)]
413
+ if len(overlays) > 1:
414
+ ax.plot(
415
+ px,
416
+ py,
417
+ "o-",
418
+ color=color,
419
+ markersize=3,
420
+ linewidth=1.5,
421
+ alpha=0.8,
422
+ zorder=55,
423
+ label=lbl,
424
+ )
425
+ if annotate_overlay_starts and len(px):
426
+ annotate_endpoint(ax, float(px[0]), float(py[0]), overlay_start_label, boxed=False)
427
+
428
+ plot_x, plot_y, _ = project_landscape_path(
429
+ rmsd_a, rmsd_b, project_path=project_path, basis=basis
430
+ )
431
+ annotate_endpoint(
432
+ ax, float(plot_x[0]), float(plot_y[0]), endpoint_start_label, boxed=endpoint_boxed
433
+ )
434
+ annotate_endpoint(
435
+ ax, float(plot_x[-1]), float(plot_y[-1]), endpoint_end_label, boxed=endpoint_boxed
436
+ )
437
+
438
+ if overlays and len(overlays) > 1:
439
+ ax.legend(frameon=True, framealpha=0.9, loc="best")
440
+
441
+ if has_strip and ax_strip is not None and strip_structs is not None:
442
+ render_endpoint_strip(
443
+ ax_strip,
444
+ list(strip_structs),
445
+ list(strip_labels or []),
446
+ strip_zoom=strip_zoom,
447
+ rotation=rotation,
448
+ theme=theme,
449
+ strip_renderer=strip_renderer,
450
+ strip_spacing=strip_spacing,
451
+ strip_dividers=strip_dividers,
452
+ perspective_tilt=perspective_tilt,
453
+ xyzrender_config=xyzrender_config,
454
+ )
455
+
456
+ save_landscape_figure(fig, output, dpi=dpi, has_strip=has_strip, ax=ax, ax_strip=ax_strip)
457
+
458
+
301
459
  def plot_optimization_landscape(
302
460
  ax,
303
461
  rmsd_a: np.ndarray,
@@ -44,10 +44,31 @@ def axis_label(label: str, unit: str) -> str:
44
44
 
45
45
 
46
46
  def convert_energy(values: Any, unit: str, *, source_unit: str = "eV") -> np.ndarray:
47
- """Convert energy-like values between supported presentation units."""
47
+ """Convert energy-like values between supported presentation units.
48
48
 
49
- factor = _ENERGY_FACTORS[unit] / _ENERGY_FACTORS[source_unit]
50
- return np.asarray(values, dtype=float) * factor
49
+ Delegates to :func:`chemparseplot.units.convert_energy_magnitude` (pint-backed).
50
+ Accepts plain arrays or pint ``Quantity`` instances (converted via ``.to``).
51
+ """
52
+ from chemparseplot.units import convert_energy_magnitude, normalize_energy_unit
53
+
54
+ # Pint Quantity with a compatible unit → magnitude in target presentation unit.
55
+ to_method = getattr(values, "to", None)
56
+ magnitude = getattr(values, "magnitude", None)
57
+ units = getattr(values, "units", None)
58
+ if callable(to_method) and magnitude is not None and units is not None:
59
+ try:
60
+ # Prefer chemical presentation units when already tagged.
61
+ unit_s = str(units)
62
+ if "chem_" in unit_s or "chem_eV" in unit_s:
63
+ return convert_energy_magnitude(magnitude, unit, source_unit=source_unit)
64
+ # Generic energy Quantity: convert to eV then to presentation unit.
65
+ in_eV = np.asarray(values.to("eV").magnitude, dtype=float)
66
+ return convert_energy_magnitude(in_eV, unit, source_unit="eV")
67
+ except Exception:
68
+ # Fall through to array path.
69
+ values = magnitude
70
+
71
+ return convert_energy_magnitude(values, unit, source_unit=source_unit)
51
72
 
52
73
 
53
74
  def convert_energy_curvature(