Repository: openmm/openmm — pull request #3791 (cpp)
Title: Fix #3790: Collision rate for MTS BAOAB Langevin integrator now correctly accounts for number of substeps

Previously, `openmm.MTSLangevinIntegrator` would use an effective collision rate that is too large when there were multiple steps, since every substep used the outer timestep instead of the inner substep timestep to apply the friction.

This PR corrects this, using a fix suggested by Charlie Matthews (@c-matthews) in #3790.

Resolved issues:
title: MTSLangevinIntegrator friction bug
body: Hi all, I think I found a bug in the [MTS Langevin Integrator](https://github.com/openmm/openmm/blob/be19e0222ddf66f612016a3c1f687161a53c2396/wrappers/python/openmm/mtsintegrator.py) code.

Currently the routine defines constants `a` and `b` as

https://github.com/openmm/openmm/blob/be19e0222ddf66f612016a3c1f687161a53c2396/wrappers/python/openmm/mtsintegrator.py#L170-L171

and then applies an Ornstein-Uhlenbeck update using these constants _at every substep_

https://github.com/openmm/openmm/blob/be19e0222ddf66f612016a3c1f687161a53c2396/wrappers/python/openmm/mtsintegrator.py#L187-L192

The problem being that if I have $N$ substeps then I effectively am using $N\times$ the friction in simulation, which can affect diffusion coefficients and the like.

An easy fix would be to redefine the `a` and `b` constants to divide the per-substep OU update by the number of total substeps, something like

    total_substeps = math.prod( [group_size for (group_id, group_size) in groups] )
    self.addGlobalVariable("a", math.exp(-friction*dt / total_substeps))
    self.addGlobalVariable("b", math.sqrt(1-math.exp(-2*friction*dt / total_substeps)))

That way the total damping should be correct. An alternative would be to give the user control of how the damping is allocated between the groups.

Cheers - CM

## IMPORTANT INSTRUCTIONS

1. If you edit a python file, you MUST run 'make PythonInstall' inside the build directory
2. If you edit a c++ file, you MUST run 'make -j4 && make install" inside the build directory

Hints:

Fix patch:
diff --git a/wrappers/python/openmm/mtsintegrator.py b/wrappers/python/openmm/mtsintegrator.py
index dcae295562..5014e31a36 100644
--- a/wrappers/python/openmm/mtsintegrator.py
+++ b/wrappers/python/openmm/mtsintegrator.py
@@ -167,8 +167,9 @@ def __init__(self, temperature, friction, dt, groups):
         self.temperature = temperature
         self.friction = friction
         import math
-        self.addGlobalVariable("a", math.exp(-friction*dt))
-        self.addGlobalVariable("b", math.sqrt(1-math.exp(-2*friction*dt)))
+        total_substeps = groups[-1][1]
+        self.addGlobalVariable("a", math.exp(-friction*dt / total_substeps))
+        self.addGlobalVariable("b", math.sqrt(1-math.exp(-2*friction*dt / total_substeps)))
         from openmm.unit import MOLAR_GAS_CONSTANT_R
         self.addGlobalVariable('kT', MOLAR_GAS_CONSTANT_R*temperature)
         self.addPerDofVariable("x1", 0)


Test patch:
diff --git a/wrappers/python/tests/TestIntegrators.py b/wrappers/python/tests/TestIntegrators.py
index 03291e06ce..07a27bae43 100644
--- a/wrappers/python/tests/TestIntegrators.py
+++ b/wrappers/python/tests/TestIntegrators.py
@@ -137,7 +137,7 @@ def testMTSLangevinIntegrator(self):
                 force.setForceGroup(0)
 
         # Create an integrator
-        integrator = MTSLangevinIntegrator(300*kelvin, 1/picosecond, 4*femtoseconds, [(2,1), (1,2), (0,4)])
+        integrator = MTSLangevinIntegrator(300*kelvin, 5/picosecond, 4*femtoseconds, [(2,1), (1,2), (0,4)])
 
         # Run some equilibration.
         context = Context(system, integrator)
@@ -156,13 +156,43 @@ def testMTSLangevinIntegrator(self):
         temperature = averageEnergy*2/(dofs*MOLAR_GAS_CONSTANT_R)
         self.assertTrue(290*kelvin < temperature < 310*kelvin)
 
+    def testMTSLangevinIntegratorFriction(self):
+        """Test the MTSLangevinIntegrator on a force-free particle to ensure friction is properly accounted for (issue #3790)"""
+        # Create a System with a single particle and no forces
+        system = System()
+        system.addParticle(12.0*amu)
+        platform = Platform.getPlatformByName('Reference')
+        initial_positions = [Vec3(0,0,0)]
+        initial_velocities = [Vec3(1,0,0)]
+        nsteps = 125 # number of steps to take
+        collision_rate = 1/picosecond
+        timestep = 4*femtoseconds
+
+        def get_final_velocities(nsubsteps):
+            """Get the final velocity vector after a fixed number of steps for the specified number of substeps"""
+            integrator = MTSLangevinIntegrator(0*kelvin, collision_rate, timestep, [(0,nsubsteps)])
+            context = Context(system, integrator, platform)
+            context.setPositions(initial_positions)
+            context.setVelocities(initial_velocities)
+            integrator.step(nsteps)
+            final_velocities = context.getState(getVelocities=True).getVelocities()
+            del context, integrator
+            return final_velocities
+
+        # Compare sub-stepped MTS with single-step MTS
+        for nsubsteps in range(2,6):
+            mts_velocities = get_final_velocities(nsubsteps)
+            self.assertAlmostEqual(math.exp(-timestep*nsteps*collision_rate), mts_velocities[0].x)
+            self.assertAlmostEqual(0, mts_velocities[0].y)
+            self.assertAlmostEqual(0, mts_velocities[0].z)
+
     def testNoseHooverIntegrator(self):
         """Test partial thermostating in the NoseHooverIntegrator (only API)"""
         pdb = PDBFile('systems/alanine-dipeptide-explicit.pdb')
         ff = ForceField('amber99sbildn.xml', 'tip3p.xml')
         system = ff.createSystem(pdb.topology, nonbondedMethod=PME)
 
-        integrator = NoseHooverIntegrator(1.0*femtosecond) 
+        integrator = NoseHooverIntegrator(1.0*femtosecond)
         integrator.addSubsystemThermostat(list(range(5)), [], 200*kelvin, 1/picosecond, 200*kelvin, 1/picosecond, 3,3,3)
         con = Context(system, integrator)
         con.setPositions(pdb.positions)