Source code for rgpycrumbs.eon.plt_neb

#!/usr/bin/env python3
"""Plots Nudged Elastic Band (NEB) reaction paths and landscapes.

.. versionadded:: 0.0.2

This script provides a command-line interface (CLI) to visualize data
generated from NEB calculations. It can plot:

1.  **Energy/Eigenvalue Profiles:** Shows the evolution of the energy or
    lowest eigenvalue along the reaction coordinate. It can overlay multiple
    paths (e.g., from different optimization steps) and use a
    physically-motivated Hermite spline interpolation using force data.

2.  **2D Reaction Landscapes:** Plots the path on a 2D coordinate system
    defined by the Root Mean Square Deviation (RMSD) from the reactant
    and product structures. This requires the 'ira_mod' library.
    It can also interpolate and display the 2D energy/eigenvalue surface.

The script can also render atomic structures from a .con file as insets
on the plots for key points (reactant, saddle, product).

This script follows the guidelines laid out here:
https://realpython.com/python-script-structure/
"""

# /// script
# requires-python = ">=3.11"
# dependencies = [
#   "click",
#   "matplotlib",
#   "numpy",
#   "scipy",
#   "jax",
#   "adjustText>=1.0",
#   "cmcrameri",
#   "rich",
#   "ase",
#   "polars",
#   "h5py",
#   "chemparseplot[neb,plot]>=1.9.17,<2",
#   "xyzrender>=0.1.3",
#   "readcon>=0.13.1",
#   "rgpycrumbs>=1.10.4",
# ]
# ///
#
# Dispatch model (prefer ``rgpycrumbs eon plt-neb`` / ``python -m rgpycrumbs.cli``;
# not raw ``uv run plt_neb.py`` as the primary path):
# - ``uv run`` (default when the active env lacks the readcon/plot stack, or
#   RGPYCRUMBS_FORCE_UV=1): PEP 723 deps above + optional SBOM constraints.
# - In-env / ``--dev``: ensure_import + AUTO_DEPS (CLI defaults AUTO_DEPS=1).
# - Optional CycloneDX SBOM: ``--sbom`` / RGPYCRUMBS_SBOM (PyPI pins only).

import logging
import sys
from pathlib import Path
from typing import Any

import click
import matplotlib as mpl
import matplotlib.patheffects as path_effects
import matplotlib.pyplot as plt
import numpy as np
import polars as pl

try:
    from rgpycrumbs._aux import ensure_import, warn_on_direct_script_import
