PLUMED post-processing

Build a selection string, sum the hills, and plot the free-energy surface:

from ase.io import read
from reactiontools import find_molecules, plumed_selection, run_sum_hills, plot_plumed

atoms = read("system.xyz")
solute = find_molecules(atoms)[0]
print(plumed_selection(solute))  # e.g. "1-9,14" — one-based, as PLUMED expects

run_sum_hills(hills="HILLS", outfile="fes.dat", mintozero=True)
plot_plumed("fes.dat", x_label="CV (Å)")

Comparing several runs laid out as <run>/fes.dat — pass the parent directory and every surface beneath it is found and labelled by run name:

from reactiontools import plot_plumed_multi

plot_plumed_multi("runs/", mintozero=True, x_label="CV (Å)")

Reading the numbers off a surface

summarise_fes measures a 1-D profile: the barrier each way, and the free energy of one basin relative to the other. Give it the two basins as windows on the collective variable — reading them off the plot is the point:

from reactiontools import summarise_fes

print(
    summarise_fes("fes.dat", basin_a=(1.0, 2.0), basin_b=(3.0, 4.5), source_unit="eV")
)
Barrier A->B:  0.352 eV
Barrier B->A:  0.418 eV
Delta F (B-A): -0.066 eV
Minima at:     1.5, 3.75
Barrier at:    2.6

Windows rather than automatic minimum-finding, because a noisy surface has local minima everywhere, and because a convergence series only means anything if every surface is measured the same way.

By default delta_f is the difference between the two minima. Pass a temperature and it comes instead from integrating the Boltzmann weight across each basin, which counts a wide basin as more probable than a narrow one of the same depth:

summarise_fes(
    "fes.dat", basin_a=(1.0, 2.0), basin_b=(3.0, 4.5), source_unit="eV", temperature=300
)

The barriers are measured out of the bottom of each well either way, since that is what a barrier is.

Has it converged?

stride sums the hills every so often instead of once at the end, giving a series of surfaces. The run has converged when the last few lie on top of each other:

from reactiontools import plot_fes_1d, run_sum_hills, sum_hills_files

run_sum_hills(stride=100, outfile="fes", grid_min=1.0, grid_max=6.0, grid_bin=500)

surfaces = sum_hills_files("fes")
plot_fes_1d(
    surfaces,
    source_unit="eV",
    max_datasets=5,
    labels=[(i + 1) * 100 for i in range(len(surfaces))],
    label_template="{:g} hills",
)

Set the grid explicitly for a series. Without it PLUMED picks bounds per surface from the hills it has so far, and the surfaces come back on grids that do not line up.

Surfaces lying on top of each other is the loose version of the test. The one that matters is whether the numbers have stopped moving, which plot_fes_convergence draws directly — the barrier and the basin difference against time:

from reactiontools import fes_convergence, plot_fes_convergence

hills = [(i + 1) * 100 for i in range(len(surfaces))]
plot_fes_convergence(
    surfaces,
    basin_a=(1.0, 2.0),
    basin_b=(3.0, 4.5),
    times=hills,
    source_unit="eV",
    filename="convergence",
)

fes_convergence returns the underlying FESSummary per surface if you want the numbers rather than the picture.

sum_hills_files exists because the naming is a trap. --stride does not number the file it was given — it writes f"{outfile}{n}.dat", so the default outfile="fes.dat" yields fes.dat0.dat, and the obvious glob fes*.dat is right only by accident. Worse, sorting those names puts fes.dat10.dat before fes.dat2.dat, which for a convergence series silently scrambles the very thing being plotted. sum_hills_files orders them by the index PLUMED gave them. Passing outfile="fes" at least gets you fes0.dat.

To project a surface onto some of its variables, name the ones to keep and give the temperature the rest are integrated out at, in the energy units of the hills — eV for a run built by plumed_metad_input:

from ase.units import kB

run_sum_hills(idw="d1", kt=kB * 300, outfile="fes_d1.dat")

There has to be something left to integrate out, so this needs a run biased on two variables or more. On a one-variable HILLS PLUMED does not report a usable error — it aborts the process on a failed internal assertion, proj.size()<getNumberOfArguments(), which surfaces here as a CalledProcessError with a signal rather than an exit code.

Anything without its own keyword goes through extra, for example extra=["--fmt", "%14.9f"].

Combining independent walkers

Several independent OPES runs of the same system — walkers — are combined at analysis time by reweighting: every COLVAR row carries the bias its own walker collected it under, so the merged samples reweight as one. Merge sorted by time to treat them as a single series (what stride and skiprows expect), or unsorted to keep each walker contiguous and get cross-walker error bars from blocks:

from reactiontools import combine_colvar_files, run_opes_reweighting

combine_colvar_files(
    [f"walker_{i:03d}/COLVAR" for i in range(4)], sort_by_time=False
)
run_opes_reweighting(
    sigma=0.1, kt=2.494, cv="phi",
    grid_min=-3.14, grid_max=3.14, grid_bin=100, blocks=4,
)

For a single run, run_opes_reweighting is instead a cross-check on run_opes_fes: one surface from the samples, one from the bias’s own running estimate, and daylight between them means the run is not converged.