Source code for rgpycrumbs.eon.plt_saddle

#!/usr/bin/env python3
"""Plots dimer/saddle search optimization trajectories.

.. versionadded:: 1.3.0

Visualizes single-ended saddle point searches (dimer, Lanczos, GPRD)
using the generalized (s, d) reaction valley projection. Supports:

1. **2D Optimization Landscape:** Projects the optimization trajectory
   into (progress, deviation) coordinates relative to (initial, saddle).
2. **Energy/Eigenvalue Profile:** Energy and curvature vs iteration.
3. **Convergence Panel:** Force norm and step size vs iteration.
4. **Mode Evolution:** Alignment of dimer mode with final mode.
"""

# /// script
# requires-python = ">=3.11"
# dependencies = [
#   "click",
#   "matplotlib",
#   "numpy",
#   "scipy",
#   "jax",
#   "cmcrameri",
#   "rich",
#   "ase",
#   "polars",
#   "chemparseplot[neb,plot]>=1.9.17,<2",
#   "xyzrender>=0.1.3",
#   "readcon>=0.13.1",
#   "rgpycrumbs>=1.10.4",
# ]
# ///
# Optional deps (jax for landscapes) via uv PEP 723 or RGPYCRUMBS_AUTO_DEPS=1.

import logging
from pathlib import Path
from typing import Any

