Skip to content

OpenMM Integration Guide

This guide explains how to use GADES with OpenMM, covering the architecture behind the integration and the constraints you need to respect when setting up your simulation.

Quick Start

from GADES import createGADESBiasForce, GADESForceUpdater
from GADES.backend import OpenMMBackend
from GADES.utils import compute_hessian_force_fd_richardson as hessian

import numpy as np
import openmm.app as app
from openmm import unit, Platform
from openmm.openmm import LangevinIntegrator

# 1. Load topology
pdb = app.PDBFile("your_system.pdb")
forcefield = app.ForceField('amber14/protein.ff14SB.xml', 'amber14/tip3p.xml')
system = forcefield.createSystem(pdb.topology, nonbondedMethod=app.PME, constraints=app.HBonds)

# 2. Choose atoms to bias
biasing_atom_ids = np.array([
    atom.index for atom in pdb.topology.atoms() if atom.name == 'CA'
])

# 3. Add GADES force to the system — must happen before Simulation is created
GAD_force = createGADESBiasForce(system.getNumParticles())
system.addForce(GAD_force)

# 4. Create simulation
integrator = LangevinIntegrator(300 * unit.kelvin, 1 / unit.picosecond, 2 * unit.femtoseconds)
simulation = app.Simulation(pdb.topology, system, integrator)
simulation.context.setPositions(pdb.positions)
simulation.minimizeEnergy()

# 5. Attach GADES reporter
backend = OpenMMBackend(simulation)
simulation.reporters.append(
    GADESForceUpdater(
        backend=backend,
        biased_force=GAD_force,
        bias_atom_indices=biasing_atom_ids,
        hess_func=hessian,
        clamp_magnitude=1000,
        kappa=0.9,
        interval=500,
        stability_interval=1000,
        logfile_prefix="gades_run",
    )
)

simulation.step(1_000_000)

See the OpenMM script templates for annotated full-script examples.


How it works

The GADES bias force

createGADESBiasForce creates an OpenMM CustomExternalForce with the energy expression:

E = -fx*x - fy*y - fz*z

This means the force on each particle is exactly (fx, fy, fz) — the per-particle parameters are the force components directly. All particles are initialised with (0, 0, 0), so the force has no effect until the first bias update.

force = CustomExternalForce("-fx*x-fy*y-fz*z")
force.addPerParticleParameter("fx")
force.addPerParticleParameter("fy")
force.addPerParticleParameter("fz")
for i in range(n_particles):
    force.addParticle(i, [0.0, 0.0, 0.0])

Force groups

The GADES force is assigned to force group 1. All standard force field terms (bonded, non-bonded, PME) default to group 0. This separation is critical: when computing the Hessian, OpenMMBackend.get_forces() requests forces from group 0 only, so the bias does not contaminate the curvature estimate:

state = context.getState(getForces=True, groups={0})   # unbiased forces only

The full simulation (dynamics) includes both groups, as normal.

Don't reassign your force field forces to group 1

If any of your physical forces are in group 1, they will be excluded from the Hessian calculation and GADES will compute the wrong curvature. All standard OpenMM force fields default to group 0, so this is only a concern if you manually set setForceGroup(1) on another force.

The Reporter pattern

GADESForceUpdater is an OpenMM Reporter. OpenMM calls two methods on every reporter at each relevant step:

  • describeNextReport(simulation) — returns how many steps until the next event and what data is needed
  • report(simulation, state) — performs the actual work

GADES uses describeNextReport to schedule the next bias update, stability check, or post-bias stability check, whichever comes first. At each scheduled step, report either recomputes the GAD force and pushes it to the context, or removes the bias if instability is detected.

Force updates are pushed with updateParametersInContext, which modifies the live context without rebuilding the simulation:

for i, idx in enumerate(bias_atom_indices):
    bias_force_object.setParticleParameters(idx, idx, tuple(biased_force_values[i]))
bias_force_object.updateParametersInContext(simulation.context)

Setup constraints

The GADES force must be added before app.Simulation

OpenMM finalises the force list when the Simulation (and its underlying Context) is created. Forces added after this point are not registered in the context and will have no effect.

