chemparseplot 1.4.2__tar.gz → 1.5.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 (64) hide show
  1. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/PKG-INFO +1 -1
  2. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/_version.py +2 -2
  3. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/parse/__init__.py +2 -2
  4. chemparseplot-1.5.0/chemparseplot/parse/eon/__init__.py +1 -0
  5. chemparseplot-1.5.0/chemparseplot/parse/eon/dimer_trajectory.py +154 -0
  6. chemparseplot-1.5.0/chemparseplot/parse/eon/min_trajectory.py +133 -0
  7. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/parse/neb_utils.py +27 -12
  8. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/parse/plumed.py +10 -0
  9. chemparseplot-1.5.0/chemparseplot/parse/projection.py +149 -0
  10. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/plot/__init__.py +4 -11
  11. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/plot/neb.py +529 -88
  12. chemparseplot-1.5.0/chemparseplot/plot/optimization.py +251 -0
  13. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/plot/structs.py +1 -1
  14. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/pyproject.toml +13 -3
  15. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/tests/parse/test_chemgp_hdf5.py +6 -4
  16. chemparseplot-1.5.0/tests/parse/test_dimer_trajectory.py +166 -0
  17. chemparseplot-1.5.0/tests/parse/test_min_trajectory.py +146 -0
  18. chemparseplot-1.5.0/tests/parse/test_projection.py +148 -0
  19. chemparseplot-1.5.0/tests/plot/test_optimization.py +220 -0
  20. chemparseplot-1.5.0/tests/plot/test_projection_refactor.py +119 -0
  21. chemparseplot-1.5.0/tests/plot/test_strip_rendering.py +178 -0
  22. chemparseplot-1.5.0/tests/test_coverage_batch.py +463 -0
  23. chemparseplot-1.5.0/tests/test_full_coverage.py +1683 -0
  24. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/.gitignore +0 -0
  25. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/LICENSE +0 -0
  26. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/__init__.py +0 -0
  27. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/parse/chemgp_hdf5.py +0 -0
  28. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/parse/chemgp_jsonl.py +0 -0
  29. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/parse/converter.py +0 -0
  30. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/parse/eon/gprd.py +0 -0
  31. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/parse/eon/minimization.py +0 -0
  32. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/parse/eon/neb.py +0 -0
  33. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/parse/eon/saddle_search.py +0 -0
  34. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/parse/file_.py +0 -0
  35. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/parse/orca/__init__.py +0 -0
  36. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/parse/orca/geomscan.py +0 -0
  37. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/parse/orca/neb/__init__.py +0 -0
  38. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/parse/orca/neb/interp.py +0 -0
  39. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/parse/orca/neb/opi_parser.py +0 -0
  40. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/parse/patterns.py +0 -0
  41. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/parse/sella/saddle_search.py +0 -0
  42. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/parse/trajectory/__init__.py +0 -0
  43. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/parse/trajectory/hdf5.py +0 -0
  44. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/parse/trajectory/neb.py +0 -0
  45. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/plot/chemgp.py +0 -0
  46. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/plot/geomscan.py +0 -0
  47. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/plot/plumed.py +0 -0
  48. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/plot/theme.py +0 -0
  49. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/units.py +0 -0
  50. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/chemparseplot/util.py +0 -0
  51. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/readme.md +0 -0
  52. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/tests/conftest.py +0 -0
  53. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/tests/parse/orca/test_geomscan.py +0 -0
  54. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/tests/parse/orca/test_interp.py +0 -0
  55. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/tests/parse/test_converter.py +0 -0
  56. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/tests/parse/test_neb_utils.py +0 -0
  57. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/tests/parse/test_patterns.py +0 -0
  58. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/tests/parse/test_plumed.py +0 -0
  59. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/tests/parse/test_trajectory_hdf5.py +0 -0
  60. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/tests/parse/test_trajectory_neb.py +0 -0
  61. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/tests/plot/__init__.py +0 -0
  62. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/tests/plot/test_chemgp_utils.py +0 -0
  63. {chemparseplot-1.4.2 → chemparseplot-1.5.0}/tests/plot/test_neb_renderers.py +0 -0
  64. {chemparseplot-1.4.2 → chemparseplot-1.5.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.4.2
3
+ Version: 1.5.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
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '1.4.2'
32
- __version_tuple__ = version_tuple = (1, 4, 2)
31
+ __version__ = version = '1.5.0'
32
+ __version_tuple__ = version_tuple = (1, 5, 0)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -2,8 +2,8 @@
2
2
  #
3
3
  # SPDX-License-Identifier: MIT
4
4
 
5
- from chemparseplot.parse import orca, patterns
5
+ from chemparseplot.parse import eon, orca, patterns
6
6
 
7
7
  # Lazy imports for modules with optional heavy deps (h5py, pandas)
8
8
  # Import directly: from chemparseplot.parse.chemgp_hdf5 import read_h5_table
9
- # Or: from chemparseplot.parse import plumed
9
+ # Or: from chemparseplot.parse import plumed, projection
@@ -0,0 +1 @@
1
+ """eOn trajectory parsers."""
@@ -0,0 +1,154 @@
1
+ """Dimer/saddle search trajectory parser for eOn output.
2
+
3
+ Reads structured per-iteration data from ``climb.dat`` (TSV) and
4
+ concatenated trajectory from ``climb.con`` (movie file), as produced
5
+ by eOn with ``write_movies=true``.
6
+
7
+ .. versionadded:: 1.5.0
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ import numpy as np
17
+ import polars as pl
18
+ from ase import Atoms
19
+ from ase.io import read as ase_read
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+
24
+ @dataclass
25
+ class DimerTrajectoryData:
26
+ """Container for a dimer/saddle search trajectory.
27
+
28
+ Attributes
29
+ ----------
30
+ atoms_list
31
+ Per-iteration structures from the movie file.
32
+ dat_df
33
+ Polars DataFrame with per-iteration metrics from ``climb.dat``.
34
+ initial_atoms
35
+ Starting structure (from ``reactant.con`` or ``pos.con``).
36
+ saddle_atoms
37
+ Final saddle point structure (from ``saddle.con``), or None.
38
+ mode_vector
39
+ Eigenvector at the saddle (from ``mode.dat``), or None.
40
+ """
41
+
42
+ atoms_list: list[Atoms]
43
+ dat_df: pl.DataFrame
44
+ initial_atoms: Atoms
45
+ saddle_atoms: Atoms | None = None
46
+ mode_vector: np.ndarray | None = None
47
+
48
+
49
+ def parse_climb_dat(path: Path) -> pl.DataFrame:
50
+ """Read the structured ``climb.dat`` TSV file.
51
+
52
+ Parameters
53
+ ----------
54
+ path
55
+ Path to the ``climb.dat`` file.
56
+
57
+ Returns
58
+ -------
59
+ pl.DataFrame
60
+ DataFrame with columns matching the TSV header.
61
+ """
62
+ return pl.read_csv(path, separator="\t")
63
+
64
+
65
+ def parse_climb_con(path: Path) -> list[Atoms]:
66
+ """Read concatenated structures from the ``climb.con`` movie file.
67
+
68
+ Parameters
69
+ ----------
70
+ path
71
+ Path to the ``climb`` or ``climb.con`` file.
72
+
73
+ Returns
74
+ -------
75
+ list[Atoms]
76
+ List of ASE Atoms objects, one per iteration.
77
+ """
78
+ # eOn .con files may not have a .con extension for movie files
79
+ atoms_list = ase_read(str(path), index=":", format="eon")
80
+ return list(atoms_list)
81
+
82
+
83
+ def _find_initial_structure(job_dir: Path) -> Atoms | None:
84
+ """Locate the initial/reactant structure in the job directory."""
85
+ for name in ("reactant.con", "pos.con"):
86
+ p = job_dir / name
87
+ if p.exists():
88
+ return ase_read(str(p), format="eon")
89
+ return None
90
+
91
+
92
+ def _load_mode_dat(path: Path) -> np.ndarray | None:
93
+ """Load eigenvector from mode.dat (Nx3 whitespace-separated)."""
94
+ if not path.exists():
95
+ return None
96
+ return np.loadtxt(path)
97
+
98
+
99
+ def load_dimer_trajectory(job_dir: Path) -> DimerTrajectoryData:
100
+ """Load a complete dimer/saddle search trajectory from an eOn job directory.
101
+
102
+ Expects the job to have been run with ``write_movies=true``.
103
+
104
+ Parameters
105
+ ----------
106
+ job_dir
107
+ Path to the eOn job output directory containing ``climb``,
108
+ ``climb.dat``, ``saddle.con``, etc.
109
+
110
+ Returns
111
+ -------
112
+ DimerTrajectoryData
113
+ Combined trajectory data.
114
+
115
+ Raises
116
+ ------
117
+ FileNotFoundError
118
+ If required files (``climb``, ``climb.dat``) are missing.
119
+ """
120
+ # Find the movie file (may be "climb" or "climb.con")
121
+ climb_con = job_dir / "climb"
122
+ if not climb_con.exists():
123
+ climb_con = job_dir / "climb.con"
124
+ if not climb_con.exists():
125
+ msg = f"No climb movie file found in {job_dir}"
126
+ raise FileNotFoundError(msg)
127
+
128
+ climb_dat = job_dir / "climb.dat"
129
+ if not climb_dat.exists():
130
+ msg = f"No climb.dat found in {job_dir} (was write_movies enabled?)"
131
+ raise FileNotFoundError(msg)
132
+
133
+ log.info("Loading dimer trajectory from %s", job_dir)
134
+ atoms_list = parse_climb_con(climb_con)
135
+ dat_df = parse_climb_dat(climb_dat)
136
+ log.info("Loaded %d frames, %d data rows", len(atoms_list), dat_df.height)
137
+
138
+ initial = _find_initial_structure(job_dir)
139
+ if initial is None:
140
+ log.warning("No reactant.con or pos.con found; using first movie frame")
141
+ initial = atoms_list[0]
142
+
143
+ saddle_path = job_dir / "saddle.con"
144
+ saddle = ase_read(str(saddle_path), format="eon") if saddle_path.exists() else None
145
+
146
+ mode = _load_mode_dat(job_dir / "mode.dat")
147
+
148
+ return DimerTrajectoryData(
149
+ atoms_list=atoms_list,
150
+ dat_df=dat_df,
151
+ initial_atoms=initial,
152
+ saddle_atoms=saddle,
153
+ mode_vector=mode,
154
+ )
@@ -0,0 +1,133 @@
1
+ """Minimization trajectory parser for eOn output.
2
+
3
+ Reads structured per-iteration data from the minimization ``.dat`` file
4
+ and concatenated trajectory from the movie ``.con`` file, as produced
5
+ by eOn with ``write_movies=true``.
6
+
7
+ .. versionadded:: 1.5.0
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ import polars as pl
17
+ from ase import Atoms
18
+ from ase.io import read as ase_read
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+
23
+ @dataclass
24
+ class MinTrajectoryData:
25
+ """Container for a minimization trajectory.
26
+
27
+ Attributes
28
+ ----------
29
+ atoms_list
30
+ Per-iteration structures from the movie file.
31
+ dat_df
32
+ Polars DataFrame with per-iteration metrics.
33
+ initial_atoms
34
+ Starting structure (first frame).
35
+ final_atoms
36
+ Final minimized structure (from ``min.con`` or last frame).
37
+ """
38
+
39
+ atoms_list: list[Atoms]
40
+ dat_df: pl.DataFrame
41
+ initial_atoms: Atoms
42
+ final_atoms: Atoms
43
+
44
+
45
+ def parse_min_dat(path: Path) -> pl.DataFrame:
46
+ """Read the structured minimization TSV data file.
47
+
48
+ Parameters
49
+ ----------
50
+ path
51
+ Path to the minimization ``.dat`` file.
52
+
53
+ Returns
54
+ -------
55
+ pl.DataFrame
56
+ DataFrame with columns: iteration, step_size, convergence, energy.
57
+ """
58
+ return pl.read_csv(path, separator="\t")
59
+
60
+
61
+ def parse_min_con(path: Path) -> list[Atoms]:
62
+ """Read concatenated structures from the minimization movie file.
63
+
64
+ Parameters
65
+ ----------
66
+ path
67
+ Path to the movie ``.con`` file.
68
+
69
+ Returns
70
+ -------
71
+ list[Atoms]
72
+ List of ASE Atoms objects, one per iteration.
73
+ """
74
+ atoms_list = ase_read(str(path), index=":", format="eon")
75
+ return list(atoms_list)
76
+
77
+
78
+ def load_min_trajectory(
79
+ job_dir: Path,
80
+ prefix: str = "min",
81
+ ) -> MinTrajectoryData:
82
+ """Load a complete minimization trajectory from an eOn job directory.
83
+
84
+ Expects the job to have been run with ``write_movies=true``.
85
+
86
+ Parameters
87
+ ----------
88
+ job_dir
89
+ Path to the eOn job output directory.
90
+ prefix
91
+ Movie file prefix (default ``"min"``). The movie file is
92
+ ``{prefix}`` and the data file is ``{prefix}.dat``.
93
+
94
+ Returns
95
+ -------
96
+ MinTrajectoryData
97
+ Combined trajectory data.
98
+
99
+ Raises
100
+ ------
101
+ FileNotFoundError
102
+ If required files are missing.
103
+ """
104
+ movie_file = job_dir / prefix
105
+ if not movie_file.exists():
106
+ movie_file = job_dir / f"{prefix}.con"
107
+ if not movie_file.exists():
108
+ msg = f"No minimization movie file ({prefix}) found in {job_dir}"
109
+ raise FileNotFoundError(msg)
110
+
111
+ dat_file = job_dir / f"{prefix}.dat"
112
+ if not dat_file.exists():
113
+ msg = f"No {prefix}.dat found in {job_dir} (was write_movies enabled?)"
114
+ raise FileNotFoundError(msg)
115
+
116
+ log.info("Loading minimization trajectory from %s", job_dir)
117
+ atoms_list = parse_min_con(movie_file)
118
+ dat_df = parse_min_dat(dat_file)
119
+ log.info("Loaded %d frames, %d data rows", len(atoms_list), dat_df.height)
120
+
121
+ # Final structure: prefer explicit min.con, fall back to last movie frame
122
+ min_con = job_dir / "min.con"
123
+ if min_con.exists():
124
+ final = ase_read(str(min_con), format="eon")
125
+ else:
126
+ final = atoms_list[-1]
127
+
128
+ return MinTrajectoryData(
129
+ atoms_list=atoms_list,
130
+ dat_df=dat_df,
131
+ initial_atoms=atoms_list[0],
132
+ final_atoms=final,
133
+ )
@@ -8,6 +8,8 @@ RMSD landscape coordinate calculation, synthetic 2D gradient projection,
8
8
  and landscape DataFrame construction.
9
9
  """
10
10
 
11
+ from __future__ import annotations
12
+
11
13
  import logging
12
14
 
13
15
  import numpy as np
@@ -18,43 +20,56 @@ log = logging.getLogger(__name__)
18
20
 
19
21
 
20
22
  def calculate_landscape_coords(
21
- atoms_list: list[Atoms], ira_instance, ira_kmax: float
23
+ atoms_list: list[Atoms],
24
+ ira_instance,
25
+ ira_kmax: float,
26
+ ref_a: Atoms | None = None,
27
+ ref_b: Atoms | None = None,
22
28
  ) -> tuple[np.ndarray, np.ndarray]:
23
- """Calculate 2D landscape coordinates (RMSD-R, RMSD-P) for a path.
29
+ """Calculate 2D landscape coordinates (RMSD-A, RMSD-B) for a path.
24
30
 
25
31
  ```{versionadded} 1.2.0
26
32
  ```
27
33
 
28
- Uses the first frame as reactant reference and the last as product.
34
+ ```{versionchanged} 1.5.0
35
+ Added *ref_a* and *ref_b* parameters for explicit reference structures.
36
+ ```
29
37
 
30
38
  :param atoms_list: List of ASE Atoms objects representing the path.
31
39
  :param ira_instance: An instantiated IRA object (or None).
32
40
  :param ira_kmax: kmax factor for IRA.
33
- :return: A tuple of (rmsd_r, rmsd_p) arrays.
41
+ :param ref_a: Reference structure A. Defaults to ``atoms_list[0]``.
42
+ :param ref_b: Reference structure B. Defaults to ``atoms_list[-1]``.
43
+ :return: A tuple of (rmsd_a, rmsd_b) arrays.
34
44
  """
35
45
  from concurrent.futures import ThreadPoolExecutor
36
46
 
37
47
  from rgpycrumbs.geom.api.alignment import calculate_rmsd_from_ref
38
48
 
39
- log.info("Calculating landscape coordinates (RMSD-R, RMSD-P)...")
49
+ if ref_a is None:
50
+ ref_a = atoms_list[0]
51
+ if ref_b is None:
52
+ ref_b = atoms_list[-1]
53
+
54
+ log.info("Calculating landscape coordinates (RMSD-A, RMSD-B)...")
40
55
  with ThreadPoolExecutor(max_workers=2) as pool:
41
- fut_r = pool.submit(
56
+ fut_a = pool.submit(
42
57
  calculate_rmsd_from_ref,
43
58
  atoms_list,
44
59
  ira_instance,
45
- ref_atom=atoms_list[0],
60
+ ref_atom=ref_a,
46
61
  ira_kmax=ira_kmax,
47
62
  )
48
- fut_p = pool.submit(
63
+ fut_b = pool.submit(
49
64
  calculate_rmsd_from_ref,
50
65
  atoms_list,
51
66
  ira_instance,
52
- ref_atom=atoms_list[-1],
67
+ ref_atom=ref_b,
53
68
  ira_kmax=ira_kmax,
54
69
  )
55
- rmsd_r = fut_r.result()
56
- rmsd_p = fut_p.result()
57
- return rmsd_r, rmsd_p
70
+ rmsd_a = fut_a.result()
71
+ rmsd_b = fut_b.result()
72
+ return rmsd_a, rmsd_b
58
73
 
59
74
 
60
75
  def compute_synthetic_gradients(
@@ -196,6 +196,12 @@ def calculate_fes_from_hills(hills, imin=1, imax=None, xlim=None, ylim=None, npo
196
196
 
197
197
  dx = max_cv1 - min_cv1
198
198
  dy = max_cv2 - min_cv2
199
+ if dx == 0:
200
+ sigma_x = np.max(hills_data[:, 3])
201
+ dx = 6 * sigma_x
202
+ if dy == 0:
203
+ sigma_y = np.max(hills_data[:, 4])
204
+ dy = 6 * sigma_y
199
205
  xlims = [min_cv1 - 0.05 * dx, max_cv1 + 0.05 * dx]
200
206
  ylims = [min_cv2 - 0.05 * dy, max_cv2 + 0.05 * dy]
201
207
 
@@ -247,6 +253,10 @@ def calculate_fes_from_hills(hills, imin=1, imax=None, xlim=None, ylim=None, npo
247
253
  # Determine grid boundaries
248
254
  min_cv1, max_cv1 = np.min(hills_data[:, 1]), np.max(hills_data[:, 1])
249
255
  dx = max_cv1 - min_cv1
256
+ if dx == 0:
257
+ # Single-point CV range: use 3*sigma as padding
258
+ sigma = np.max(hills_data[:, 2])
259
+ dx = 6 * sigma
250
260
  xlims = [min_cv1 - 0.05 * dx, max_cv1 + 0.05 * dx]
251
261
 
252
262
  # Override with user-defined or periodic limits
@@ -0,0 +1,149 @@
1
+ """Reaction valley (s, d) projection utilities.
2
+
3
+ Extracts the 2D RMSD-plane rotation into reusable functions.
4
+ The projection maps ``(rmsd_a, rmsd_b)`` coordinates into
5
+ progress ``s`` (along the path) and deviation ``d`` (perpendicular).
6
+
7
+ For NEB paths, reference A is the reactant and B is the product.
8
+ For single-ended methods, A is the initial structure and B is the
9
+ final (saddle or minimum).
10
+
11
+ Implements the method from :cite:`goswami2026valley`.
12
+
13
+ .. versionadded:: 1.5.0
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+
20
+ import numpy as np
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class ProjectionBasis:
25
+ """Orthonormal basis for the (s, d) reaction valley projection.
26
+
27
+ Attributes
28
+ ----------
29
+ a_start, b_start
30
+ RMSD values of the first point (origin of the rotated frame).
31
+ u_a, u_b
32
+ Unit vector along the path direction in (a, b) space.
33
+ v_a, v_b
34
+ Unit vector perpendicular to the path (``v = rotate(u, +90deg)``).
35
+ path_norm
36
+ Euclidean length of the path vector in (a, b) space.
37
+ """
38
+
39
+ a_start: float
40
+ b_start: float
41
+ u_a: float
42
+ u_b: float
43
+ v_a: float
44
+ v_b: float
45
+ path_norm: float
46
+
47
+
48
+ def compute_projection_basis(
49
+ rmsd_a: np.ndarray,
50
+ rmsd_b: np.ndarray,
51
+ ) -> ProjectionBasis:
52
+ """Compute the projection basis from first/last points of the arrays.
53
+
54
+ Parameters
55
+ ----------
56
+ rmsd_a, rmsd_b
57
+ RMSD distance arrays (to reference A and B respectively).
58
+ The first element defines the origin; the last defines the
59
+ path direction.
60
+
61
+ Returns
62
+ -------
63
+ ProjectionBasis
64
+ Frozen dataclass with the orthonormal basis vectors.
65
+
66
+ Raises
67
+ ------
68
+ ValueError
69
+ If the path has zero length (first and last points coincide
70
+ in RMSD space).
71
+ """
72
+ a_start, b_start = float(rmsd_a[0]), float(rmsd_b[0])
73
+ a_end, b_end = float(rmsd_a[-1]), float(rmsd_b[-1])
74
+
75
+ vec_a, vec_b = a_end - a_start, b_end - b_start
76
+ path_norm = np.hypot(vec_a, vec_b)
77
+
78
+ if path_norm < 1e-12: # noqa: PLR2004
79
+ msg = (
80
+ "Path has zero length in RMSD space "
81
+ f"(start=({a_start:.6f}, {b_start:.6f}), "
82
+ f"end=({a_end:.6f}, {b_end:.6f}))"
83
+ )
84
+ raise ValueError(msg)
85
+
86
+ u_a = vec_a / path_norm
87
+ u_b = vec_b / path_norm
88
+
89
+ return ProjectionBasis(
90
+ a_start=a_start,
91
+ b_start=b_start,
92
+ u_a=u_a,
93
+ u_b=u_b,
94
+ v_a=-u_b,
95
+ v_b=u_a,
96
+ path_norm=path_norm,
97
+ )
98
+
99
+
100
+ def project_to_sd(
101
+ rmsd_a: np.ndarray,
102
+ rmsd_b: np.ndarray,
103
+ basis: ProjectionBasis,
104
+ ) -> tuple[np.ndarray, np.ndarray]:
105
+ """Project (rmsd_a, rmsd_b) into (s, d) reaction valley coordinates.
106
+
107
+ Parameters
108
+ ----------
109
+ rmsd_a, rmsd_b
110
+ RMSD arrays to project.
111
+ basis
112
+ Pre-computed projection basis.
113
+
114
+ Returns
115
+ -------
116
+ s, d
117
+ Progress and deviation arrays.
118
+ """
119
+ da = rmsd_a - basis.a_start
120
+ db = rmsd_b - basis.b_start
121
+ s = da * basis.u_a + db * basis.u_b
122
+ d = da * basis.v_a + db * basis.v_b
123
+ return s, d
124
+
125
+
126
+ def inverse_sd_to_ab(
127
+ s: np.ndarray,
128
+ d: np.ndarray,
129
+ basis: ProjectionBasis,
130
+ ) -> tuple[np.ndarray, np.ndarray]:
131
+ """Map (s, d) grid coordinates back to (a, b) RMSD space.
132
+
133
+ Used for evaluating the RBF surface on a projected grid.
134
+
135
+ Parameters
136
+ ----------
137
+ s, d
138
+ Progress and deviation arrays (can be meshgrid raveled).
139
+ basis
140
+ Pre-computed projection basis.
141
+
142
+ Returns
143
+ -------
144
+ rmsd_a, rmsd_b
145
+ Coordinates in the original RMSD plane.
146
+ """
147
+ rmsd_a = basis.a_start + s * basis.u_a + d * basis.v_a
148
+ rmsd_b = basis.b_start + s * basis.u_b + d * basis.v_b
149
+ return rmsd_a, rmsd_b
@@ -14,18 +14,11 @@ from chemparseplot.plot.theme import (
14
14
 
15
15
  # Lazy imports for submodules with heavy deps (cmcrameri, pint, etc.)
16
16
  def __getattr__(name):
17
- if name == "geomscan":
18
- from chemparseplot.plot import geomscan as _mod
17
+ import importlib
19
18
 
20
- return _mod
21
- if name == "structs":
22
- from chemparseplot.plot import structs as _mod
23
-
24
- return _mod
25
- if name == "chemgp":
26
- from chemparseplot.plot import chemgp as _mod
27
-
28
- return _mod
19
+ lazy_submodules = {"geomscan", "structs", "chemgp", "optimization"}
20
+ if name in lazy_submodules:
21
+ return importlib.import_module(f".{name}", __name__)
29
22
  if name == "ureg":
30
23
  from chemparseplot.units import ureg as _ureg
31
24