Source code for chemparseplot.parse.projection

"""Reaction valley (s, d) projection utilities.

Extracts the 2D RMSD-plane rotation into reusable functions.
The projection maps ``(rmsd_a, rmsd_b)`` coordinates into
progress ``s`` (along the path) and deviation ``d`` (perpendicular).

For NEB paths, reference A is the reactant and B is the product.
For single-ended methods, A is the initial structure and B is the
final (saddle or minimum).

Implements the method from :cite:`goswami2026valley`.

.. versionadded:: 1.5.0
"""

from __future__ import annotations

from dataclasses import dataclass

import numpy as np


[docs] @dataclass(frozen=True, slots=True) class ProjectionBasis: """Orthonormal basis for the (s, d) reaction valley projection. Attributes ---------- a_start, b_start RMSD values of the first point (origin of the rotated frame). u_a, u_b Unit vector along the path direction in (a, b) space. v_a, v_b Unit vector perpendicular to the path (``v = rotate(u, +90deg)``). path_norm Euclidean length of the path vector in (a, b) space. """ a_start: float b_start: float u_a: float u_b: float v_a: float v_b: float path_norm: float
[docs] def compute_projection_basis( rmsd_a: np.ndarray, rmsd_b: np.ndarray, ) -> ProjectionBasis: """Compute the projection basis from first/last points of the arrays. Parameters ---------- rmsd_a, rmsd_b RMSD distance arrays (to reference A and B respectively). The first element defines the origin; the last defines the path direction. Returns ------- ProjectionBasis Frozen dataclass with the orthonormal basis vectors. Raises ------ ValueError If the path has zero length (first and last points coincide in RMSD space). """ a_start, b_start = float(rmsd_a[0]), float(rmsd_b[0]) a_end, b_end = float(rmsd_a[-1]), float(rmsd_b[-1]) vec_a, vec_b = a_end - a_start, b_end - b_start path_norm = np.hypot(vec_a, vec_b) if path_norm < 1e-12: # noqa: PLR2004 msg = ( "Path has zero length in RMSD space " f"(start=({a_start:.6f}, {b_start:.6f}), " f"end=({a_end:.6f}, {b_end:.6f}))" ) raise ValueError(msg) u_a = vec_a / path_norm u_b = vec_b / path_norm return ProjectionBasis( a_start=a_start, b_start=b_start, u_a=u_a, u_b=u_b, v_a=-u_b, v_b=u_a, path_norm=path_norm, )
[docs] def project_to_sd( rmsd_a: np.ndarray, rmsd_b: np.ndarray, basis: ProjectionBasis, ) -> tuple[np.ndarray, np.ndarray]: """Project (rmsd_a, rmsd_b) into (s, d) reaction valley coordinates. Parameters ---------- rmsd_a, rmsd_b RMSD arrays to project. basis Pre-computed projection basis. Returns ------- s, d Progress and deviation arrays. """ da = rmsd_a - basis.a_start db = rmsd_b - basis.b_start s = da * basis.u_a + db * basis.u_b d = da * basis.v_a + db * basis.v_b return s, d
[docs] def inverse_sd_to_ab( s: np.ndarray, d: np.ndarray, basis: ProjectionBasis, ) -> tuple[np.ndarray, np.ndarray]: """Map (s, d) grid coordinates back to (a, b) RMSD space. Used for evaluating the RBF surface on a projected grid. Parameters ---------- s, d Progress and deviation arrays (can be meshgrid raveled). basis Pre-computed projection basis. Returns ------- rmsd_a, rmsd_b Coordinates in the original RMSD plane. """ rmsd_a = basis.a_start + s * basis.u_a + d * basis.v_a rmsd_b = basis.b_start + s * basis.u_b + d * basis.v_b return rmsd_a, rmsd_b