torch-sim

torch-sim is a tool for running (batched) molecular dynamics and structure optimisations in a GPU-accelerated manner.

You can use any model from graph-pes with torch-sim! Doing this is extremely straightforward, as this notebook hopes to demonstrate. One caveat is that torch-sim expects all models to return energies and stresses in units of eV and forces in units of eV/Å. If you have a model that was trained in a different unit system, you will need to wrap it in a graph_pes.models.UnitConverter object so that it can be used in torch-sim.

To get started, you must first ensure you have the torch-sim-atomistic package installed:

[ ]:
# this notebook ran successfully with this torch-sim version.
# future versions may require changes as the package evolves.
!pip install graph-pes torch-sim-atomistic==0.2.0
[1]:
from pathlib import Path

import ase
import ase.io
import load_atoms
import torch
import torch_sim
from ase.build import bulk

from graph_pes import models

%config InlineBackend.figure_format = 'retina'
torch.manual_seed(42)  # for reproducibility

DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {DEVICE}")
[1]:
Using device: cpu

NVT MD

torch-sim makes it easy to run NVT molecular dynamics using a Langevin thermostat.

To keep things simple, we use a simple LennardJones model to run NVT for a single structure. It’s worth repeating that this proceduce will be identical for any other model in graph-pes, be that a model you’ve trained from scratch yourself, a pre-trained foundation model or a foundation model you have fine-tuned on your own data.

[2]:
# make a model: this could be any model from `graph-pes`
model = models.LennardJones(sigma=1.5, epsilon=1.0)
ts_model = model.torch_sim_model(device=DEVICE)

# create a bulk Cu structure using ASE
structure = bulk("Cu", "fcc", a=3.58, cubic=True).repeat(4)

# NVT at 300 K with a timestep of 0.002 fs
torch_sim.integrate(
    system=structure,
    model=ts_model,
    n_steps=500,
    timestep=0.002,
    temperature=300,
    integrator=torch_sim.nvt_langevin,
    trajectory_reporter=torch_sim.TrajectoryReporter(
        "Cu-traj.h5md",
        state_frequency=10,  # save every 10 steps
        state_kwargs=dict(save_velocities=True, save_forces=True),
    ),
)

# post-process the trajectory to save as an ASE-compatible file
trajectory = torch_sim.TorchSimTrajectory("Cu-traj.h5md")
trajectory.write_ase_trajectory("Cu-traj.traj")
load_atoms.view(ase.io.read("Cu-traj.traj", index="-1"))
[2]:

Of course, a Lennard-Jones model is not particularly realistic for this case, but we can see that MD has generated a collection of Lennard-Jones clusters as expected.

torch-sim saves structure snapshots in a custom format. To extract ase.Atoms objects with the force and velocities properties, we can use the following helper functions:

[3]:
def get_atoms_with_labels(trajectory: torch_sim.TorchSimTrajectory, index: int):
    atoms = trajectory.get_atoms(index)
    for property in ["forces", "velocities"]:
        if property not in trajectory.array_registry:
            continue
        atoms.arrays[property] = trajectory.get_array(
            property, index, index + 1
        )[0]
    return atoms


def trajectory_to_ase_structures(file_name: str | Path) -> list[ase.Atoms]:
    traj = torch_sim.TorchSimTrajectory(file_name)
    atoms = []
    i = 0
    while True:
        try:
            atoms.append(get_atoms_with_labels(traj, i))
        except IndexError:
            break
        i += 1
    return atoms


structures = trajectory_to_ase_structures("Cu-traj.h5md")
structures[0].arrays["forces"].shape
[3]:
(256, 3)

NPT MD

torch-sim also supports NPT molecular dynamics using a Langevin barostat and thermostat. Below, we do just that.

Note that graph-pes models return stresses in the energy and length-scale units that they were originally trained on: typically this is eV and Å, but not always. Use a graph_pes.models.UnitConverter to convert the energies to eV and lengths to Å if necessary.

torch-sim expects the external pressure provided to be in units of eV/Å^3.

[4]:
amorphous_carbon_structures = load_atoms.load_dataset("C-GAP-17").filter_by(
    lambda structure: len(structure) < 50,
    config_type="bulk_amo",
)[:100]
amorphous_carbon_structures
[4]:
Dataset:
    structures: '100'
    atoms: 3,552
    species:
        C: 100.00%
    properties:
        per atom: (forces)
        per structure: (config_type, detailed_ct, energy, split)

