Source code for rgpycrumbs.eon.seed_dimers

# SPDX-FileCopyrightText: 2023-present Rohit Goswami <rog32@hi.is>
#
# SPDX-License-Identifier: MIT
"""Seed eOn dimer (min-mode) saddle searches from a converged NEB band.

Reads a converged NEB band (``neb.con``), locates every interior local maximum
of the per-image energy profile, and writes one eOn ``job=saddle_search`` seed
directory per peak. Each seed dir holds the peak geometry (``pos.con``), an
initial unstable mode (``direction.dat``), a finite-difference displaced
geometry (``displacement.con``), and a dimer ``config.ini``.

The initial mode prefers a matching eOn ``peak{MM}_mode.dat`` (paired by RMSD of
``peak{MM}_pos.con`` against the peak frame) when ``peak_files_dir`` is given;
otherwise it uses the normalized NEB tangent ``pos[i+1] - pos[i-1]``.

.. versionadded:: 1.8.0
.. versionchanged:: 1.9.x
    Seed ``config.ini`` is a minimal explicit section map written with ``eon_schema`` ``write_ini`` (validated L0 keys; no full L1 dump).
"""

from __future__ import annotations

import logging
import re
from dataclasses import dataclass
from pathlib import Path

import numpy as np
from readcon import ConFrame, read_con, write_con
from scipy.signal import find_peaks