except ImportError:  # pragma: no cover - direct script execution without package root
[docs] ensure_import = None
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") # Optional heavy: resolve at use site so library import does not require # adjustText / AUTO_DEPS until a plot actually needs label adjustment.
[docs] def adjust_text(*args, **kwargs): """Lazy adjustText (ensure_import when AUTO_DEPS is on).""" if ensure_import is not None: from rgpycrumbs._aux import enable_library_auto_deps enable_library_auto_deps() return ensure_import("adjustText").adjust_text(*args, **kwargs) from adjustText import adjust_text as _adjust_text # pragma: no cover return _adjust_text(*args, **kwargs)
try: from ._render_cli import add_config_option, add_render_options 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.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.parse.eon.neb import ( aggregate_neb_landscape_data, compute_profile_rmsd, estimate_rbf_smoothing, load_structures_and_calculate_additional_rmsd, ) # --- Library Imports --- from chemparseplot.parse.file_ import find_file_paths from chemparseplot.parse.trajectory.hdf5 import ( history_to_landscape_df as hdf5_history_to_landscape_df, ) from chemparseplot.parse.trajectory.hdf5 import ( history_to_profile_dats, result_to_atoms_list, ) from chemparseplot.parse.trajectory.hdf5 import ( result_to_profile_dat as hdf5_result_to_profile_dat, ) from chemparseplot.parse.trajectory.neb import ( load_trajectory, trajectory_to_landscape_df, trajectory_to_profile_dat, ) from matplotlib.gridspec import GridSpec from matplotlib.patches import ArrowStyle from rich.logging import RichHandler try: from chemparseplot.parse.projection import ( compute_projection_basis, project_to_sd, ) except ImportError:
[docs] compute_projection_basis = None
project_to_sd = None from chemparseplot.plot.neb import ( convert_neb_values, default_neb_ylabel, landscape_half_span, landscape_projection_basis, mark_saddle_point, plot_energy_path, plot_landscape_path_overlay, plot_landscape_surface, plot_neb_evolution, plot_phase_points_overlay, plot_structure_inset, plot_structure_strip, profile_strip_payload, profile_structure_indices, save_plot, ) from chemparseplot.plot.structs import ( StructurePlacement, convert_energy, ) from chemparseplot.plot.theme import ( apply_axis_theme, get_theme, setup_global_theme, ) # --- Logging Setup --- 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")
# --- Constants ---
[docs] DEFAULT_INPUT_PATTERN = "neb_*.dat"
[docs] DEFAULT_PATH_PATTERN = "neb_path_*.con"
[docs] IRA_KMAX_DEFAULT = 14.0
[docs] NEB_LANDSCAPE_STRIP_ZOOM_MULT = 3.15
[docs] NEB_PROFILE_STRIP_ZOOM_MULT = 3.4
# Landscape "all" strip: two rows of six so each molecule is larger.
[docs] NEB_LANDSCAPE_STRIP_MAX_COLS = 6
# Profile strip needs real inches so R/SP/P stay legible under the curve.
[docs] NEB_PROFILE_STRIP_HEIGHT_IN = 1.95
[docs] NEB_PROFILE_MAIN_HEIGHT_IN = 3.55
# --- CLI ---
[docs] def plot_neb_from_settings(settings: dict[str, Any]) -> Path | None: """Run the eOn NEB plot pipeline from a resolved settings mapping. Prefer :func:`plot_neb` for library callers. This entry is the shared implementation used by the Click CLI after ``resolve_from_click``. Parameters ---------- settings: Mapping from :func:`rgpycrumbs.eon.plot_config.merge_plot_settings` (or ``resolve_from_click``). Returns ------- pathlib.Path | None Output path when a figure is written. .. versionadded:: 1.10.2 """ from rgpycrumbs._aux import enable_library_auto_deps enable_library_auto_deps() input_dat_pattern = settings["input_dat_pattern"] input_path_pattern = settings["input_path_pattern"] con_file = settings.get("con_file") additional_con = settings.get("additional_con") source = settings["source"] input_traj = settings.get("input_traj") input_h5 = settings.get("input_h5") plot_type = settings["plot_type"] landscape_mode = settings["landscape_mode"] landscape_path = settings["landscape_path"] project_path = settings["project_path"] rc_mode = settings["rc_mode"] plot_structures = settings["plot_structures"] rbf_smoothing = settings.get("rbf_smoothing") show_pts = settings["show_pts"] plot_mode = settings["plot_mode"] surface_type = settings["surface_type"] auto_thin = bool(settings.get("auto_thin", False)) max_surface_points = int(settings.get("max_surface_points", 64)) n_inducing = settings.get("n_inducing") output_file = settings.get("output_file") start = settings.get("start") end = settings.get("end") normalize_rc = settings["normalize_rc"] title = settings["title"] xlabel = settings.get("xlabel") ylabel = settings.get("ylabel") energy_unit = settings["energy_unit"] highlight_last = settings["highlight_last"] theme = settings["theme"] cmap_profile = settings.get("cmap_profile") cmap_landscape = settings.get("cmap_landscape") facecolor = settings.get("facecolor") fontsize_base = settings.get("fontsize_base") figsize = settings["figsize"] fig_height = settings.get("fig_height") aspect_ratio = settings.get("aspect_ratio") dpi = settings["dpi"] zoom_ratio = settings["zoom_ratio"] rotation = settings["rotation"] perspective_tilt = settings["perspective_tilt"] strip_renderer = settings["strip_renderer"] xyzrender_config = settings["xyzrender_config"] strip_spacing = settings["strip_spacing"] strip_dividers = settings["strip_dividers"] arrow_head_length = settings.get("arrow_head_length", 0.2) arrow_head_width = settings.get("arrow_head_width", 0.3) arrow_tail_width = settings.get("arrow_tail_width", 0.1) spline_method = settings["spline_method"] # Hermite uses -f_para as dE/d(rc). With image index that derivative is # not dE/dindex, so Hermite overshoots and looks jerky. Use a plain cubic # spline through the energies instead (smooth curve, no force slope). # Explicit none|spline from the user is left alone. if rc_mode == "index" and spline_method == "hermite": log.info( "rc-mode=index: switching spline-method hermite → spline " "(force-based Hermite is meaningless on image index; cubic " "through energies keeps a smooth curve)" ) spline_method = "spline" draw_reactant = settings.get("draw_reactant", (15, 60, 0.1)) draw_saddle = settings.get("draw_saddle", (15, 60, 0.1)) draw_product = settings.get("draw_product", (15, 60, 0.1)) mmf_peaks = settings.get("mmf_peaks") peak_dir = settings.get("peak_dir") show_evolution = settings["show_evolution"] show_legend = settings["show_legend"] cache_file = settings["cache_file"] force_recompute = settings["force_recompute"] ira_kmax = settings["ira_kmax"] sp_file = settings["sp_file"] augment_dat = settings.get("augment_dat") augment_con = settings.get("augment_con") # 1. Setup Theme active_theme = get_theme( theme, cmap_profile=cmap_profile, cmap_landscape=cmap_landscape, font_size=fontsize_base, facecolor=facecolor, ) setup_global_theme(active_theme) if fig_height and aspect_ratio: figsize = (fig_height * aspect_ratio, fig_height) elif fig_height or aspect_ratio: log.error( "Both --fig-height and --aspect-ratio must be provided together. Using default figsize." ) fig = plt.figure(figsize=figsize, dpi=dpi) # Layout Logic has_strip = plot_structures in ["all", "crit_points"] and plot_type in { "landscape", "profile", } if has_strip: # Single-row strips need little vertical gap; multi-row needs more. n_expected = (3 if plot_structures == "crit_points" else 12) + len( additional_con or [] ) # Prefer one strip row (matches plot_structure_strip single-row preference). n_rows = 1 if n_expected <= 16 else 2 if plot_type == "profile": # Temporary GridSpec; profile uses content-sized layout later so the # strip is re-rendered at final pixel height (not crushed by a cap). calc_hspace = 0.22 if n_rows > 1 else 0.12 height_ratios = [1, 0.62 if n_rows == 1 else 0.75] else: # Landscape: keep strip close; avoid a tall empty canvas under the map. calc_hspace = 0.28 if n_rows > 1 else 0.12 height_ratios = [1, 0.32 if n_rows == 1 else 0.45] gs = GridSpec(2, 1, height_ratios=height_ratios, hspace=calc_hspace, figure=fig) ax = fig.add_subplot(gs[0]) ax_strip = fig.add_subplot(gs[1]) apply_axis_theme(ax_strip, active_theme) else: ax = fig.add_subplot(111) ax_strip = None apply_axis_theme(ax, active_theme) atoms_list = None additional_atoms_data = [] sp_data = None # Filled so strips re-render after content-sized layout (profile + landscape). landscape_strip_render = None profile_strip_render = None # Only attempt to load structures if specifically requested or needed for the plot type if con_file: try: overlay_bundle = load_structures_and_calculate_additional_rmsd( con_file, additional_con, ira_kmax, sp_file ) atoms_list = overlay_bundle.atoms_list additional_atoms_data = overlay_bundle.additional_structures sp_data = overlay_bundle.saddle_point except Exception as e: log.error(f"Error loading structures: {e}") # Critical failure for landscape/RMSD modes if plot_type == "landscape" or rc_mode == "rmsd": log.critical("Cannot proceed without structures.") raise RuntimeError( "Cannot proceed without structures " f"(plot_type={plot_type!r}, rc_mode={rc_mode!r}): {e}" ) from e # --- Trajectory source: load once if applicable --- # In-memory ConFrame path (path_frames / plot(frames, kind="neb")): treat as traj. frames = settings.get("frames") traj_atoms_list = settings.get("atoms_list") if traj_atoms_list is None and frames is not None: from chemparseplot.parse.eon.frame_series import atoms_list_from_frames traj_atoms_list = atoms_list_from_frames(frames) source = "traj" if source == "traj" and traj_atoms_list is None: if not input_traj: msg = "--input-traj is required when --source traj is used" log.critical(msg) raise RuntimeError(msg) traj_atoms_list = load_trajectory(str(input_traj)) if plot_type == "landscape": # --- Landscape Plot --- z_label = default_neb_ylabel(plot_mode, energy_unit) if source == "traj": df = trajectory_to_landscape_df(traj_atoms_list, ira_kmax=ira_kmax) # Use traj structures for con_file features when not provided if atoms_list is None: atoms_list = traj_atoms_list elif source == "hdf5": if not input_h5: msg = "--input-h5 is required when --source hdf5 is used" log.critical(msg) raise RuntimeError(msg) h5_str = str(input_h5) # Prefer history file for multi-step landscape try: df = hdf5_history_to_landscape_df(h5_str, ira_kmax=ira_kmax) except Exception: log.warning("History read failed, falling back to single-step result.") from chemparseplot.parse.trajectory.hdf5 import ( result_to_atoms_list as _r2a, ) from chemparseplot.parse.trajectory.neb import ( trajectory_to_landscape_df as _traj_ldf, ) hdf5_atoms = _r2a(h5_str) df = _traj_ldf(hdf5_atoms, ira_kmax=ira_kmax) if atoms_list is None: atoms_list = result_to_atoms_list(h5_str) else: dat_paths = find_file_paths(input_dat_pattern) con_paths = find_file_paths(str(input_path_pattern)) if not dat_paths: msg = f"No data files found for pattern: {input_dat_pattern}" log.critical(msg) raise RuntimeError(msg) # Fallback if no path files found but main file exists if not con_paths and con_file: con_paths = [con_file] y_col = 2 if plot_mode == "energy" else 4 df = aggregate_neb_landscape_data( dat_paths, con_paths, y_col, None, cache_file=cache_file, force_recompute=force_recompute, ira_kmax=ira_kmax, augment_dat=augment_dat, augment_con=augment_con, ref_atoms=atoms_list[0] if atoms_list else None, # main reactant prod_atoms=atoms_list[-1] if atoms_list else None, # main product ) # Compute a SINGLE projection basis from the full dataset's endpoints. # This basis is used consistently for: surface grid, path overlay, # additional-con overlay, and viewport calculation. r_full = df["r"].to_numpy() p_full = df["p"].to_numpy() global_basis = compute_projection_basis(r_full, p_full) if project_path else None # Surface Generation if landscape_mode == "surface": if landscape_path == "last": max_step = df["step"].max() df_surface = df.filter(pl.col("step") == max_step) else: df_surface = df # Prepare arrays r_all = df_surface["r"].to_numpy() p_all = df_surface["p"].to_numpy() z_all = convert_neb_values(df_surface["z"].to_numpy(), plot_mode, energy_unit) gr_all = convert_neb_values( df_surface["grad_r"].to_numpy(), plot_mode, energy_unit ) gp_all = convert_neb_values( df_surface["grad_p"].to_numpy(), plot_mode, energy_unit ) step_all = df_surface["step"].to_numpy() # Heuristic for RBF smoothing if missing if rbf_smoothing is None: rbf_smoothing = estimate_rbf_smoothing(df) log.info(f"Calculated heuristic RBF smoothing: {rbf_smoothing:.4f}") extra_pts = [] if sp_data: extra_pts.append([sp_data.r, sp_data.p]) for overlay in additional_atoms_data: extra_pts.append([overlay.r, overlay.p]) extra_pts_arr = np.array(extra_pts) if extra_pts else None # Pre-compute viewport from FULL data (not filtered surface data) vp_xlim = vp_ylim = None if project_path and global_basis is not None: _s, _d = project_to_sd(r_full, p_full, global_basis) # Modest s pad; d half-span matches s so Δs=Δd (true 1:1 Å). _s_pad = max((_s.max() - _s.min()) * 0.04, 0.02) vp_xlim = (float(_s.min() - _s_pad), float(_s.max() + _s_pad)) _s_half = 0.5 * (vp_xlim[1] - vp_xlim[0]) _half = max( _s_half, abs(float(_d.max())) * 1.12, abs(float(_d.min())) * 1.12, 0.02, ) if sp_data is not None: _, _sd = project_to_sd( np.array([sp_data.r]), np.array([sp_data.p]), global_basis ) _half = max(_half, abs(float(_sd[0])) * 1.12) for overlay in additional_atoms_data: _, _ad = project_to_sd( np.array([overlay.r]), np.array([overlay.p]), global_basis ) _half = max(_half, abs(float(_ad[0])) * 1.12) vp_ylim = (-_half, _half) plot_landscape_surface( ax, r_all, p_all, gr_all, gp_all, z_all, step_data=step_all, method=surface_type, rbf_smooth=rbf_smoothing, cmap=active_theme.cmap_landscape, show_pts=show_pts, # so we always show 5% and 95%, this is the user defined additional one # TODO(rg): just be a user parameter.. variance_threshold=0.5, # 50% uncertainty project_path=project_path, extra_points=extra_pts_arr, n_inducing=n_inducing, xlim=vp_xlim, ylim=vp_ylim, basis=global_basis, auto_thin=auto_thin, max_surface_points=max_surface_points, ) # Path Overlay (Final Step) max_step = df["step"].max() df_final = df.filter(pl.col("step") == max_step) final_r = df_final["r"].to_numpy() final_p = df_final["p"].to_numpy() final_z = convert_neb_values(df_final["z"].to_numpy(), plot_mode, energy_unit) # Pass all-iteration data for triangulated background when no GP surface bg_kwargs = {} if landscape_mode != "surface": bg_kwargs = { "all_r": df["r"].to_numpy(), "all_p": df["p"].to_numpy(), "all_z": convert_neb_values(df["z"].to_numpy(), plot_mode, energy_unit), } plot_landscape_path_overlay( ax, final_r, final_p, final_z, active_theme.cmap_landscape, z_label, project_path=project_path, basis=global_basis, **bg_kwargs, ) # --- OCI-NEB/RONEB: refinement-sample overlay --- _show_mmf = mmf_peaks _peak_search_dir = peak_dir or Path(".") if _show_mmf is None: _show_mmf = any( (_peak_search_dir / name).exists() for name in ("climb", "climb.con") ) if _show_mmf: from rgpycrumbs.geom.api.alignment import calculate_rmsd_from_ref try: from rgpycrumbs._aux import _import_from_parent_env _ira_mod = _import_from_parent_env("ira_mod") ira_instance = _ira_mod.IRA() except (ImportError, AttributeError): ira_instance = None # Use same references as the main path ref_r = atoms_list[0] if atoms_list else None ref_p = atoms_list[-1] if atoms_list else None if ref_r is not None and ref_p is not None: try: dimer_traj = load_dimer_trajectory(_peak_search_dir) except (FileNotFoundError, ValueError): dimer_traj = None if dimer_traj is not None and dimer_traj.atoms_list: try: dimer_rmsd_r = calculate_rmsd_from_ref( dimer_traj.atoms_list, ira_instance, ref_atom=ref_r, ira_kmax=ira_kmax, ) dimer_rmsd_p = calculate_rmsd_from_ref( dimer_traj.atoms_list, ira_instance, ref_atom=ref_p, ira_kmax=ira_kmax, ) except ValueError as exc: log.warning( "Skipping MMF refinement overlay from %s: %s", _peak_search_dir, exc, ) else: plot_phase_points_overlay( ax, dimer_rmsd_r, dimer_rmsd_p, project_path=project_path, path_rmsd_r=final_r, path_rmsd_p=final_p, ) log.info( "Plotted %d MMF refinement frame(s)", len(dimer_traj.atoms_list), ) # --- OCI-NEB/RONEB: Band Evolution --- if show_evolution: unique_steps = sorted(df["step"].unique().to_list()) if len(unique_steps) > 1: step_r_list = [] step_p_list = [] for step in unique_steps: step_df = df.filter(pl.col("step") == step) step_r_list.append(step_df["r"].to_numpy()) step_p_list.append(step_df["p"].to_numpy()) plot_neb_evolution( ax, step_r_list, step_p_list, project_path=project_path, ) log.info("Plotted band evolution (%d steps)", len(unique_steps)) # Saddle Point Marker if sp_data: # Use explicit SP coordinates sp_x_raw, sp_y_raw = sp_data.r, sp_data.p log.info(f"Plotting explicit SP at R={sp_x_raw:.3f}, P={sp_y_raw:.3f}") else: # Fallback to heuristic if plot_mode == "energy": saddle_idx = np.argmax(final_z[1:-1]) + 1 else: saddle_idx = np.argmin(final_z) sp_x_raw, sp_y_raw = final_r[saddle_idx], final_p[saddle_idx] # Apply projection to saddle point if enabled if project_path: _sp_basis = landscape_projection_basis(global_basis, final_r, final_p) sp_sd = project_to_sd(np.array([sp_x_raw]), np.array([sp_y_raw]), _sp_basis) sp_x, sp_y = float(sp_sd[0][0]), float(sp_sd[1][0]) else: sp_x, sp_y = sp_x_raw, sp_y_raw # Star only on the landscape — R/SP/P text already comes from the # strip-driven main-axes labels (avoids a second floating SP box). mark_saddle_point( ax, sp_x, sp_y, font_size=active_theme.font_size, vline=False, annotate=False, ) if additional_atoms_data: marker_cmap = mpl.colormaps.get_cmap("tab10") for i, overlay in enumerate(additional_atoms_data): color = marker_cmap(i % 10) if project_path: _add_basis = landscape_projection_basis( global_basis, final_r, final_p ) _s, _d = project_to_sd( np.array([overlay.r]), np.array([overlay.p]), _add_basis ) plot_add_r, plot_add_p = float(_s[0]), float(_d[0]) else: plot_add_r, plot_add_p = overlay.r, overlay.p ax.plot( plot_add_r, plot_add_p, marker="*", markersize=int(active_theme.font_size * 1.1), color=color, markeredgecolor="white", markeredgewidth=1.0, linestyle="None", zorder=102, label=overlay.label, ) if has_strip and atoms_list: strip_payload = [] # Helper to calculate projected coordinates for labels def get_projected_coords(r_val, p_val): if project_path: _pc_basis = landscape_projection_basis(global_basis, final_r, final_p) _s, _d = project_to_sd( np.array([r_val]), np.array([p_val]), _pc_basis ) return float(_s[0]), float(_d[0]) return r_val, p_val # Saddle index for labels (explicit SP may still be a separate geometry). if plot_mode == "energy": s_idx = int(np.argmax(final_z[1:-1]) + 1) else: s_idx = int(np.argmin(final_z)) if plot_structures == "all": # One entry per band image (no R/SP/P *and* numeric duplicates at # the same x, which stacked strip captions on top of each other). for i, atoms_i in enumerate(atoms_list): ix, iy = get_projected_coords(final_r[i], final_p[i]) if i == 0: label = "R" elif i == len(atoms_list) - 1: label = "P" elif i == s_idx: label = "SP" else: label = str(i) strip_payload.append( StructurePlacement( atoms=atoms_i, x=ix, y=iy, label=label, ) ) # Explicit SP is marked on the main axes; the band already has an # SP-labeled image so we do not double it in the strip. else: # Crit points only: R, SP, P. rx, ry = get_projected_coords(final_r[0], final_p[0]) strip_payload.append( StructurePlacement(atoms=atoms_list[0], x=rx, y=ry, label="R") ) if sp_data: sx, sy = get_projected_coords(sp_data.r, sp_data.p) strip_payload.append( StructurePlacement( atoms=sp_data.atoms, x=sx, y=sy, label=sp_data.label, ) ) else: sx, sy = get_projected_coords(final_r[s_idx], final_p[s_idx]) strip_payload.append( StructurePlacement( atoms=atoms_list[s_idx], x=sx, y=sy, label="SP", ) ) px, py = get_projected_coords(final_r[-1], final_p[-1]) strip_payload.append( StructurePlacement(atoms=atoms_list[-1], x=px, y=py, label="P") ) # Add additional structures for overlay in additional_atoms_data: ax_r, ax_p = get_projected_coords(overlay.r, overlay.p) strip_payload.append( StructurePlacement( atoms=overlay.atoms, x=ax_r, y=ax_p, label=overlay.label, ) ) strip_payload.sort(key=lambda entry: entry.x) # Defer strip drawing until after equal-aspect figure layout so # structures are rendered at the final (large) strip pixel size. # A first draw on the small GridSpec cell left tiny molecules. landscape_strip_render = { "payload": strip_payload, "zoom": zoom_ratio * NEB_LANDSCAPE_STRIP_ZOOM_MULT, "rotation": rotation, "theme_color": active_theme.textcolor, "renderer": strip_renderer, "xyzrender_config": xyzrender_config, "col_spacing": strip_spacing, "show_dividers": strip_dividers, "perspective_tilt": perspective_tilt, "width_fill_fraction": 0.92, # Two rows (e.g. 12 → 6+6) for larger per-cell molecules. "max_cols": NEB_LANDSCAPE_STRIP_MAX_COLS, "prefer_single_row": False, } # Annotate Main Plot -- only label R, SP, P (not additional con; # those are identified by the legend markers instead) main_plot_texts = [] main_labels = {"R", "SP", "P"} for d in strip_payload: if d.label not in main_labels: continue t = ax.text( d.x, d.y, d.label, fontsize=11, fontweight="bold", color="white", ha="center", va="bottom", zorder=102, ) t.set_path_effects( [path_effects.withStroke(linewidth=2.5, foreground="black")] ) main_plot_texts.append(t) if main_plot_texts: adjust_text( main_plot_texts, ax=ax, arrowprops={"arrowstyle": "-", "color": "white", "lw": 1.0}, expand_points=(2.0, 2.0), force_text=(1.0, 2.0), force_points=(1.0, 2.0), ) # Labels if project_path: final_xlabel = xlabel or r"Reaction progress $s$ ($\AA$)" final_ylabel = ylabel or r"Orthogonal deviation $d$ ($\AA$)" final_title = "Reaction Valley Projection" if title == "NEB Path" else title else: final_xlabel = xlabel or r"RMSD from Reactant ($\AA$)" final_ylabel = ylabel or r"RMSD from Product ($\AA$)" final_title = "RMSD(R,P) projection" if title == "NEB Path" else title else: # --- Profile Plot --- strip_payload = [] if source == "hdf5": if not input_h5: msg = "--input-h5 is required when --source hdf5 is used" log.critical(msg) raise RuntimeError(msg) h5_str = str(input_h5) # Use history final step if available, else result try: dats = history_to_profile_dats(h5_str) data = dats[-1] except Exception: data = hdf5_result_to_profile_dat(h5_str) if atoms_list is None: atoms_list = result_to_atoms_list(h5_str) if rc_mode == "index": data[1] = np.arange(data.shape[1]) elif normalize_rc: data[1] = data[1] / data[1].max() if data[1].max() > 0 else data[1] y_col = 2 if plot_mode == "energy" else 4 data[y_col] = convert_neb_values(data[y_col], plot_mode, energy_unit) if plot_mode == "energy": data[3] = convert_energy(data[3], energy_unit) color = active_theme.highlight_color plot_energy_path( ax, data[1], data[y_col], data[3], color, 1.0, 20, method=spline_method, ) elif source == "traj": # Trajectory source: single extxyz file -> one profile data = trajectory_to_profile_dat(traj_atoms_list) if atoms_list is None: atoms_list = traj_atoms_list if rc_mode == "index": data[1] = np.arange(data.shape[1]) elif normalize_rc: data[1] = data[1] / data[1].max() if data[1].max() > 0 else data[1] y_col = 2 if plot_mode == "energy" else 4 data[y_col] = convert_neb_values(data[y_col], plot_mode, energy_unit) if plot_mode == "energy": data[3] = convert_energy(data[3], energy_unit) color = active_theme.highlight_color plot_energy_path( ax, data[1], data[y_col], data[3], color, 1.0, 20, method=spline_method, ) if atoms_list and plot_structures != "none": if has_strip: strip_payload.extend( profile_strip_payload( atoms_list, data[1], data[y_col], plot_structures, plot_mode, ) ) else: indices = profile_structure_indices( atoms_list, data[y_col], plot_structures, plot_mode ) for i in indices: if i == 0: xybox, rad = draw_reactant[:2], draw_reactant[2] elif i == len(atoms_list) - 1: xybox, rad = draw_product[:2], draw_product[2] else: xybox, rad = draw_saddle[:2], draw_saddle[2] if plot_structures == "all": xybox = (15.0, 60.0 if i % 2 == 0 else -60.0) rad = 0.1 if i % 2 == 0 else -0.1 plot_structure_inset( ax, atoms_list[i], data[1][i], data[y_col][i], xybox, rad, zoom=zoom_ratio, rotation=rotation, renderer=strip_renderer, xyzrender_config=xyzrender_config, perspective_tilt=perspective_tilt, ) else: # eOn source: multiple .dat files dat_paths = find_file_paths(input_dat_pattern) file_paths_to_plot = dat_paths[start:end] if not file_paths_to_plot: msg = "No profile data files found in the requested start:end range" log.error(msg) raise RuntimeError(msg) # Optional: Load RMSD for X-axis rmsd_rc = None if rc_mode == "rmsd" and atoms_list: df_rmsd = compute_profile_rmsd( atoms_list, cache_file=cache_file, force_recompute=force_recompute, ira_kmax=ira_kmax, ) rmsd_rc = df_rmsd["r"].to_numpy() # Plot Loop cm = plt.get_cmap(active_theme.cmap_profile) color_divisor = ( len(file_paths_to_plot) - 1 if len(file_paths_to_plot) > 1 else 1.0 ) y_col = 2 if plot_mode == "energy" else 4 last_profile_rc = None last_profile_y = None for idx, fpath in enumerate(file_paths_to_plot): try: data = np.loadtxt(fpath, skiprows=1).T except Exception as ex: log.error(ex) continue # X-Axis Logic if rc_mode == "rmsd" and rmsd_rc is not None: if len(rmsd_rc) == data.shape[1]: data[1] = rmsd_rc elif rc_mode == "index": data[1] = np.arange(data.shape[1]) elif normalize_rc: data[1] = data[1] / data[1].max() if data[1].max() > 0 else data[1] # Style Logic is_last = idx == len(file_paths_to_plot) - 1 if highlight_last and is_last: color, alpha, zorder = active_theme.highlight_color, 1.0, 20 step_label = f"Step {idx + 1} (final)" else: color = cm(idx / color_divisor) alpha = 1.0 if idx == 0 else 0.5 zorder = 10 if idx == 0 else 5 step_label = f"Step {idx + 1}" if idx == 0 else None # Plot data[y_col] = convert_neb_values(data[y_col], plot_mode, energy_unit) if plot_mode == "energy": data[3] = convert_energy(data[3], energy_unit) plot_energy_path( ax, data[1], data[y_col], data[3], # Forces color, alpha, zorder, method=spline_method, label=step_label, ) if is_last or last_profile_rc is None: last_profile_rc = np.asarray(data[1], dtype=float) last_profile_y = np.asarray(data[y_col], dtype=float) if ( highlight_last and is_last and atoms_list and plot_structures != "none" ): if has_strip: strip_payload.extend( profile_strip_payload( atoms_list, data[1], data[y_col], plot_structures, plot_mode, ) ) else: indices = profile_structure_indices( atoms_list, data[y_col], plot_structures, plot_mode ) for i in indices: if i == 0: xybox, rad = draw_reactant[:2], draw_reactant[2] elif i == len(atoms_list) - 1: xybox, rad = draw_product[:2], draw_product[2] else: xybox, rad = draw_saddle[:2], draw_saddle[2] if plot_structures == "all": xybox = (15.0, 60.0 if i % 2 == 0 else -60.0) rad = 0.1 if i % 2 == 0 else -0.1 # Call library function plot_structure_inset( ax, atoms_list[i], data[1][i], data[y_col][i], xybox, rad, zoom=zoom_ratio, rotation=rotation, renderer=strip_renderer, xyzrender_config=xyzrender_config, ) # Saddle marker on the (final) 1D profile — gold star + vertical guide. if ( last_profile_rc is not None and last_profile_y is not None and len(last_profile_y) > 2 ): if plot_mode == "energy": s_idx = int(np.argmax(last_profile_y[1:-1]) + 1) else: s_idx = int(np.argmin(last_profile_y)) mark_saddle_point( ax, float(last_profile_rc[s_idx]), float(last_profile_y[s_idx]), font_size=active_theme.font_size, vline=True, # Strip already labels SP; skip the floating box on the curve. annotate=not has_strip, ) # --- Profile Additional Structures --- if additional_atoms_data and rc_mode == "rmsd": for i, overlay in enumerate(additional_atoms_data): ax.axvline( overlay.r, color=active_theme.gridcolor, linestyle=":", linewidth=2, zorder=90, ) if has_strip: strip_payload.append( StructurePlacement( atoms=overlay.atoms, x=float(overlay.r), label=overlay.label, ) ) elif plot_structures != "none": y_span = ax.get_ylim()[1] - ax.get_ylim()[0] y_pos = ax.get_ylim()[0] + 0.9 * y_span plot_structure_inset( ax, overlay.atoms, overlay.r, y_pos, xybox=( draw_saddle[0] + (i * 15), draw_saddle[1], ), # Stagger slightly rad=draw_saddle[2], zoom=zoom_ratio, rotation=rotation, renderer=strip_renderer, xyzrender_config=xyzrender_config, arrow_props={ "arrowstyle": ArrowStyle.Fancy( head_length=arrow_head_length, head_width=arrow_head_width, tail_width=arrow_tail_width, ), "connectionstyle": f"arc3,rad={rad}", "linestyle": "-", "alpha": 0.8, "color": "black", "linewidth": 1.2, }, ) if has_strip and strip_payload: deduped_payload = [] seen = set() for entry in sorted(strip_payload, key=lambda d: d.x): key = (entry.label, round(entry.x, 8)) if key in seen: continue seen.add(key) deduped_payload.append(entry) # Defer strip draw until content-sized layout (same as landscape). profile_strip_render = { "payload": deduped_payload, "zoom": zoom_ratio * NEB_PROFILE_STRIP_ZOOM_MULT, "rotation": rotation, "theme_color": active_theme.textcolor, "renderer": strip_renderer, "xyzrender_config": xyzrender_config, "col_spacing": strip_spacing, "show_dividers": strip_dividers, "perspective_tilt": perspective_tilt, "width_fill_fraction": 0.72, } # Profile Labels if xlabel: final_xlabel = xlabel elif rc_mode == "rmsd": final_xlabel = r"RMSD from reactant ($\AA$)" elif rc_mode == "index": final_xlabel = "Image index" else: final_xlabel = r"Path length ($\AA$)" final_ylabel = ylabel or default_neb_ylabel(plot_mode, energy_unit) if title == "NEB Path": final_title = { "path": "Energy against path length", "index": "Energy against image index", "rmsd": "Energy against reactant RMSD", }.get(rc_mode, title) else: final_title = title # Final Aesthetics ax.set_xlabel(final_xlabel, weight="bold") ax.set_ylabel(final_ylabel, weight="bold") ax.set_title(final_title) ax.minorticks_on() projected_layout_done = False if plot_type == "landscape" and not aspect_ratio: if project_path: # True 1:1 metric: Δd window = Δs window so set_aspect('equal') is a # square panel where 1 Å of s matches 1 Å of d (same RMSD unit). x_min, x_max = ax.get_xlim() half_span = landscape_half_span( (x_min, x_max), final_r, final_p, additional_atoms_data, global_basis, equal_metric=True, ) if sp_data is not None and global_basis is not None: _sp_basis = landscape_projection_basis(global_basis, final_r, final_p) _, _spd = project_to_sd( np.array([sp_data.r]), np.array([sp_data.p]), _sp_basis ) half_span = max(half_span, abs(float(_spd[0])) * 1.12) ax.set_ylim(-half_span, half_span) # Center s so the data window is exactly square (Δs == Δd == 2*half). s_mid = 0.5 * (x_min + x_max) ax.set_xlim(s_mid - half_span, s_mid + half_span) x_min, x_max = ax.get_xlim() y_min, y_max = -half_span, half_span # Square data window (Δs == Δd == 2*half) + square axes box so # 1 Å of s equals 1 Å of d on screen (true 1:1 RMSD metric). # Note: d ticks show ±half (e.g. ±0.67), not 0→1.34 — the *span* # matches s (e.g. 0→1.34); both windows are the same length in Å. map_in = 6.2 # square: map_w_in == map_h_in # Two-row strip needs real height so molecules stay large. n_strip = ( len(landscape_strip_render["payload"]) if landscape_strip_render is not None else 0 ) n_strip_rows = ( max( 1, (n_strip + NEB_LANDSCAPE_STRIP_MAX_COLS - 1) // NEB_LANDSCAPE_STRIP_MAX_COLS, ) if n_strip and landscape_strip_render is not None and not landscape_strip_render.get("prefer_single_row", True) else (1 if n_strip else 0) ) strip_h_in = (3.35 if n_strip_rows >= 2 else 2.2) if has_strip else 0.0 y_label_in = 0.95 cbar_in = 1.20 top_in = 0.55 gap_map_strip_in = 0.20 if has_strip else 0.0 bottom_in = 0.18 fig_w = y_label_in + map_in + cbar_in fig_h = top_in + map_in + gap_map_strip_in + strip_h_in + bottom_in fig.set_size_inches(fig_w, fig_h, forward=True) left = y_label_in / fig_w map_w_frac = map_in / fig_w map_h_frac = map_in / fig_h map_bottom = (bottom_in + strip_h_in + gap_map_strip_in) / fig_h ax.set_position([left, map_bottom, map_w_frac, map_h_frac]) ax.set_aspect("equal", adjustable="box", anchor="C") cbar_left = left + map_w_frac + 0.012 cbar_w_frac = max(0.015, 0.20 / fig_w) for other in list(fig.axes): if other is ax or other is ax_strip: continue other.set_position([cbar_left, map_bottom, cbar_w_frac, map_h_frac]) if ax_strip is not None: strip_bottom = bottom_in / fig_h strip_h_frac = strip_h_in / fig_h ax_strip.set_position([left, strip_bottom, map_w_frac, strip_h_frac]) ax_strip.set_navigate(False) # Re-render structure strip at the final large axes size. if landscape_strip_render is not None and ax_strip is not None: ax_strip.clear() apply_axis_theme(ax_strip, active_theme) ax_strip.axis("off") fig.canvas.draw() plot_structure_strip( ax_strip, landscape_strip_render["payload"], zoom=landscape_strip_render["zoom"], rotation=landscape_strip_render["rotation"], theme_color=landscape_strip_render["theme_color"], renderer=landscape_strip_render["renderer"], xyzrender_config=landscape_strip_render["xyzrender_config"], col_spacing=landscape_strip_render["col_spacing"], show_dividers=landscape_strip_render["show_dividers"], perspective_tilt=landscape_strip_render["perspective_tilt"], width_fill_fraction=landscape_strip_render["width_fill_fraction"], max_cols=landscape_strip_render.get( "max_cols", NEB_LANDSCAPE_STRIP_MAX_COLS ), prefer_single_row=landscape_strip_render.get( "prefer_single_row", False ), ) ax_strip.set_position([left, strip_bottom, map_w_frac, strip_h_frac]) projected_layout_done = True log.info( "Set 1:1 (s,d) square panel: Δs=Δd=%.3f Å " "(s=[%.3f, %.3f], d=[%.3f, %.3f]); strip_rows=%d; " "figsize=(%.2f, %.2f) in", 2.0 * half_span, x_min, x_max, y_min, y_max, n_strip_rows, fig_w, fig_h, ) else: # Raw RMSD(R) vs RMSD(P) is also Å–Å. ax.set_aspect("equal", adjustable="box", anchor="C") if show_legend: ax.legend( # Path usually sits at d ≳ 0; lower-left negative-d corner is free. # Avoid loc='best' on wide equal-aspect maps (drops on the band). loc="lower left", borderaxespad=0.3, frameon=True, framealpha=1.0, facecolor="white", edgecolor="black", fontsize=int(active_theme.font_size * 0.75), ).set_zorder(101) if not projected_layout_done: if plot_type == "profile" and ax_strip is not None and profile_strip_render: # Content-sized profile: main panel + dedicated strip inches so R/SP/P # are large enough to read. Re-render the strip at the final pixel size # (the old min(height, 0.20) cap crushed molecules after a first draw). y_label_in = 0.85 right_in = 0.35 top_in = 0.55 bottom_in = 0.28 # Room for the main-axes xlabel between the curve and R/SP/P strip. gap_main_strip_in = 0.58 main_h_in = NEB_PROFILE_MAIN_HEIGHT_IN strip_h_in = NEB_PROFILE_STRIP_HEIGHT_IN # Prefer the requested width when provided; grow only if too narrow. fig_w = max(float(figsize[0]), 6.4) fig_h = top_in + main_h_in + gap_main_strip_in + strip_h_in + bottom_in fig.set_size_inches(fig_w, fig_h, forward=True) left = y_label_in / fig_w main_w_frac = max(0.55, 1.0 - (y_label_in + right_in) / fig_w) main_h_frac = main_h_in / fig_h strip_h_frac = strip_h_in / fig_h strip_bottom = bottom_in / fig_h main_bottom = (bottom_in + strip_h_in + gap_main_strip_in) / fig_h ax.set_position([left, main_bottom, main_w_frac, main_h_frac]) ax.xaxis.labelpad = 6 ax_strip.set_position([left, strip_bottom, main_w_frac, strip_h_frac]) ax_strip.set_navigate(False) ax_strip.clear() apply_axis_theme(ax_strip, active_theme) ax_strip.axis("off") fig.canvas.draw() plot_structure_strip( ax_strip, profile_strip_render["payload"], zoom=profile_strip_render["zoom"], rotation=profile_strip_render["rotation"], theme_color=profile_strip_render["theme_color"], renderer=profile_strip_render["renderer"], xyzrender_config=profile_strip_render["xyzrender_config"], col_spacing=profile_strip_render["col_spacing"], show_dividers=profile_strip_render["show_dividers"], perspective_tilt=profile_strip_render["perspective_tilt"], width_fill_fraction=profile_strip_render["width_fill_fraction"], ) ax_strip.set_position([left, strip_bottom, main_w_frac, strip_h_frac]) projected_layout_done = True log.info( "Profile content layout: main=%.2f in, strip=%.2f in, figsize=(%.2f, %.2f)", main_h_in, strip_h_in, fig_w, fig_h, ) elif not ax_strip: plt.tight_layout(pad=0.5) elif ax_strip: # Fallback for non-profile strips without a deferred payload. fig.canvas.draw() pos_main = ax.get_position() pos_strip = ax_strip.get_position() gap = 0.04 strip_h = max(pos_strip.height, 0.28) strip_y = max(0.02, pos_main.y0 - gap - strip_h) ax_strip.set_position([pos_main.x0, strip_y, pos_main.width, strip_h]) from matplotlib.offsetbox import AnnotationBbox for artist in ax_strip.get_children(): if isinstance(artist, AnnotationBbox): artist.set_clip_on(True) if output_file: # Content-sized layouts already set figure inches; tight crop is still fine. if projected_layout_done: fig.canvas.draw() fig.savefig( output_file, transparent=False, dpi=dpi, bbox_inches="tight", pad_inches=0.12, facecolor=fig.get_facecolor(), ) else: save_plot(output_file, dpi, has_strip=ax_strip is not None) else: plt.show() return Path(output_file) if output_file else None
[docs] plot_neb = library_plot("neb", plot_neb_from_settings)
@click.command() @click.pass_context @add_config_option @click.option( "--input-dat-pattern", default=DEFAULT_INPUT_PATTERN, help="Glob pattern for input data files.", ) @click.option( "--input-path-pattern", default=DEFAULT_PATH_PATTERN, help="Glob pattern for input path files.", ) @click.option( "--con-file", type=click.Path(exists=True, dir_okay=False, path_type=Path), default=None, help="Path to .con trajectory file.", ) @click.option( "--additional-con", type=( click.Path(exists=True, dir_okay=False, path_type=Path), str, ), # Takes (Path, Label) multiple=True, default=None, help="Path(s) to additional .con file(s) and label.", ) @click.option( "--augment-dat", type=str, default=None, help="Glob pattern for extra .dat files for surface fitting.", ) @click.option( "--augment-con", type=str, default=None, help="Glob pattern for extra .con files for surface fitting.", ) @click.option( "--sp-file", type=click.Path(exists=False, dir_okay=False, path_type=Path), default=Path("sp.con"), help="Path to explicit saddle point file (eOn sp.con).", ) @click.option( "--source", type=click.Choice(["eon", "traj", "hdf5"]), default="eon", help="Data source: 'eon' for .dat/.con pairs, 'traj' for extxyz, 'hdf5' for ChemGP HDF5.", ) @click.option( "--input-h5", type=click.Path(exists=True, dir_okay=False, path_type=Path), default=None, help="Path to ChemGP NEB HDF5 file (result or history).", ) @click.option( "--input-traj", type=click.Path(exists=True, dir_okay=False, path_type=Path), default=None, help="Path to extxyz trajectory file (used with --source traj).", ) @click.option( "--plot-type", type=click.Choice(["profile", "landscape"]), default="profile", help="Type of plot to generate.", ) @click.option( "--rbf-smoothing", type=float, default=None, show_default=True, help="Smoothing term for 2D RBF.", ) @click.option( "--landscape-mode", type=click.Choice(["path", "surface"]), default="surface", help="For landscape plot: 'path' or 'surface'.", ) @click.option( "--landscape-path", type=click.Choice(["last", "all"]), default="all", help="Last uses an interpolation only on the last path, otherwise use all points.", ) @click.option( "--project-path/--no-project-path", is_flag=True, default=True, help="Project landscape coordinates into the reaction valley (s, d).", ) @click.option( "--rc-mode", type=click.Choice(["path", "rmsd", "index"]), default="path", help="Reaction coordinate for profile plot.", ) @click.option( "--plot-structures", type=click.Choice(["none", "all", "crit_points"]), default="none", help="Structures to render on the path. Requires --con-file.", ) @click.option( "--surface-type", type=click.Choice( [ "grid", "rbf", "grad_matern", "grad_imq", "grad_imq_ny", "matern", "imq", "grad_rq", "grad_se", ] ), default="rbf", help="Interpolation method for the 2D surface.", ) @click.option( "--n-inducing", type=int, default=None, help="Number of inducing points for Nystrom or RFF features. Defaults to 300 (Nystrom) or 500 (RFF).", ) @click.option( "--show-pts/--no-show-pts", default=True, help="Show all paths from the optimization on the RMSD 2D plot.", ) @click.option( "--plot-mode", type=click.Choice(["energy", "eigenvalue"]), default="energy", help="Quantity to plot.", ) @click.option( "-o", "--output-file", type=click.Path(path_type=Path), default=None, help="Output image filename.", ) @click.option( "--start", type=int, default=None, help="Start file index for profile plot." ) @click.option("--end", type=int, default=None, help="End file index for profile plot.") @click.option( "--normalize-rc", is_flag=True, default=False, help="Normalize reaction coordinate." ) @click.option("--title", default="NEB Path", help="Plot title.") @click.option("--xlabel", default=None, help="X-axis label.") @click.option("--ylabel", default=None, help="Y-axis label.") @click.option( "--energy-unit", type=click.Choice(["eV", "kcal/mol", "kJ/mol"]), default="eV", show_default=True, help="Presentation unit for energy-like axes and color scales.", ) # --- Theme and Override Options --- @click.option( "--theme", default="ruhi", help="The plotting theme to use.", ) @click.option("--cmap-profile", default=None, help="Colormap for profile plot.") @click.option("--cmap-landscape", default=None, help="Colormap for landscape plot.") @click.option("--facecolor", type=str, default=None, help="Background color.") @click.option("--fontsize-base", type=int, default=None, help="Base font size.") # --- Figure and Inset Options --- @click.option( "--figsize", nargs=2, type=(float, float), default=(5.37, 5.37), show_default=True, help="Figure width, height in inches.", ) @click.option( "--fig-height", type=float, default=None, help="Figure height in inches.", ) @click.option( "--aspect-ratio", type=float, default=None, help="Figure aspect ratio.", ) @click.option( "--dpi", type=int, default=200, show_default=True, help="Resolution in Dots Per Inch.", ) @click.option( "--zoom-ratio", type=float, default=0.5, show_default=True, help="Scale the inset image.", ) @add_render_options @click.option( "--arrow-head-length", type=float, default=0.2, show_default=True, help="Arrow head length.", ) @click.option( "--arrow-head-width", type=float, default=0.3, show_default=True, help="Arrow head width.", ) @click.option( "--arrow-tail-width", type=float, default=0.1, show_default=True, help="Arrow tail width.", ) # --- Path/Spline Options --- @click.option( "--highlight-last/--no-highlight-last", is_flag=True, default=True, help="Highlight last path.", ) @click.option( "--spline-method", type=click.Choice(["hermite", "spline", "none"]), default="hermite", help=( "Profile interpolant: hermite (uses -f_para as dE/drc; path RC only), " "spline (cubic through energies), none (markers + straight segments). " "Image-index RC defaults hermite→spline (force slopes are not dE/dindex)." ), ) # --- Inset Position Options --- @click.option( "--draw-reactant", type=(float, float, float), nargs=3, default=(15, 60, 0.1), show_default=True, help="Reactant inset pos (x, y, rad).", ) @click.option( "--draw-saddle", type=(float, float, float), nargs=3, default=(15, 60, 0.1), show_default=True, help="Saddle inset pos (x, y, rad).", ) @click.option( "--draw-product", type=(float, float, float), nargs=3, default=(15, 60, 0.1), show_default=True, help="Product inset pos (x, y, rad).", ) @click.option( "--cache-file", type=click.Path(path_type=Path), default=Path(".neb_landscape.parquet"), help="Parquet cache file.", ) @click.option( "--force-recompute", is_flag=True, default=False, help="Force re-calculation of RMSD.", ) @click.option( "--mmf-peaks/--no-mmf-peaks", is_flag=True, default=None, help="Overlay OCI/MMF refinement samples on landscape (auto-detected from climb movies).", ) @click.option( "--peak-dir", type=click.Path(exists=True, file_okay=False, path_type=Path), default=None, help="Directory containing OCI/MMF refinement outputs (for example climb/climb.con).", ) @click.option( "--show-evolution", is_flag=True, default=False, help="Show band evolution across iterations (requires write_movies data).", ) @click.option( "--show-legend/--no-legend", default=True, help="Show the legends.", ) @click.option(
[docs] "--ira-kmax", default=IRA_KMAX_DEFAULT, help="kmax factor for IRA.", ) def main(ctx, config, **params): """CLI entry: merge flags/config then run plot_neb_from_settings.""" return run_from_click("neb", plot_neb_from_settings, ctx, config=config, **params)
if __name__ == "__main__": main()