# Correct
GAD_force = createGADESBiasForce(system.getNumParticles())
system.addForce(GAD_force)                        # before Simulation
simulation = app.Simulation(topology, system, integrator)

# Wrong — GAD_force will be silently ignored
simulation = app.Simulation(topology, system, integrator)
system.addForce(GAD_force)                        # too late

OpenMMBackend takes the Simulation, not the System

The backend wraps the live Simulation object (which contains the Context, Integrator, and System). It must be created after the simulation is initialised:

backend = OpenMMBackend(simulation)   # after Simulation(...) and setPositions(...)

Comparison with the ASE backend

Aspect OpenMM backend ASE backend
Force application CustomExternalForce updated via updateParametersInContext GADESCalculator wrapper added to bias calculator results
Bias scheduling OpenMM Reporter interface (describeNextReport / report) Called inside GADESCalculator.calculate()
Circular dependency None — force object and backend are independent Yes — solved by ASEBackend.with_gades() factory
Setup complexity Moderate — force must be added before Simulation Handled by factory method
Recommended pattern GADESForceUpdater as reporter ASEBackend.with_gades()

Key parameters

Parameter Description Typical values
kappa Bias scaling factor. Controls how strongly forces are projected onto the softest mode. 0.5 – 0.9
clamp_magnitude Maximum per-atom bias force (kJ/mol/nm). Prevents unphysically large updates. 500 – 2000
interval Steps between Hessian updates. Smaller = more accurate bias direction, higher cost. 200 – 2000
stability_interval Steps between temperature stability checks. Bias is removed if temperature deviates too far. 500 – 2000
logfile_prefix If set, writes eigenvector, eigenvalue, and biased-atom trajectory logs. any string

Parameter recommendations

kappa

Set kappa=0.9 and control the strength of the bias through clamp_magnitude instead:

GADESForceUpdater(
    ...
    kappa=0.9,                  # recommended value
    clamp_magnitude=1000,       # adjust this to control exploration aggressiveness
)

kappa damps all forces uniformly, while clamp_magnitude only acts on atoms where the bias would otherwise exceed the cap. Using a high κ with a moderate clamp gives effective exploration without over-biasing low-gradient regions.

stability_interval

Set stability_interval to interval // 2 or smaller to catch instabilities before they propagate:

GADESForceUpdater(
    ...
    interval=500,
    stability_interval=200,     # roughly interval // 2
)

hess_func

Use compute_hessian_force_fd_richardson (Richardson-extrapolated finite differences). It is less sensitive to step size than a plain single-step finite difference and reduces numerical error in the Hessian:

from GADES.utils import compute_hessian_force_fd_richardson as hessian

GADESForceUpdater(..., hess_func=hessian)

Advanced options

Eigensolver

By default, GADESForceUpdater uses numpy.linalg.eigh for the full Hessian eigendecomposition. For large biased subsets, this becomes expensive. Switch to Lanczos:

GADESForceUpdater(
    ...
    eigensolver="lanczos",        # or "lanczos_hvp" for very large systems
    lanczos_iterations=30,
)

See the large systems guide for details on when to use each solver.

Bofill Hessian update

To reduce the number of full Hessian evaluations, enable the Bofill quasi-Newton update, which approximates the Hessian between full recomputations:

GADESForceUpdater(
    ...
    use_bofill_update=True,
    full_hessian_interval=2000,   # recompute full Hessian every 2000 steps
)

Stability checking

OpenMMBackend estimates the instantaneous temperature from the kinetic energy and compares it to the integrator's target temperature. If the deviation exceeds the threshold (default 50 K), the bias is removed until the next update cycle.

For integrators where temperature cannot be auto-detected (e.g. VerletIntegrator), set it explicitly:

backend = OpenMMBackend(simulation, target_temperature=300.0)

Running without bias

Set BIASED = 0 and skip attaching the reporter. The GAD_force is still added to the system (required to keep the context valid for potential later use), but with all-zero parameters it contributes nothing to the dynamics.

GAD_force = createGADESBiasForce(system.getNumParticles())
system.addForce(GAD_force)
simulation = app.Simulation(...)

# No GADESForceUpdater — plain MD with zero-cost bias force
simulation.step(n_steps)