[docs] log = logging.getLogger(__name__)
# Mirror the dimer saddle-search blocks from scripts/dimer_from_peak.py.
[docs] _RANDOM_SEED = 706253457
[docs] _DISPLACE_MAGNITUDE = 0.01
@dataclass(frozen=True)
[docs] class PeakSeed: """Summary of one seed directory created for a NEB peak. .. versionadded:: 1.8.0 """
[docs] peak_index: int
[docs] image_index: int
[docs] energy: float
[docs] energy_vs_reactant: float
[docs] seed_dir: Path
[docs] mode_source: str # "eon_peak" or "neb_tangent"
[docs] def _image_energies(frames, neb_con: Path) -> np.ndarray: """Return per-image energies, falling back to a sibling ``neb.dat``.""" energies = [getattr(f, "energy", None) for f in frames] if all(e is not None for e in energies): return np.asarray(energies, dtype=float) # Fallback: eOn writes the energy profile to neb.dat (col index 2). Partial # runs leave per-iteration neb_NNN.dat files instead; use the latest. candidates = [neb_con.with_name("neb.dat")] candidates.append(neb_con.with_name(neb_con.stem + ".dat")) iter_dats = sorted(neb_con.parent.glob("neb_[0-9]*.dat")) if iter_dats: candidates.append(iter_dats[-1]) for dat in candidates: if not dat.is_file(): continue rows = [ line.split() for line in dat.read_text().splitlines() if line.strip() and not line.lstrip().startswith("img") ] if len(rows) == len(frames): log.info("Read image energies from %s", dat.name) return np.asarray([float(r[2]) for r in rows], dtype=float) missing = [i for i, e in enumerate(energies) if e is None] msg = ( f"neb.con has no per-image energy for images {missing} and no usable " f"sibling neb.dat / neb_NNN.dat next to {neb_con}" ) raise ValueError(msg)
[docs] def _neb_tangent(frames, idx: int) -> np.ndarray: """Normalized central-difference tangent ``pos[i+1] - pos[i-1]``.""" lo = max(0, idx - 1) hi = min(len(frames) - 1, idx + 1) fwd = np.asarray(frames[hi].to_ase().get_positions()) bwd = np.asarray(frames[lo].to_ase().get_positions()) tangent = fwd - bwd norm = np.linalg.norm(tangent) if norm > 0: tangent = tangent / norm return tangent
[docs] def _match_eon_mode( peak_atoms, peak_files_dir: Path, rmsd_tol: float ) -> np.ndarray | None: """Return the eOn mode whose ``peak*_pos.con`` best matches ``peak_atoms``. Pairs by direct (non-aligned) RMSD of atom positions; eOn peak geometries share atom ordering and frame with the NEB band, so no reordering is needed. """ ref = np.asarray(peak_atoms.get_positions()) best: tuple[float, Path] | None = None for pos_file in sorted(peak_files_dir.glob("peak*_pos.con")): match = re.search(r"peak(\d+)_pos\.con$", pos_file.name) if match is None: continue mode_file = pos_file.with_name(f"peak{match.group(1)}_mode.dat") if not mode_file.is_file(): continue cand = np.asarray(read_con(str(pos_file))[0].to_ase().get_positions()) if cand.shape != ref.shape: continue rmsd = float(np.sqrt(np.mean(np.sum((cand - ref) ** 2, axis=1)))) if best is None or rmsd < best[0]: best = (rmsd, mode_file) if best is None or best[0] > rmsd_tol: return None mode = np.loadtxt(best[1], dtype=float) if mode.shape != ref.shape: log.warning( "eOn mode %s shape %s != %s; ignoring", best[1], mode.shape, ref.shape ) return None norm = np.linalg.norm(mode) if norm > 0: mode = mode / norm return mode
[docs] def _dimer_config_sections( settings_name: str, socket: str ) -> dict[str, dict[str, object]]: """Minimal eOn dimer saddle-search INI sections (explicit keys only). Aligns improved-dimer with L2 ``DimerSpec`` (method=improved). Written via ``eon_schema.config.write_ini`` with L0 key validation on covered sections. Requires ``eon-schema>=0.2``. """ return { "Main": {"job": "saddle_search", "random_seed": _RANDOM_SEED}, "Potential": {"potential": "socket_nwchem"}, "SocketNWChemPot": { "unix_socket_path": socket, "unix_socket_mode": True, "nwchem_settings": settings_name, "make_template_input": False, }, "Saddle Search": { "min_mode_method": "dimer", "displace_magnitude": 0.0, "max_energy": 50.0, }, "Dimer": { "improved": True, "opt_method": "cg", "remove_rotation": False, "converged_angle": 0.5, }, "Optimizer": { "opt_method": "lbfgs", "max_iterations": 1000, "max_move": 0.05, "convergence_metric": "norm", "converged_force": 0.05, }, "LBFGS": { "lbfgs_memory": 25, "lbfgs_inverse_curvature": 0.01, "lbfgs_auto_scale": True, }, "Debug": {"write_movies": True}, }
[docs] def find_neb_peaks(energies: np.ndarray, prominence: float) -> list[int]: """Return interior local-maximum image indices (endpoints excluded). .. versionadded:: 1.8.0 """ energies = np.asarray(energies, dtype=float) peaks, _ = find_peaks(energies, prominence=prominence) return [int(p) for p in peaks if 0 < p < len(energies) - 1]
[docs] def seed_dimers_from_peaks( neb_con, out_dir, settings_name: str, socket: str, peak_files_dir=None, prominence: float = 0.02, rmsd_tol: float = 0.1, ) -> tuple[list[Path], list[PeakSeed]]: """Write one eOn dimer saddle-search seed dir per interior NEB peak. Parameters ---------- neb_con: Path to the converged NEB band (``neb.con``). Per-image energies are read from :class:`readcon.ConFrame` ``energy``; if absent a sibling ``neb.dat`` is used. out_dir: Parent directory; each peak gets ``out_dir/peak_<NN>/``. settings_name: Name of the NWChem ``.nwi`` settings fragment referenced by config.ini. socket: Unix socket path/name for ``[SocketNWChemPot]``. peak_files_dir: Optional directory holding eOn ``peak{MM}_pos.con`` + ``peak{MM}_mode.dat`` pairs; the matching mode (by RMSD) seeds ``direction.dat`` when found. prominence: ``scipy.signal.find_peaks`` prominence threshold on the energy profile. rmsd_tol: Max RMSD (Angstrom) for an eOn peak geometry to count as a match. Returns ------- seed_dirs, summary: List of created seed directories and a list of :class:`PeakSeed`. .. versionadded:: 1.8.0 """ neb_con = Path(neb_con) out_dir = Path(out_dir) peak_files_dir = Path(peak_files_dir) if peak_files_dir is not None else None frames = read_con(str(neb_con)) if len(frames) < 3: msg = f"NEB band needs >= 3 images to have an interior peak, got {len(frames)}" raise ValueError(msg) energies = _image_energies(frames, neb_con) reactant_energy = float(energies[0]) peak_indices = find_neb_peaks(energies, prominence) log.info("Found %d interior peak(s): %s", len(peak_indices), peak_indices) sections = _dimer_config_sections(settings_name, socket) seed_dirs: list[Path] = [] summary: list[PeakSeed] = [] for nn, idx in enumerate(peak_indices): seed_dir = out_dir / f"peak_{nn:02d}" seed_dir.mkdir(parents=True, exist_ok=True) peak_frame = frames[idx] write_con(str(seed_dir / "pos.con"), [peak_frame]) peak_atoms = peak_frame.to_ase() mode = None mode_source = "neb_tangent" if peak_files_dir is not None and peak_files_dir.is_dir(): mode = _match_eon_mode(peak_atoms, peak_files_dir, rmsd_tol) if mode is not None: mode_source = "eon_peak" if mode is None: mode = _neb_tangent(frames, idx) np.savetxt(seed_dir / "direction.dat", mode, fmt="%18.12f") disp = peak_atoms.copy() disp.set_positions(peak_atoms.get_positions() + _DISPLACE_MAGNITUDE * mode) write_con(str(seed_dir / "displacement.con"), [ConFrame.from_ase(disp)]) _write_dimer_config(seed_dir / "config.ini", sections) e_img = float(energies[idx]) summary.append( PeakSeed( peak_index=nn, image_index=idx, energy=e_img, energy_vs_reactant=e_img - reactant_energy, seed_dir=seed_dir, mode_source=mode_source, ) ) seed_dirs.append(seed_dir) log.info( "peak_%02d <- image %d E=%.4f (dE=%.4f vs reactant) mode=%s", nn, idx, e_img, e_img - reactant_energy, mode_source, ) return seed_dirs, summary
[docs] def _write_dimer_config(path: Path, sections: dict[str, dict[str, object]]) -> None: """Write slim seed config.ini via eon-schema write_ini (+ L0 validate).""" from rgpycrumbs.eon.helpers import write_eon_config write_eon_config(path, sections, validate=True)