#!/usr/bin/env python3
"""Plot general readcon CON (or chemfiles) trajectories.
Energy/force profiles, multi-file overlays, optional structure strips.
Unlike ``plt-min`` / ``plt-neb``, no eOn job directory or sidecar ``.dat``.
.. versionadded:: 1.10.5
"""
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "click",
# "matplotlib",
# "numpy",
# "polars",
# "ase",
# "rich",
# "chemparseplot[neb,plot]>=1.9.17,<2",
# "readcon>=0.13.1",
# "rgpycrumbs>=1.10.4",
# ]
# ///
from __future__ import annotations
import logging
import math
from pathlib import Path
import click
import matplotlib.pyplot as plt
try:
from rgpycrumbs._aux import warn_on_direct_script_import
except ImportError: # pragma: no cover
[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-con")
from chemparseplot.api import load_trajectory, plot_con_overlay, plot_con_overview
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]
def _has_forces(traj) -> bool:
return (
traj.table is not None
and not traj.table.is_empty()
and "fmax" in traj.table.columns
and traj.table["fmax"].null_count() < traj.table.height
)
@click.command()
@click.argument(
"con_files",
nargs=-1,
required=True,
type=click.Path(exists=True, dir_okay=False, path_type=Path),
)
@click.option(
"--output",
"-o",
type=click.Path(path_type=Path),
default=None,
help="Output figure (default: <first_stem>_profile.pdf).",
)
@click.option(
"--label",
"labels",
type=str,
multiple=True,
help="Legend label per input (must match count when multi-file).",
)
@click.option(
"--energy-unit",
type=click.Choice(["eV", "kcal/mol", "kJ/mol"]),
default="eV",
show_default=True,
)
@click.option(
"--absolute/--relative",
default=False,
help="Absolute energies vs ΔE from each trajectory's first frame.",
)
@click.option(
"--no-forces",
is_flag=True,
default=False,
help="Skip force panel (single-traj mode only).",
)
@click.option(
"--structures",
type=click.Choice(["none", "endpoints", "linspace", "all"]),
default="none",
show_default=True,
help="Structure strip mode (single-traj; ase renderer by default).",
)
@click.option(
"--max-structs",
type=int,
default=8,
show_default=True,
help="Cap on strip structures for linspace/all.",
)
@click.option(
"--strip-renderer",
type=click.Choice(["ase", "xyzrender"]),
default="ase",
show_default=True,
)
@click.option("--dpi", type=int, default=200, show_default=True)
@click.option("--title", type=str, default=None)
[docs]
def main(
con_files,
output,
labels,
energy_unit,
absolute,
no_forces,
structures,
max_structs,
strip_renderer,
dpi,
title,
):
"""Plot CON/chemfiles trajectory profile(s).
One file: energy (+ forces + optional structure strip).
Multiple files: energy overlay only.
"""
paths = list(con_files)
trajs = [load_trajectory(p) for p in paths]
for p, traj in zip(paths, trajs, strict=False):
n_e = int(sum(1 for v in traj.energies if math.isfinite(v)))
log.info(
"Loaded %s: %d frames, energy finite=%d, forces=%s, source=%s",
p.name,
traj.n_frames,
n_e,
"yes" if _has_forces(traj) else "no",
traj.source,
)
if len(trajs) == 1:
fig = plot_con_overview(
trajs[0],
energy_unit=energy_unit,
relative=not absolute,
show_forces=not no_forces,
structures=structures,
max_structs=max_structs,
strip_renderer=strip_renderer,
title=title or paths[0].name,
)
else:
if labels and len(labels) != len(trajs):
raise click.UsageError(
f"--label count ({len(labels)}) must match files ({len(trajs)})"
)
fig, ax = plt.subplots(figsize=(5.37, 3.8), constrained_layout=True)
plot_con_overlay(
trajs,
labels=list(labels) if labels else None,
ax=ax,
energy_unit=energy_unit,
relative=not absolute,
title=title or "CON overlay",
)
out = output or paths[0].with_name(f"{paths[0].stem}_profile.pdf")
fig.savefig(out, dpi=dpi, bbox_inches="tight")
log.info("Wrote %s", out)
plt.close(fig)
if __name__ == "__main__":
main()