Molecular dynamics¶
This tutorial uses liquid water to illustrate molecular dynamics with a trained FlashCart potential in LAMMPS. It examines energy conservation and density in a periodic system of 256 water molecules. The simulations use Kokkos on one GPU and a time step of 0.5 fs. The two calculations probe different properties:
Energy conservation: 50 ps of NVT equilibration at 300 K, followed by 1.2 ns of NVE dynamics. The analysis reports the drift and fluctuation of total energy over the final 1 ns.
Density: 1.2 ns of NPT dynamics at 1 bar, at temperatures from 273.15 K to 373.15 K in 20 K steps. The analysis reports the mean density and its block-averaged standard error over the final 1 ns.
Run the simulations¶
The input files and scripts are in the repository’s examples/md/water directory.
Use a LAMMPS build with ML-IAP, Python support, and CUDA-enabled Kokkos,
available as lmp on PATH. See Build LAMMPS for build instructions.
The model must support hydrogen and oxygen and use eV for energy and Å for distance. It should describe water over the temperatures and densities studied.
From the repository root, select a GPU and run:
CUDA_VISIBLE_DEVICES=0 bash examples/md/water/run.sh models/my_run
Replace 0 with the GPU index for your run. This command runs the complete
example: NVT equilibration followed by one NVE trajectory, then six NPT
trajectories. The simulations run one after another on the GPU you specify.
The script creates the initial box and exports the model with
flashcart-lammps models/my_run --compile-mode default if those files do not
already exist. Existing model exports are reused. If the checkpoint or compilation
setting has changed, run the export again before starting:
flashcart-lammps models/my_run --compile-mode default
With compilation enabled, the model is compiled when first evaluated in each LAMMPS process. See Compiled evaluation in LAMMPS interface.
Logs, trajectories, and saved structures are written to
examples/md/water/results/my_run. The analysis prints its summary to the
terminal. The directory name comes from the final component of the training
directory path. Runs with the same name reuse this output directory and start
the simulations again.
Energy conservation tests the force evaluation and time integration. It does not by itself establish the accuracy of the potential.
Simulation script
#!/usr/bin/env bash
# Water NVE + NPT campaign for one trained model.
#
# CUDA_VISIBLE_DEVICES=0 bash run.sh ../../../models/my_run
#
# Needs `lmp` built with ML-IAP (Python) and Kokkos on PATH. See the LAMMPS
# page of the documentation. Each run takes one GPU.
set -euo pipefail
RUN_DIR=$(realpath "$1") # training output_path
cd "$(dirname "$0")"
OUT=results/$(basename "$RUN_DIR")
LMP="lmp -k on g 1 -sf kk -pk kokkos newton on neigh half"
[ -f water_256.data ] || python build_box.py
[ -f "$RUN_DIR/flashcart-mliap.pt" ] || flashcart-lammps "$RUN_DIR" --compile-mode default
mkdir -p "$OUT"
$LMP -in in.nve -var model "$RUN_DIR/flashcart-mliap.pt" -var out "$OUT" -log "$OUT/log.nve"
for T in 273.15 293.15 313.15 333.15 353.15 373.15; do
$LMP -in in.npt -var model "$RUN_DIR/flashcart-mliap.pt" -var out "$OUT" -var temp "$T" -log "$OUT/log.npt_${T}K"
done
python analyze.py "$OUT"
Build the box¶
The builder places 256 water molecules (768 atoms) in a periodic cube of side 19.706 Å, with random molecular orientations and a fixed seed. It writes a LAMMPS data file and an extended XYZ file. The initial box is unrelaxed. Both LAMMPS inputs minimize it before assigning velocities and starting dynamics.
Water box construction script
"""Build the 256-molecule water box used for the MD runs.
python build_box.py
Places H2O molecules with random orientations (seed 0) on the FCC sites of a
4x4x4 grid in a 19.706 Å periodic cube (about 1 g/cm^3) and writes
water_256.data (LAMMPS) and water_256.extxyz (ASE). The box is not relaxed.
in.nve and in.npt minimize it first.
"""
from pathlib import Path
import numpy as np
from ase import Atoms
from ase.build import molecule
from ase.io import write
BOX = 19.706
FCC = np.array([[0, 0, 0], [0.5, 0.5, 0], [0.5, 0, 0.5], [0, 0.5, 0.5]])
CELLS = 4
SEED = 0
OUT = Path("water_256")
def build():
rng = np.random.default_rng(SEED)
a = BOX / CELLS
box = Atoms(cell=[BOX] * 3, pbc=True)
for i, j, k in np.ndindex(CELLS, CELLS, CELLS):
for site in FCC:
mol = molecule("H2O")
mol.euler_rotate(*rng.uniform(0, 360, 3), center="COM")
mol.translate((np.array([i, j, k]) + site) * a - mol.get_center_of_mass())
box += mol
box.wrap()
return box
def main():
atoms = build()
d = atoms.get_all_distances(mic=True)
np.fill_diagonal(d, 9.0)
print(f"{len(atoms)} atoms, box {BOX} A, min distance {d.min():.3f} A")
comment = f"256 H2O on FCC sites of a 4x4x4 cell, random orientations (seed {SEED}), unrelaxed (build_box.py)"
data = OUT.with_suffix(".data")
write(data, atoms, format="lammps-data", atom_style="atomic", specorder=["H", "O"], masses=True)
lines = data.read_text().splitlines()
lines[0] = comment
data.write_text("\n".join(lines) + "\n")
atoms.info["comment"] = comment
write(OUT.with_suffix(".extxyz"), atoms, format="extxyz")
print(f"wrote {data} and {OUT.with_suffix('.extxyz')}")
if __name__ == "__main__":
main()
LAMMPS inputs¶
# Energy conservation: minimize, 50 ps NVT (300 K by default), then 1.2 ns NVE (0.5 fs steps).
#
# lmp -k on g 1 -sf kk -pk kokkos newton on neigh half -in in.nve \
# -var model /abs/path/flashcart-mliap.pt -var out results/<name>
variable model index flashcart-mliap.pt
variable out index results
variable temp index 300.0 # NVT pre-equilibration
variable eqsteps index 100000 # 50 ps NVT before the NVE stage
variable steps index 2400000 # 1.2 ns NVE at 0.5 fs
variable dt index 0.0005 # ps
variable seed index 4928459
variable tdamp index 0.2 # ps
variable thermoevery index 200 # 0.1 ps
variable dumpevery index 4000 # 2 ps
shell mkdir -p ${out}
units metal
atom_style atomic
boundary p p p
newton on
read_data water_256.data
pair_style mliap unified ${model} 0
pair_coeff * * H O
neighbor 1.0 bin
neigh_modify every 1 delay 0 check yes
timestep ${dt}
thermo ${thermoevery}
thermo_style custom step time temp pe ke etotal press
thermo_modify format float %18.8f flush yes
minimize 1.0e-4 1.0e-6 300 3000
reset_timestep 0
velocity all create ${temp} ${seed} mom yes rot yes dist gaussian
fix eq all nvt temp ${temp} ${temp} ${tdamp}
run ${eqsteps}
unfix eq
write_data ${out}/nve_start.data nocoeff
reset_timestep 0
fix nve all nve
dump traj all custom ${dumpevery} ${out}/nve.lammpstrj id type element x y z ix iy iz
dump_modify traj sort id element H O
run ${steps}
# Density: minimize, then 1.2 ns NPT at 1 bar (300 K by default, set -var temp) with 0.5 fs steps.
#
# lmp -k on g 1 -sf kk -pk kokkos newton on neigh half -in in.npt \
# -var model /abs/path/flashcart-mliap.pt -var out results/<name> -var temp 320
variable model index flashcart-mliap.pt
variable out index results
variable temp index 300.0 # K
variable press index 1.0 # bar
variable steps index 2400000 # 1.2 ns at 0.5 fs
variable dt index 0.0005 # ps
variable seed index 4928459
variable tdamp index 0.2 # ps
variable pdamp index 1.0 # ps
variable thermoevery index 200 # 0.1 ps
variable dumpevery index 4000 # 2 ps
shell mkdir -p ${out}
units metal
atom_style atomic
boundary p p p
newton on
read_data water_256.data
pair_style mliap unified ${model} 0
pair_coeff * * H O
neighbor 1.0 bin
neigh_modify every 1 delay 0 check yes
timestep ${dt}
thermo ${thermoevery}
thermo_style custom step time temp pe ke etotal press vol density
thermo_modify format float %18.8f flush yes
minimize 1.0e-4 1.0e-6 300 3000
reset_timestep 0
velocity all create ${temp} ${seed} mom yes rot yes dist gaussian
fix npt all npt temp ${temp} ${temp} ${tdamp} iso ${press} ${press} ${pdamp}
dump traj all custom ${dumpevery} ${out}/npt_${temp}K.lammpstrj id type element x y z ix iy iz
dump_modify traj sort id element H O
run ${steps}
write_data ${out}/npt_${temp}K_final.data nocoeff
Analysis¶
The analysis reports the slope of a linear fit to total energy as the energy drift in meV/atom/ns. Energy fluctuations are the standard deviation of total energy per atom in meV/atom, without subtracting the fitted drift.
For NPT trajectories, the analysis reports the mean density in g/cm³ and estimates its standard error from 50 ps block averages. The standard error quantifies uncertainty in the mean density due to the finite trajectory length. Compare densities with experimental values at matching temperatures and pressure. Simulation speed in ns/day is taken from the LAMMPS performance summary for the production run.
To repeat the analysis without rerunning the simulations:
python examples/md/water/analyze.py examples/md/water/results/my_run \
--analysis-ps 1000 --block-ps 50
The default atom count is 768. If you change the box size, pass --n-atoms so that
the energy drift and fluctuation are divided by the correct number of atoms.
Analysis script
"""Analyze energy conservation, density, and speed from the water simulations.
python analyze.py results/<name>
Read log.nve and log.npt_<T>K from the run directory. Over the final
--analysis-ps of each trajectory, report energy drift in meV/atom/ns,
energy fluctuations in meV/atom, and density with a block-averaged standard
error. Simulation speed comes from the LAMMPS production-run summary.
"""
import argparse
import re
from pathlib import Path
import numpy as np
PERF_RE = re.compile(r"Performance: ([\d.eE+-]+) ns/day")
NVE_BLOCK, NPT_BLOCK = 2, 1
def read_log(path, block):
"""Thermo columns of one run block of a LAMMPS log and its ns/day (None if not reached)."""
blocks = [] # [columns, rows, ns_per_day]
for line in path.read_text().splitlines():
parts = line.split()
perf = PERF_RE.search(line)
if parts and parts[0] == "Step":
blocks.append([parts, [], None])
elif blocks and perf:
blocks[-1][2] = float(perf[1])
elif blocks and len(parts) == len(blocks[-1][0]):
try:
blocks[-1][1].append([float(p) for p in parts])
except ValueError:
pass
if len(blocks) <= block or len(blocks[block][1]) < 2:
return None
cols, rows, ns_per_day = blocks[block]
table = np.array(rows)
return {c: table[:, i] for i, c in enumerate(cols)}, ns_per_day
def block_sem(x, t, block_ps):
"""Standard error of the mean from block averages of length block_ps."""
edges = np.arange(t[0], t[-1] + block_ps, block_ps)
means = [x[(t >= a) & (t < b)].mean() for a, b in zip(edges[:-1], edges[1:]) if ((t >= a) & (t < b)).any()]
return float(np.std(means, ddof=1) / np.sqrt(len(means))) if len(means) > 1 else float("nan")
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("run_dir", type=Path)
parser.add_argument("--n-atoms", type=int, default=768)
parser.add_argument("--analysis-ps", type=float, default=1000.0, help="final window analyzed, ps")
parser.add_argument("--block-ps", type=float, default=50.0, help="block length for standard errors, ps")
args = parser.parse_args()
log = args.run_dir / "log.nve"
if log.exists() and (parsed := read_log(log, NVE_BLOCK)) is not None:
thermo, ns_per_day = parsed
t = thermo["Time"]
w = t >= t[-1] - args.analysis_ps
e = 1e3 * thermo["TotEng"][w] / args.n_atoms # meV/atom
slope = np.polyfit(t[w], e, 1)[0] # meV/atom/ps
print(f"NVE: drift {1e3 * slope:.4f} meV/atom/ns, std {e.std():.4f} meV/atom, "
f"T {thermo['Temp'][w].mean():.1f} K, {ns_per_day} ns/day")
for log in sorted(args.run_dir.glob("log.npt_*K")):
parsed = read_log(log, NPT_BLOCK)
if parsed is None:
continue
thermo, ns_per_day = parsed
t = thermo["Time"]
w = t >= t[-1] - args.analysis_ps
rho = thermo["Density"][w]
print(f"NPT {log.name[8:]}: density {rho.mean():.4f} ± {block_sem(rho, t[w], args.block_ps):.4f} g/cm^3, "
f"T {thermo['Temp'][w].mean():.1f} K, {ns_per_day} ns/day")
if __name__ == "__main__":
main()
For molecular dynamics with ASE, see Molecular dynamics in ASE calculator.