Source code for rgpycrumbs.eon.plt_neb_stitch

#!/usr/bin/env python3
"""Stitch multi-segment NEB paths into ONE band and plot it (1D + 2D).

.. versionadded:: 0.0.3

This CLI assembles several NEB segments that share an absolute-energy axis into a
single continuous reaction band (deduplicating shared junction frames and
removing per-segment minimization offsets), then renders both the 1D energy
profile (with the xyzrender structure strip) and the 2D reaction-valley
landscape on the stitched band by reusing the existing ``plt-neb`` plotter.

The stitching itself lives in :func:`chemparseplot.parse.eon.stitch.stitch_neb_segments`.

Example::

    python -m rgpycrumbs.cli --dev eon plt-neb-stitch \\
        --segment "geo2 -> well:results/02_neb/si3n4_g2g1/neb.con:0:7" \\
        --segment "well -> geo1_R:results/02_neb/si3n4_g2g1_mid/neb.con:0:" \\
        --segment "geo1_R -> N2 loss:results/02_neb/si3n4_cluster/neb.con:0:" \\
        --saddle-override "well -> geo1_R:results/03_saddle/si3n4_g2g1_mid/saddle.con:-29432.165003" \\
        --out-dir results/02_neb/si3n4_resolved \\
        --profile-output results/03_figures/si3n4_resolved-1d-path.png \\
        --landscape-output results/03_figures/si3n4_resolved-2d-landscape.png
"""

# /// script
# requires-python = ">=3.11"
# dependencies = [
#   "click",
#   "numpy",
#   "ase",
#   "chemparseplot[neb,plot]>=1.9.17,<2",
#   "readcon>=0.7.0",
#   "rgpycrumbs>=1.10.4",
# ]
# ///

import logging
import os
import subprocess
import sys
from pathlib import Path

import click

try:
    from rgpycrumbs._aux import warn_on_direct_script_import