import click
import matplotlib.pyplot as plt

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-saddle") try: from ._render_cli import add_config_option, add_render_options from ._single_ended_cli import default_output_path, load_trajectories, overlay_labels from .plot_config import library_plot, run_from_click except ImportError: # pragma: no cover - direct script execution from rgpycrumbs.eon._render_cli import add_config_option, add_render_options from rgpycrumbs.eon._single_ended_cli import ( default_output_path, load_trajectories, overlay_labels, ) from rgpycrumbs.eon.plot_config import library_plot, run_from_click # Lazy plot stack: AUTO_DEPS + ensure_import (same as jax / adjustText) try: from rgpycrumbs._aux import enable_library_auto_deps from rgpycrumbs._aux import ensure_import as _ei enable_library_auto_deps() _ei("chemparseplot") except ImportError: pass from chemparseplot.parse.eon.dimer_trajectory import load_dimer_trajectory from chemparseplot.plot.optimization import ( plot_single_ended_convergence, plot_single_ended_profile, render_single_ended_landscape, ) from chemparseplot.plot.theme import get_theme, setup_global_theme from rich.logging import RichHandler logging.basicConfig( level=logging.INFO, format="%(levelname)s - %(message)s", handlers=[RichHandler(rich_tracebacks=True, show_path=False, markup=True)], )
[docs] log = logging.getLogger("rich")
[docs] IRA_KMAX_DEFAULT = 14.0
[docs] def plot_saddle_from_settings(settings: dict[str, Any]) -> Path | None: """Run the eOn saddle plot pipeline from a resolved settings mapping. Prefer :func:`plot_saddle` for library callers. Shared by the Click CLI. .. versionadded:: 1.10.3 """ from rgpycrumbs._aux import enable_library_auto_deps enable_library_auto_deps() job_dir = settings.get("job_dir") or () label = settings.get("label") or () plot_type = settings["plot_type"] ref_product = settings.get("ref_product") project_path = settings["project_path"] surface_type = settings["surface_type"] ira_kmax = settings["ira_kmax"] energy_unit = settings["energy_unit"] theme = settings["theme"] plot_structures = settings["plot_structures"] strip_renderer = settings["strip_renderer"] xyzrender_config = settings["xyzrender_config"] strip_spacing = settings["strip_spacing"] strip_zoom = settings.get("strip_zoom") strip_dividers = settings["strip_dividers"] rotation = settings["rotation"] perspective_tilt = settings["perspective_tilt"] output = settings.get("output") dpi = settings["dpi"] verbose = settings["verbose"] if verbose: logging.getLogger().setLevel(logging.DEBUG) output = default_output_path("saddle", plot_type, output) trajectory = settings.get("trajectory") if trajectory is not None: trajs = [trajectory] labels = list(label) if label else ["saddle"] else: if not job_dir: raise ValueError( "Provide --job-dir and/or set [saddle].job_dir in --config " "(or pass trajectory= / plot(frames, kind='saddle'))" ) trajs = load_trajectories( job_dir, load_dimer_trajectory, log_info=log.info, noun="trajectory", detail=lambda traj: ( f"{len(traj.atoms_list)} frames, saddle=" f"{'yes' if traj.saddle_atoms is not None else 'no'}" ), ) labels = overlay_labels(job_dir, label) traj = trajs[0] # primary trajectory for single-traj plot types active_theme = get_theme(theme) setup_global_theme(active_theme) if plot_type == "profile": _plot_profile(trajs, labels, output, dpi, energy_unit=energy_unit) elif plot_type == "landscape": _plot_landscape( trajs, labels, output, dpi, ref_product=ref_product, project_path=project_path, surface_type=surface_type, ira_kmax=ira_kmax, energy_unit=energy_unit, cmap=active_theme.cmap_landscape, plot_structures=plot_structures, strip_renderer=strip_renderer, xyzrender_config=xyzrender_config, strip_spacing=strip_spacing, strip_zoom=strip_zoom, strip_dividers=strip_dividers, rotation=rotation, perspective_tilt=perspective_tilt, theme=active_theme, ) elif plot_type == "convergence": _plot_convergence(trajs, labels, output, dpi) elif plot_type == "mode-evolution": _plot_mode_evolution(traj, output, dpi) log.info("Saved %s", output) return Path(output) if output else None
[docs] plot_saddle = library_plot("saddle", plot_saddle_from_settings)
@click.command() @click.pass_context @add_config_option @click.option( "--job-dir", type=click.Path(exists=True, file_okay=False, path_type=Path), required=False, multiple=True, help="Path to eOn saddle search output directory. Repeat for overlay. " "Optional when [saddle].job_dir is set in --config.", ) @click.option( "--label", type=str, multiple=True, help="Label for each job-dir (e.g. FIRE, LBFGS). Must match --job-dir count.", ) @click.option( "--plot-type", type=click.Choice(["profile", "landscape", "convergence", "mode-evolution"]), default="profile", help="Type of plot to generate.", ) @click.option( "--ref-product", type=click.Path(exists=True, dir_okay=False, path_type=Path), default=None, help="Optional product .con file to use as reference B instead of saddle.", ) @click.option( "--project-path/--no-project-path", is_flag=True, default=True, help="Project landscape into (s, d) coordinates.", ) @click.option( "--surface-type", type=click.Choice(["grad_matern", "grad_imq", "rbf"]), default="grad_matern", help="Surface fitting method for landscape plot.", ) @click.option( "--ira-kmax", type=float, default=IRA_KMAX_DEFAULT, help="IRA kmax parameter for RMSD calculation.", ) @click.option( "--energy-unit", type=click.Choice(["eV", "kcal/mol", "kJ/mol"]), default="eV", show_default=True, help="Presentation unit for energy axes and color scales.", ) @click.option( "--theme", type=str, default="ruhi", help="Plot theme name.", ) @click.option( "--plot-structures", type=click.Choice(["none", "endpoints"]), default="none", help="Show structure strip below landscape.", ) @add_render_options @click.option( "--strip-zoom", type=float, default=None, help="Strip image zoom (default: auto-scaled by atom count).", ) @click.option( "-o", "--output", type=click.Path(path_type=Path), default=None, help="Output file path. Defaults to {plot_type}.pdf.", ) @click.option( "--dpi", type=int, default=200, help="Output resolution.", ) @click.option("-v", "--verbose", is_flag=True, help="Enable debug logging.")
[docs] def main(ctx, config, **params): """CLI entry: merge flags/config then run plot_saddle_from_settings.""" return run_from_click( "saddle", plot_saddle_from_settings, ctx, config=config, **params )
[docs] def _plot_profile(trajs, labels, output, dpi, *, energy_unit): plot_single_ended_profile( trajs, labels, output, dpi, energy_unit=energy_unit, energy_column="delta_e", title="Energy vs Iteration", eigen_column="eigenvalue", )
[docs] def _plot_landscape( trajs, labels, output, dpi, *, ref_product, project_path, surface_type, ira_kmax, energy_unit, cmap="viridis", plot_structures="none", strip_renderer="xyzrender", xyzrender_config="paton", strip_spacing=1.5, strip_zoom=None, strip_dividers=False, rotation="auto", perspective_tilt=0.0, theme=None, ): import readcon try: from rgpycrumbs._aux import _import_from_parent_env ira_mod = _import_from_parent_env("ira_mod") except ImportError: ira_mod = None ira_instance = ira_mod.IRA() if ira_mod else None traj = trajs[0] if ref_product is not None: ref_b = readcon.read_con_as_ase(str(ref_product))[0] elif traj.saddle_atoms is not None: ref_b = traj.saddle_atoms else: ref_b = traj.atoms_list[-1] end_label = "SP" if traj.saddle_atoms is not None else "End" strip_structs = None strip_labels = None if plot_structures == "endpoints": strip_structs = [traj.initial_atoms, ref_b] strip_labels = ["R", end_label] render_single_ended_landscape( atoms_list=traj.atoms_list, energies_eV=traj.dat_df["delta_e"].to_numpy(), ref_a=traj.initial_atoms, ref_b=ref_b, overlay_atom_lists=[t.atoms_list for t in trajs], overlay_labels=labels, ira_instance=ira_instance, ira_kmax=ira_kmax, project_path=project_path, surface_type=surface_type, energy_unit=energy_unit, cmap=cmap, output=output, dpi=dpi, theme=theme, plot_structures=plot_structures, strip_structs=strip_structs, strip_labels=strip_labels, endpoint_start_label="R", endpoint_end_label=end_label, endpoint_boxed=False, annotate_overlay_starts=True, overlay_start_label="R", strip_renderer=strip_renderer, xyzrender_config=xyzrender_config, strip_spacing=strip_spacing, strip_zoom=strip_zoom, strip_dividers=strip_dividers, rotation=rotation, perspective_tilt=perspective_tilt, )
[docs] def _plot_convergence(trajs, labels, output, dpi): plot_single_ended_convergence(trajs, labels, output, dpi)
[docs] def _plot_mode_evolution(traj, output, dpi): if traj.mode_vector is None: log.warning("No mode.dat found; cannot plot mode evolution") return # Mode evolution requires per-iteration mode vectors. # Currently only the final mode is available from mode.dat. # When per-iteration modes are saved, this will use them. log.warning( "Per-iteration mode vectors not yet available from eOn output. " "Showing final mode only." ) fig, ax = plt.subplots(figsize=(5.37, 4), dpi=dpi) ax.text( 0.5, 0.5, "Per-iteration mode vectors\nnot yet available", ha="center", va="center", transform=ax.transAxes, fontsize=12, ) fig.savefig(str(output), dpi=dpi, bbox_inches="tight") plt.close(fig)
if __name__ == "__main__": main()