Here we load in a model trained on part of the C-GAP-17 dataset:

[5]:
from graph_pes.utils.analysis import parity_plot

carbon_model = models.load_model("c-gap-17-painn.pt")
parity_plot(
    carbon_model,
    amorphous_carbon_structures,
    "forces",
    lw=0,
    s=3,
    units="eV/Å",
)
../_images/tools_torch-sim_10_0.png

We can see that this model generates stress predictions as expected - graph-pes automatically calculates stresses for all models, allowing them to be used in NPT simulations.

[6]:
carbon_model.ase_calculator().get_stress(amorphous_carbon_structures[0])
[6]:
array([ 0.07337982,  0.06381519,  0.08241484,  0.00042613, -0.01667964,
        0.00498147])

We start with a low-density amorphous carbon structure:

[7]:
starting_structure = amorphous_carbon_structures[0].copy()
load_atoms.view(starting_structure, show_bonds=True)
[7]:
[ ]:
import numpy as np
import matplotlib.pyplot as plt


# negative sign shows that we want to **compress** the system
desired_pressure = -1000  # in bar
bar_to_eV_per_A3 = 6.2415e-07

print(f"Starting cell:\n{starting_structure.cell.array.round(2)}\n")

# run NPT
final_state = torch_sim.integrate(
    system=starting_structure,
    model=carbon_model.torch_sim_model(device=DEVICE),
    n_steps=1_000,
    timestep=0.001,
    temperature=300,
    integrator=torch_sim.npt_langevin,
    external_pressure=-desired_pressure * bar_to_eV_per_A3,
    trajectory_reporter=torch_sim.TrajectoryReporter(
        "test.h5md", state_frequency=10
    ),
    b_tau=0.1,
)
trajectory = torch_sim.TorchSimTrajectory("test.h5md")
cells = trajectory.get_array("cell")
print(f"Final cell:\n{cells[-1].round(2)}")
Starting cell:
[[9.1  0.   0.  ]
 [0.   9.1  0.  ]
 [0.   0.   6.83]]

Final cell:
[[7.86 0.   0.  ]
 [0.   7.86 0.  ]
 [0.   0.   5.9 ]]

We can see that the NPT simulation has compressed the system as expected

[9]:
volumes = np.linalg.det(cells)
plt.plot(volumes)
[9]:
[<matplotlib.lines.Line2D at 0x307389a50>]
../_images/tools_torch-sim_17_1.png
[10]:
load_atoms.view(final_state.to_atoms()[0], show_bonds=True)
[10]:

You can also express a (3, 3) shaped torch.Tensor as an anisotropic external pressure. See the torch-sim docs for more details.

Relaxation

Relaxation in torch-sim is provided via torch_sim.optimize(). We relax the final state of the NPT simulation:

[ ]:
relaxed_state = torch_sim.optimize(
    system=final_state,
    model=carbon_model.torch_sim_model(device=DEVICE),
    optimizer=torch_sim.frechet_cell_fire,
)
load_atoms.view(relaxed_state.to_atoms()[0], show_bonds=True)

Batched simulations

The real power of torch-sim comes when you want to run many simulations at once.

Here’s a simple example of how to run NVE MD for a collection of structures simultaneously. Crucially, you can use this batching process with any of the above examples too!

[12]:
from graph_pes.interfaces import mace_off

# use the MACE-OFF (small) model to run NVE simulations
# since these structure have no unit cell, it is important to set
# compute_stress=False to avoid an error
mace_off_torch_sim = mace_off("small").torch_sim_model(
    device=DEVICE, dtype=torch.float32, compute_stress=False
)
Using MACE-OFF23 MODEL for MACECalculator with /Users/john/.cache/mace/MACE-OFF23_small.model
[13]:
# load the first 100 structures from the QM7 dataset
structures = load_atoms.load_dataset("QM7")[:100]

# run batched NVE simulations for multiple structures simultaneously
# letting torch-sim handle batching and memory management for us
final_states = torch_sim.integrate(
    system=list(structures),
    model=mace_off_torch_sim,
    n_steps=100,
    timestep=0.001,
    temperature=300,
    integrator=torch_sim.nve,
    trajectory_reporter=torch_sim.TrajectoryReporter(
        # save each trajectory to a unique file
        [f"QM7-traj-{i}.h5md" for i in range(len(structures))],
        state_frequency=10,
    ),
)