except ImportError:  # pragma: no cover - direct script execution without package root
[docs] warn_on_direct_script_import = None
if warn_on_direct_script_import is not None: warn_on_direct_script_import(__name__, "rgpycrumbs eon plt-neb-stitch") from chemparseplot.parse.eon.stitch import stitch_neb_segments logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s")
[docs] log = logging.getLogger("plt-neb-stitch")
[docs] PLT_NEB = Path(__file__).resolve().parent / "plt_neb.py"
[docs] def _parse_segment(spec: str) -> tuple[str, str, int | None, int | None]: """Parse ``LABEL:CON_PATH:START:END`` (END may be empty for 'to the end').""" rest, end = spec.rsplit(":", 1) rest, start = rest.rsplit(":", 1) label, con = rest.split(":", 1) def _idx(token: str) -> int | None: token = token.strip() return int(token) if token else None return label.strip(), con.strip(), _idx(start), _idx(end)
[docs] def _parse_override(spec: str) -> tuple[str, str, float]: """Parse ``LABEL:CON_PATH:ENERGY`` (absolute saddle energy in eV).""" rest, energy = spec.rsplit(":", 1) label, con = rest.split(":", 1) return label.strip(), con.strip(), float(energy)
[docs] def _run_plt_neb(args: list[str]) -> int: """Dispatch to the existing plt-neb plotter, inheriting the environment.""" command = [sys.executable, str(PLT_NEB), *args] log.info("Running: %s", " ".join(command)) proc = subprocess.run(command, env=os.environ.copy(), check=False) # noqa: S603 return proc.returncode
@click.command() @click.option( "--segment", "segments", multiple=True, required=True, help="Repeatable segment spec 'LABEL:CON_PATH:START:END' (END empty = end).", ) @click.option( "--saddle-override", "saddle_overrides", multiple=True, default=(), help="Repeatable 'LABEL:SADDLE_CON:ENERGY_EV' to replace a segment peak.", ) @click.option( "--out-dir", type=click.Path(path_type=Path), required=True, help="Directory for the stitched band (neb.con, neb_path_000.con, neb_000.dat, sp.con).", ) @click.option( "--profile-output", type=click.Path(path_type=Path), default=None, help="Output PNG for the 1D profile (skipped if omitted).", ) @click.option( "--landscape-output", type=click.Path(path_type=Path), default=None, help="Output PNG for the 2D landscape (skipped if omitted).", ) @click.option( "--sp-label", default="hidden TS", show_default=True, help="Legend label for the overlaid saddle (sp.con) on the landscape.", ) @click.option("--profile-title", default="Stitched NEB path", show_default=True) @click.option( "--landscape-title", default="Reaction valley (stitched)", show_default=True ) @click.option("--plot-structures", default="crit_points", show_default=True) @click.option("--strip-renderer", default="xyzrender", show_default=True) @click.option("--facecolor", default="white", show_default=True) @click.option("--figsize", nargs=2, type=float, default=(7.0, 7.0), show_default=True) @click.option("--dpi", type=int, default=200, show_default=True) @click.option("--fontsize-base", type=int, default=16, show_default=True) @click.option("--zoom-ratio", type=float, default=0.4, show_default=True) @click.option("--rotation", default="auto", show_default=True) @click.option("--ira-kmax", type=float, default=14.0, show_default=True)
[docs] def main( segments, saddle_overrides, out_dir, profile_output, landscape_output, sp_label, profile_title, landscape_title, plot_structures, strip_renderer, facecolor, figsize, dpi, fontsize_base, zoom_ratio, rotation, ira_kmax, ): """Stitch NEB segments and plot the combined 1D profile and 2D landscape.""" seg_specs = [_parse_segment(s) for s in segments] overrides = {} for spec in saddle_overrides: label, con, energy = _parse_override(spec) overrides[label] = (con, energy) summary = stitch_neb_segments(seg_specs, out_dir, saddle_overrides=overrides) click.echo("") click.echo(f"Stitched band: {summary.n_frames} frames in {summary.out_dir}") click.echo(f"Segment boundaries (combined index): {summary.boundary_indices}") for rec in summary.segments: click.echo( f" {rec.label:<22s} frames {rec.start:>2d}..{rec.end:<2d} " f"well {rec.well_energy:+.4f} peak {rec.peak_energy:+.4f} " f"barrier {rec.barrier:.4f} eV" ) click.echo( f"Overall highest point: {summary.highest_energy:+.4f} eV " f"at image {summary.highest_index}" ) click.echo("") out_dir = Path(out_dir) neb_con = out_dir / "neb.con" dat_pattern = str(out_dir / "neb_*.dat") path_pattern = str(out_dir / "neb_path*.con") sp_con = out_dir / "sp.con" fig_w, fig_h = figsize common = [ "--con-file", str(neb_con), "--plot-structures", plot_structures, "--strip-renderer", strip_renderer, "--facecolor", facecolor, "--input-dat-pattern", dat_pattern, "--figsize", str(fig_w), str(fig_h), "--dpi", str(dpi), "--fontsize-base", str(fontsize_base), "--zoom-ratio", str(zoom_ratio), "--rotation", rotation, ] rc = 0 if profile_output is not None: profile_args = [ *common, "--output-file", str(profile_output), "--plot-type", "profile", "--rc-mode", "path", "--title", profile_title, ] rc |= _run_plt_neb(profile_args) if landscape_output is not None: landscape_args = [ *common, "--output-file", str(landscape_output), "--plot-type", "landscape", "--rc-mode", "path", "--landscape-mode", "surface", "--landscape-path", "all", "--surface-type", "grad_imq", "--project-path", "--show-pts", "--strip-dividers", "--input-path-pattern", path_pattern, "--additional-con", str(sp_con), sp_label, "--ira-kmax", str(ira_kmax), "--cache-file", str(out_dir / "2dcache.parquet"), "--show-legend", "--title", landscape_title, ] rc |= _run_plt_neb(landscape_args) sys.exit(rc)
if __name__ == "__main__": main()