AmberPrmtopFile accepts box vectors as a constructor argument (openmm/openmm#4094)

See #4078 for a discussion of the problems this solves.

The `AmberPrmtopFile` class does not currently allow users to specify periodic box vectors directly when creating an instance. This limitation requires users to set the periodic box vectors separately after creating the object, which can lead to redundant code and potential errors if the vectors are not set correctly.

The expected behavior is that users should be able to provide periodic box vectors as an optional argument when initializing an `AmberPrmtopFile` object. This would streamline the workflow, reduce the risk of errors, and improve code clarity by allowing the box vectors to be specified directly during object creation.

## 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

diff --git a/docs-source/usersguide/application/02_running_sims.rst b/docs-source/usersguide/application/02_running_sims.rst
index 080634d2b5..5605a1dedb 100644
--- a/docs-source/usersguide/application/02_running_sims.rst
+++ b/docs-source/usersguide/application/02_running_sims.rst
@@ -190,7 +190,7 @@ Using AMBER Files
 OpenMM can build a system in several different ways.  One option, as shown
 above, is to start with a PDB file and then select a force field with which to
 model it.  Alternatively, you can use AmberTools_ to model your system.  In that
-case, you provide a :class:`prmtop` file and an :class:`inpcrd` file.  OpenMM loads the files and
+case, you provide a :file:`prmtop` file and an :file:`inpcrd` file.  OpenMM loads the files and
 creates a :class:`System` from them.  This is illustrated in the following script.  It can be
 found in OpenMM’s :file:`examples` folder with the name :file:`simulateAmber.py`.

@@ -202,15 +202,13 @@ found in OpenMM’s :file:`examples` folder with the name :file:`simulateAmber.p
         from openmm.unit import *
         from sys import stdout

-        prmtop = AmberPrmtopFile('input.prmtop')
         inpcrd = AmberInpcrdFile('input.inpcrd')
+        prmtop = AmberPrmtopFile('input.prmtop', periodicBoxVectors=inpcrd.boxVectors)
         system = prmtop.createSystem(nonbondedMethod=PME, nonbondedCutoff=1*nanometer,
                 constraints=HBonds)
         integrator = LangevinMiddleIntegrator(300*kelvin, 1/picosecond, 0.004*picoseconds)
         simulation = Simulation(prmtop.topology, system, integrator)
         simulation.context.setPositions(inpcrd.positions)
-        if inpcrd.boxVectors is not None:
-            simulation.context.setPeriodicBoxVectors(*inpcrd.boxVectors)
         simulation.minimizeEnergy()
         simulation.reporters.append(PDBReporter('output.pdb', 1000))
         simulation.reporters.append(StateDataReporter(stdout, 1000, step=True,
@@ -225,12 +223,12 @@ This script is very similar to the previous one.  There are just a few
 significant differences:
 ::

-    prmtop = AmberPrmtopFile('input.prmtop')
     inpcrd = AmberInpcrdFile('input.inpcrd')
+    prmtop = AmberPrmtopFile('input.prmtop', periodicBoxVectors=inpcrd.boxVectors)

-In these lines, we load the prmtop file and inpcrd file.  More precisely, we
-create :class:`AmberPrmtopFile` and :class:`AmberInpcrdFile` objects and assign them to the
-variables :code:`prmtop` and :code:`inpcrd`\ , respectively.  As before,
+In these lines, we load the :file:`inpcrd` file and :file:`prmtop` file.  More precisely, we
+create :class:`AmberInpcrdFile` and :class:`AmberPrmtopFile` objects and assign them to the
+variables :code:`inpcrd` and :code:`prmtop`\ , respectively.  As before,
 you can change these lines to specify any files you want.  Be sure to include
 the single quotes around the file names.

@@ -241,6 +239,19 @@ the single quotes around the file names.
     example files that are in the "old-style" :file:`prmtop` format. These "old-style" files will
     not run in OpenMM.

+Notice that when we load the :file:`prmtop` file we include the argument :code:`periodicBoxVectors=inpcrd.boxVectors`\ .
+AMBER stores information about the periodic box in the :file:`inpcrd` file.  To let
+:class:`AmberPrmtopFile` create a :class:`Topology` object, we therefore need to
+tell it the periodic box vectors that were loaded from the :file:`inpcrd` file.  You
+only need to do this if you are simulating a periodic system.  For implicit
+solvent simulations, it usually can be omitted.
+
+.. note::
+
+    For historical reasons, :file:`prmtop` files also have the ability to store
+    periodic box information, but it is deprecated.  It is always better to get
+    the box vectors from the :file:`inpcrd` file instead.
+
 Next, the :class:`System` object is created in a different way:
 ::

@@ -259,22 +270,7 @@ directly.

 Notice that we now get the topology from the :file:`prmtop` file and the atom positions
 from the :file:`inpcrd` file.  In the previous section, both of these came from a PDB
-file, but AMBER puts the topology and positions in separate files.  We also add the
-following lines:
-::
-
-    if inpcrd.boxVectors is not None:
-        simulation.context.setPeriodicBoxVectors(*inpcrd.boxVectors)
-
-For periodic systems, the :file:`prmtop` file specifies the periodic box vectors, just
-as a PDB file does.  When we call :meth:`createSystem`, it sets those as the default
-periodic box vectors, to be used automatically for all simulations.  However, the
-:file:`inpcrd` may *also* specify periodic box vectors,
-and if so we want to use those ones instead.  For example, if the system has been
-equilibrated with a barostat, the box vectors may have changed during equilibration.
-We therefore check to see if the :file:`inpcrd` file contained box vectors.  If so,
-we call :meth:`setPeriodicBoxVectors` to tell it to use those ones, overriding the
-default ones provided by the :class:`System`.
+file, but AMBER puts the topology and positions in separate files.

 .. _using_gromacs_files:

@@ -315,16 +311,10 @@ with the name :file:`simulateGromacs.py`.

 This script is nearly identical to the previous one, just replacing
 :class:`AmberInpcrdFile` and :class:`AmberPrmtopFile` with :class:`GromacsGroFile` and :class:`GromacsTopFile`.
-Note that when we create the :class:`GromacsTopFile`, we specify values for two extra
-options.  First, we specify
-:code:`periodicBoxVectors=gro.getPeriodicBoxVectors()`\ .  Unlike OpenMM and
-AMBER, which can store periodic unit cell information with the topology, Gromacs
-only stores it with the coordinates.  To let :class:`GromacsTopFile` create a :class:`Topology`
-object, we therefore need to tell it the periodic box vectors that were loaded
-from the :file:`gro` file.  You only need to do this if you are simulating a periodic
-system.  For implicit solvent simulations, it usually can be omitted.
-
-Second, we specify :code:`includeDir='/usr/local/gromacs/share/gromacs/top'`\ .  Unlike AMBER,
+As with AMBER files, when we create the :class:`GromacsTopFile` we specify
+:code:`periodicBoxVectors=gro.getPeriodicBoxVectors()` to tell it the periodic
+box vectors that were loaded from the :file:`gro` file.  In addition, we specify
+:code:`includeDir='/usr/local/gromacs/share/gromacs/top'`\ .  Unlike AMBER,
 which stores all the force field parameters directly in a :file:`prmtop` file, Gromacs just stores
 references to force field definition files that are installed with the Gromacs
 application.  OpenMM needs to know where to find these files, so the
diff --git a/examples/simulateAmber.py b/examples/simulateAmber.py
index 060cb5372f..dd408d949b 100644
--- a/examples/simulateAmber.py
+++ b/examples/simulateAmber.py
@@ -3,14 +3,12 @@
 from openmm.unit import *
 from sys import stdout

-prmtop = AmberPrmtopFile('input.prmtop')
 inpcrd = AmberInpcrdFile('input.inpcrd')
+prmtop = AmberPrmtopFile('input.prmtop', periodicBoxVectors=inpcrd.boxVectors)
 system = prmtop.createSystem(nonbondedMethod=PME, nonbondedCutoff=1*nanometer, constraints=HBonds)
 integrator = LangevinMiddleIntegrator(300*kelvin, 1/picosecond, 0.004*picoseconds)
 simulation = Simulation(prmtop.topology, system, integrator)
 simulation.context.setPositions(inpcrd.positions)
-if inpcrd.boxVectors is not None:
-    simulation.context.setPeriodicBoxVectors(*inpcrd.boxVectors)
 simulation.minimizeEnergy()
 simulation.reporters.append(PDBReporter('output.pdb', 1000))
 simulation.reporters.append(StateDataReporter(stdout, 1000, step=True, potentialEnergy=True, temperature=True))
diff --git a/wrappers/python/openmm/app/amberprmtopfile.py b/wrappers/python/openmm/app/amberprmtopfile.py
index 5d7166b49f..d01a590d9a 100644
--- a/wrappers/python/openmm/app/amberprmtopfile.py
+++ b/wrappers/python/openmm/app/amberprmtopfile.py
@@ -82,11 +82,26 @@ def _strip_optunit(thing, unit):
 class AmberPrmtopFile(object):
     """AmberPrmtopFile parses an AMBER prmtop file and constructs a Topology and (optionally) an OpenMM System from it."""

-    def __init__(self, file):
-        """Load a prmtop file."""
+    def __init__(self, file, periodicBoxVectors=None, unitCellDimensions=None):
+        """Load a prmtop file.
+
+        file : str
+            the name of the file to load
+        periodicBoxVectors : tuple of Vec3=None
+            the vectors defining the periodic box
+        unitCellDimensions : Vec3=None
+            the dimensions of the crystallographic unit cell.  For
+            non-rectangular unit cells, specify periodicBoxVectors instead.
+        """
         ## The Topology read from the prmtop file
         self.topology = top = Topology()
         self.elements = []
+        if periodicBoxVectors is not None:
+            if unitCellDimensions is not None:
+                raise ValueError("Specify either periodicBoxVectors or unitCellDimensions, but not both")
+            top.setPeriodicBoxVectors(periodicBoxVectors)
+        else:
+            top.setUnitCellDimensions(unitCellDimensions)

         # Load the prmtop file

@@ -151,7 +166,7 @@ def __init__(self, file):

         # Set the periodic box size.

-        if prmtop.getIfBox():
+        if top.getPeriodicBoxVectors() is None and prmtop.getIfBox():
             box = prmtop.getBoxBetaAndDimensions()
             top.setPeriodicBoxVectors(computePeriodicBoxVectors(*(box[1:4] + box[0:1]*3)))

diff --git a/wrappers/python/openmm/app/internal/amber_file_parser.py b/wrappers/python/openmm/app/internal/amber_file_parser.py
index e36152a962..dbd6f4b42b 100644
--- a/wrappers/python/openmm/app/internal/amber_file_parser.py
+++ b/wrappers/python/openmm/app/internal/amber_file_parser.py
@@ -12,7 +12,7 @@
 Biological Structures at Stanford, funded under the NIH Roadmap for
 Medical Research, grant U54 GM072970. See https://simtk.org.

-Portions copyright (c) 2012-2022 Stanford University and the Authors.
+Portions copyright (c) 2012-2023 Stanford University and the Authors.
 Authors: Randall J. Radmer, John D. Chodera, Peter Eastman
 Contributors: Christoph Klein, Michael R. Shirts, Jason Swails, Kye Won Wang

@@ -890,7 +890,7 @@ def readAmberSystem(topology, prmtop_filename=None, prmtop_loader=None, shake=No
     # Add nonbonded interactions.
     if verbose: print("Adding nonbonded interactions...")
     force = mm.NonbondedForce()
-    if (prmtop.getIfBox() == 0):
+    if topology.getPeriodicBoxVectors() is None and prmtop.getIfBox() == 0:
         # System is non-periodic.
         if nonbondedMethod == 'NoCutoff':
             force.setNonbondedMethod(mm.NonbondedForce.NoCutoff)
@@ -904,9 +904,12 @@ def readAmberSystem(topology, prmtop_filename=None, prmtop_loader=None, shake=No
     else:
         # System is periodic.
         # Set periodic box vectors for periodic system
-        (boxBeta, boxX, boxY, boxZ) = prmtop.getBoxBetaAndDimensions()
-        xVec, yVec, zVec = computePeriodicBoxVectors(boxX, boxY, boxZ, boxBeta, boxBeta, boxBeta)
-        system.setDefaultPeriodicBoxVectors(xVec, yVec, zVec)
+        if topology.getPeriodicBoxVectors() is None:
+            (boxBeta, boxX, boxY, boxZ) = prmtop.getBoxBetaAndDimensions()
+            xVec, yVec, zVec = computePeriodicBoxVectors(boxX, boxY, boxZ, boxBeta, boxBeta, boxBeta)
+            system.setDefaultPeriodicBoxVectors(xVec, yVec, zVec)
+        else:
+            system.setDefaultPeriodicBoxVectors(*topology.getPeriodicBoxVectors())

         # Set cutoff.
         if nonbondedCutoff is None:

diff --git a/wrappers/python/tests/TestAmberPrmtopFile.py b/wrappers/python/tests/TestAmberPrmtopFile.py
index b107e9dede..aa190d4b64 100644
--- a/wrappers/python/tests/TestAmberPrmtopFile.py
+++ b/wrappers/python/tests/TestAmberPrmtopFile.py
@@ -7,16 +7,16 @@
 from openmm.unit import *
 import openmm.app.element as elem

+inpcrd3 = AmberInpcrdFile('systems/ff14ipq.rst7')
+inpcrd4 = AmberInpcrdFile('systems/Mg_water.inpcrd')
+inpcrd7 = AmberInpcrdFile('systems/18protein.rst7')
 prmtop1 = AmberPrmtopFile('systems/alanine-dipeptide-explicit.prmtop')
 prmtop2 = AmberPrmtopFile('systems/alanine-dipeptide-implicit.prmtop')
-prmtop3 = AmberPrmtopFile('systems/ff14ipq.parm7')
-prmtop4 = AmberPrmtopFile('systems/Mg_water.prmtop')
+prmtop3 = AmberPrmtopFile('systems/ff14ipq.parm7', periodicBoxVectors=inpcrd3.boxVectors)
+prmtop4 = AmberPrmtopFile('systems/Mg_water.prmtop', periodicBoxVectors=inpcrd4.boxVectors)
 prmtop5 = AmberPrmtopFile('systems/tz2.truncoct.parm7')
 prmtop6 = AmberPrmtopFile('systems/gaffwat.parm7')
-prmtop7 = AmberPrmtopFile('systems/18protein.parm7')
-inpcrd3 = AmberInpcrdFile('systems/ff14ipq.rst7')
-inpcrd4 = AmberInpcrdFile('systems/Mg_water.inpcrd')
-inpcrd7 = AmberInpcrdFile('systems/18protein.rst7')
+prmtop7 = AmberPrmtopFile('systems/18protein.parm7', periodicBoxVectors=inpcrd7.boxVectors)

 class TestAmberPrmtopFile(unittest.TestCase):

@@ -177,7 +177,6 @@ def test_NBFIX_LongRange(self):
         # 'working', and the system is plenty small so this won't be too slow
         sim = Simulation(prmtop3.topology, system, integrator, Platform.getPlatformByName('Reference'))
         # Check that the energy is about what we expect it to be
-        sim.context.setPeriodicBoxVectors(*inpcrd3.boxVectors)
         sim.context.setPositions(inpcrd3.positions)
         ene = sim.context.getState(getEnergy=True, enforcePeriodicBox=True).getPotentialEnergy()
         ene = ene.value_in_unit(kilocalories_per_mole)
@@ -208,7 +207,6 @@ def test_NBFIX_noLongRange(self):
         # 'working', and the system is plenty small so this won't be too slow
         sim = Simulation(prmtop3.topology, system, integrator, Platform.getPlatformByName('Reference'))
         # Check that the energy is about what we expect it to be
-        sim.context.setPeriodicBoxVectors(*inpcrd3.getBoxVectors())
         sim.context.setPositions(inpcrd3.getPositions())
         ene = sim.context.getState(getEnergy=True, enforcePeriodicBox=True).getPotentialEnergy()
         ene = ene.value_in_unit(kilocalories_per_mole)
@@ -254,12 +252,19 @@ def test_LJ1264(self):
         self.assertTrue(has_nonbond_force)
         self.assertTrue(has_custom_nonbond_force)
         self.assertEqual(nonbond_exceptions, custom_nonbond_exceptions)
+        # Make sure the periodic box vectors match the ones in the inpcrd file, which are different from
+        # the ones in the prmtop file.
+        systemBox = system.getDefaultPeriodicBoxVectors()
+        topologyBox = prmtop4.topology.getPeriodicBoxVectors()
+        for i in range(3):
+            for j in range(3):
+                self.assertEqual(inpcrd4.boxVectors[i][j], systemBox[i][j])
+                self.assertEqual(inpcrd4.boxVectors[i][j], topologyBox[i][j])
         integrator = VerletIntegrator(1.0*femtoseconds)
         # Use reference platform, since it should always be present and
         # 'working', and the system is plenty small so this won't be too slow
         sim = Simulation(prmtop4.topology, system, integrator, Platform.getPlatformByName('Reference'))
         # Check that the energy is about what we expect it to be
-        sim.context.setPeriodicBoxVectors(*inpcrd4.boxVectors)
         sim.context.setPositions(inpcrd4.positions)
         ene = sim.context.getState(getEnergy=True, enforcePeriodicBox=True).getPotentialEnergy()
         ene = ene.value_in_unit(kilocalories_per_mole)
@@ -358,7 +363,6 @@ def test_with_dcd_reporter(self):

         simulation = Simulation(prmtop.topology, system, integrator)
         simulation.context.setPositions(inpcrd.positions)
-        simulation.context.setPeriodicBoxVectors(*inpcrd.boxVectors)

         fname = tempfile.mktemp(suffix='.dcd')
         simulation.reporters.append(DCDReporter(fname, 1))  # This is an explicit test for the bugs in issue #850
@@ -435,8 +439,7 @@ def testAmberCMAP(self):

         simulation = Simulation(prmtop.topology, system, integrator)
         simulation.context.setPositions(inpcrd.positions)
-        simulation.context.setPeriodicBoxVectors(*inpcrd.boxVectors)
-
+
         for i, force in enumerate(system.getForces()):
             force.setForceGroup(i)


Fix "off by one" error in xtc time reporting. (openmm/openmm#4168)

Fixes #4163.
Both the reporter and the tests expected the first step to have index 1, causing the time of the first step to be assigned the time `dt` instead of 0.

Now running this snippet:
```python
from openmm.app import *
from openmm import *
from openmm.unit import *
from sys import stdout

pdb = PDBFile('villin.pdb')

forcefield = ForceField('amber14-all.xml', 'amber14/tip3pfb.xml')
system = forcefield.createSystem(pdb.topology, nonbondedMethod=PME,
        nonbondedCutoff=1*nanometer, constraints=HBonds)
integrator = LangevinMiddleIntegrator(300*kelvin, 1/picosecond, 0.002*picoseconds)
simulation = Simulation(pdb.topology, system, integrator)
simulation.context.setPositions(pdb.positions)
simulation.minimizeEnergy(maxIterations=100)
simulation.reporters.append(XTCReporter('traj.xtc', 1000, enforcePeriodicBox=False))
simulation.reporters.append(StateDataReporter(stdout, 1000, step=True, time=True,
        potentialEnergy=True, temperature=True))
simulation.step(10000)
```

Produces a traj file such that:
```shell
$ gmx check -f traj.xtc
                :-) GROMACS - gmx check, 2023.1-conda_forge (-:

Executable:   /home/raul/mambaforge/envs/openmm/bin.AVX2_256/gmx
Data prefix:  /home/raul/mambaforge/envs/openmm
Working dir:  /home/raul/openmm/tests
Command line:
  gmx check -f traj.xtc

Checking file traj.xtc
Reading frame       0 time 2000.000
# Atoms  8867
Precision 0.001 (nm)
Last frame          9 time 20000.000

Item        #frames Timestep (ps)
Step            10    2000
Time            10    2000
Lambda           0
Coords          10    2000
Velocities       0
Forces           0
Box             10    2000

GROMACS reminds you: "People are DNA's way of making more DNA." (Edward O. Wilson)
```

@sef43, in your report you mentioned an issue with just the frame time. In addition to shifting the time, this PR also reduces by one the step count, so that the first frame written has index "0" (whereas before it was 1). Is this also the desired behavior here?

off by one error in XTCReporter time record

The new XTC reporter has an off by one error in the time it records for each frame.
Issue first noticed here: https://www.ovito.org/forum/topic/dcd-format-trajectory-reader/ (login required)

It can be seen with the following script using the villin.pdb file from the cookbook:
```python
from openmm.app import *
from openmm import *
from openmm.unit import *
from sys import stdout

pdb = PDBFile('villin.pdb')

forcefield = ForceField('amber14-all.xml', 'amber14/tip3pfb.xml')
system = forcefield.createSystem(pdb.topology, nonbondedMethod=PME,
        nonbondedCutoff=1*nanometer, constraints=HBonds)
integrator = LangevinMiddleIntegrator(300*kelvin, 1/picosecond, 0.002*picoseconds)
simulation = Simulation(pdb.topology, system, integrator)
simulation.context.setPositions(pdb.positions)
simulation.minimizeEnergy(maxIterations=100)
simulation.reporters.append(XTCReporter('traj.xtc', 1000, enforcePeriodicBox=False))
simulation.reporters.append(StateDataReporter(stdout, 1000, step=True, time=True,
        potentialEnergy=True, temperature=True))
simulation.step(10000)
```

If you run `gmx check -f traj.xtc` it outputs:
```
                :-) GROMACS - gmx check, 2023.1-conda_forge (-:

Executable:   /usr/local/bin.AVX2_256/gmx
Data prefix:  /usr/local
Working dir:  /content
Command line:
  gmx check -f traj.xtc

Checking file traj.xtc
Reading frame       0 time 4000.000
# Atoms  8867
Precision 0.001 (nm)
Last frame          9 time 22000.000

Item        #frames Timestep (ps)
Step            10    2000
Time            10    2000
Lambda           0
Coords          10    2000
Velocities       0
Forces           0
Box             10    2000
```
Frame 0 should have time 2,000 not 4,000
and frame 9 should have time 20,000 not 22,000

## 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

diff --git a/wrappers/python/openmm/app/xtcfile.py b/wrappers/python/openmm/app/xtcfile.py
index ab5ff5b93a..1cdd6d7e63 100644
--- a/wrappers/python/openmm/app/xtcfile.py
+++ b/wrappers/python/openmm/app/xtcfile.py
@@ -141,7 +141,7 @@ def writeModel(self, positions, unitCellDimensions=None, periodicBoxVectors=None
             )
         else:
             boxVectors = np.zeros((3, 3)).astype(np.float32)
-        step = self._modelCount * self._interval + self._firstStep
+        step = (self._modelCount - 1) * self._interval + self._firstStep
         time = step * self._dt
         xtc_write_frame(
             self._filename.encode("utf-8"),

diff --git a/wrappers/python/tests/TestXtcFile.py b/wrappers/python/tests/TestXtcFile.py
index c33c65d047..1b84104205 100644
--- a/wrappers/python/tests/TestXtcFile.py
+++ b/wrappers/python/tests/TestXtcFile.py
@@ -49,9 +49,9 @@ def test_xtc_triclinic(self):
             box = np.array(box).transpose(1, 2, 0)
             self.assertTrue(np.allclose(box_read, box, atol=1e-3))
             self.assertTrue(
-                np.allclose(time, np.arange(1, nframes + 1) * 0.001, atol=1e-5)
+                np.allclose(time, np.arange(0, nframes) * 0.001, atol=1e-5)
             )
-            self.assertTrue(np.allclose(step, np.arange(1, nframes + 1), atol=1e-5))
+            self.assertTrue(np.allclose(step, np.arange(0, nframes), atol=1e-5))

     def test_xtc_cubic(self):
         """Test the XTC file by writing a trajectory and reading it back. Using a cubic box"""
@@ -87,9 +87,9 @@ def test_xtc_cubic(self):
             box = np.array(box).transpose(1, 2, 0)
             self.assertTrue(np.allclose(box_read, box, atol=1e-3))
             self.assertTrue(
-                np.allclose(time, np.arange(1, nframes + 1) * 0.001, atol=1e-5)
+                np.allclose(time, np.arange(0, nframes) * 0.001, atol=1e-5)
             )
-            self.assertTrue(np.allclose(step, np.arange(1, nframes + 1), atol=1e-5))
+            self.assertTrue(np.allclose(step, np.arange(0, nframes), atol=1e-5))

     def test_xtc_box_from_topology(self):
         """Test the XTC file by writing a trajectory and reading it back. Letting the box be set from the topology"""
@@ -131,9 +131,9 @@ def test_xtc_box_from_topology(self):
             box = np.array(np.tile(boxVectors, (nframes, 1, 1))).transpose(1, 2, 0)
             self.assertTrue(np.allclose(box_read, box, atol=1e-3))
             self.assertTrue(
-                np.allclose(time, np.arange(1, nframes + 1) * 0.001, atol=1e-5)
+                np.allclose(time, np.arange(0, nframes) * 0.001, atol=1e-5)
             )
-            self.assertTrue(np.allclose(step, np.arange(1, nframes + 1), atol=1e-5))
+            self.assertTrue(np.allclose(step, np.arange(0, nframes), atol=1e-5))

     def testLongTrajectory(self):
         """Test writing a trajectory that has more than 2^31 steps."""


Don't add extra bond between water hydrogens (openmm/openmm#4219)

Fixes #4212.

Amber files list an extra "bond" between the hydrogens of a water molecule, which was incorrectly included in the Topology built from a prmtop file.  A Topology should only include actual chemical bonds.

TIP3P Residue Matching Issue w/ AMBER system

I'm using OpenMM to run a system I prepared using LEaP.

My workflow looks something like this:
-Use LEaP to prepare AMBER style PRMTOP/INPCRD files for a protein in explicit solvent with ff14SB/TIP3P
-Use OpenMM to parse the AMBER style files and create a forcefield object/perform residue to template matching
-This (functions in ```forcefield.py``` and the ```System``` object) is used by a custom simulation package to implement a forcefield and integration routine to do MD simulations

The issue I'm running into is happening when parsing the AMBER files into an OpenMM style system and doing residue to template matching. For this purpose, I'm using the ```amber14/protein.ff14SB.xml``` and ```amber14/tip3p.xml``` files located in the OpenMM directory. From previous experience, I understand that TIP3P in the AMBER convention includes an H-H bond to represent the rigid nature of the potential. When I load the files into OpenMM and check the residues:
```
prmtop = app.AmberPrmtopFile('../RAMP1_ion.prmtop')
inpcrd = app.AmberInpcrdFile('../RAMP1_ion.inpcrd')
residues = list(prmtop.topology.residues())
print(list(residues[5000].atoms()))
print(list(residues[5000].bonds()))
```
I see this behavior, which results in the expected O, H1, and H2 atoms as well as the expected 3 bonds.
```
[<Atom 15966 (O) of chain 0 residue 5000 (HOH)>,
<Atom 15967 (H1) of chain 0 residue 5000 (HOH)>,
<Atom 15968 (H2) of chain 0 residue 5000 (HOH)>]

[Bond(<Atom 15967 (H1) of chain 0 residue 5000 (HOH)>, <Atom 15966 (O) of chain 0 residue 5000 (HOH)>),
Bond(<Atom 15968 (H2) of chain 0 residue 5000 (HOH)>, <Atom 15966 (O) of chain 0 residue 5000 (HOH)>),
Bond(<Atom 15968 (H2) of chain 0 residue 5000 (HOH)>, <Atom 15967 (H1) of chain 0 residue 5000 (HOH)>)]
```

When looking at the ```amber14/tip3p.xml``` file, I notice that the only bonds defined are the two O-H bonds.

```
<Residue name="HOH">
   <Atom name="O" type="tip3p-O" charge="-0.834"/>
   <Atom name="H1" type="tip3p-H" charge="0.417"/>
   <Atom name="H2" type="tip3p-H" charge="0.417"/>
   <Bond atomName1="O" atomName2="H1"/>
   <Bond atomName1="O" atomName2="H2"/>
</Residue>
```
When the simulation package or OpenMM tries to perform the template matching using the functions in ```forcefield.py```, this results in an error:
```
#OpenMM example that produces the matching error
prmtop = AmberPrmtopFile('../RAMP1_ion.prmtop')
inpcrd = AmberInpcrdFile('../RAMP1_ion.inpcrd')
forcefield = ForceField('amber14/protein.ff14SB.xml', 'amber14/tip3p.xml')
system = forcefield.createSystem(prmtop.topology, nonbondedMethod=PME, nonbondedCutoff=8*angstrom, constraints=None)
```
```
ValueError: No template found for residue 122 (HOH).  The set of atoms matches HOH, but the bonds are different.
```
I've used ParmED / the ```amberprmtopfile.py``` tool included in OpenMM that completely bypasses ```forcefield.py``` to setup AMBER systems for OpenMM before but the tool I'm using would require significant rewriting to avoid this. I'm trying to figure out if there's anything I can do to deal with this extra bond either in system setup, parsing into OpenMM objects, or an alternative XML file I can use to resolve this issue.

## 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

diff --git a/wrappers/python/openmm/app/amberprmtopfile.py b/wrappers/python/openmm/app/amberprmtopfile.py
index d01a590d9a..250d4438a1 100644
--- a/wrappers/python/openmm/app/amberprmtopfile.py
+++ b/wrappers/python/openmm/app/amberprmtopfile.py
@@ -160,7 +160,13 @@ def __init__(self, file, periodicBoxVectors=None, unitCellDimensions=None):

         atoms = list(top.atoms())
         for bond in prmtop.getBondsWithH():
-            top.addBond(atoms[bond[0]], atoms[bond[1]])
+            a1 = atoms[bond[0]]
+            a2 = atoms[bond[1]]
+            if a1.residue.name == 'HOH' and a1.element == elem.hydrogen and a2.element == elem.hydrogen:
+                # Don't add the "bond" Amber lists between the two hydrogens of a water molecule.
+                pass
+            else:
+                top.addBond(a1, a2)
         for bond in prmtop.getBondsNoH():
             top.addBond(atoms[bond[0]], atoms[bond[1]])

diff --git a/wrappers/python/tests/TestAmberPrmtopFile.py b/wrappers/python/tests/TestAmberPrmtopFile.py
index aa190d4b64..f25a1e6cfa 100644
--- a/wrappers/python/tests/TestAmberPrmtopFile.py
+++ b/wrappers/python/tests/TestAmberPrmtopFile.py
@@ -78,12 +78,14 @@ def test_RigidWaterAndConstraints(self):
         """Test all eight options for the constraints and rigidWater parameters."""

         topology = prmtop1.topology
-        for constraints_value in [None, HBonds, AllBonds, HAngles]:
-            for rigidWater_value in [True, False]:
-                system = prmtop1.createSystem(constraints=constraints_value,
-                                              rigidWater=rigidWater_value)
-                validateConstraints(self, topology, system,
-                                    constraints_value, rigidWater_value)
+        for constraints in [None, HBonds, AllBonds, HAngles]:
+            for rigidWater in [True, False]:
+                system = prmtop1.createSystem(constraints=constraints, rigidWater=rigidWater)
+                if constraints != None:
+                    # Amber adds an extra "bond" between water hydrogens, so any constraint
+                    # method except None is equivalent to rigidWater=True.
+                    rigidWater = True
+                validateConstraints(self, topology, system, constraints, rigidWater)

     def test_ImplicitSolvent(self):
         """Test the four types of implicit solvents using the implicitSolvent
@@ -460,5 +462,16 @@ def testEPConstraints(self):
             # If a constraint was added to a massless particle, this will throw an exception.
             context = Context(system, integrator, Platform.getPlatformByName('Reference'))

+    def testWaterBonds(self):
+        """Test that water molecules have the right set of bonds"""
+        top = prmtop1.topology
+        for residue in top.residues():
+            if residue.name == 'HOH':
+                bonds = list(residue.bonds())
+                self.assertEqual(2, len(bonds))
+                for a1, a2 in bonds:
+                    self.assertTrue(a1.element == elem.oxygen or a2.element == elem.oxygen)
+                    self.assertTrue(a1.element == elem.hydrogen or a2.element == elem.hydrogen)
+
 if __name__ == '__main__':
     unittest.main()
diff --git a/wrappers/python/tests/TestForceField.py b/wrappers/python/tests/TestForceField.py
index 767c7793d6..d9073b963a 100644
--- a/wrappers/python/tests/TestForceField.py
+++ b/wrappers/python/tests/TestForceField.py
@@ -439,7 +439,6 @@ def test_residueMatcher(self):
    <Atom name="H2" type="tip3p-H" charge="0.417"/>
    <Bond from="0" to="1"/>
    <Bond from="0" to="2"/>
-   <Bond from="1" to="2"/>
   </Residue>
   <Residue name="HOH2">
    <Atom name="O" type="tip3p-O" charge="0.834"/>
@@ -447,7 +446,6 @@ def test_residueMatcher(self):
    <Atom name="H2" type="tip3p-H" charge="-0.417"/>
    <Bond from="0" to="1"/>
    <Bond from="0" to="2"/>
-   <Bond from="1" to="2"/>
   </Residue>
  </Residues>
  <NonbondedForce coulomb14scale="0.833333" lj14scale="0.5">


CustomCPPForceImpl for writing forces in C++ (openmm/openmm#4231)

This is a new piece of infrastructure.  It allows you to implement a new force by writing a single implementation in platform-independent C++.  This is mainly intended for plugins that interface to other codes.  It should allow us to enormously simplify the PLUMED plugin.  It also can be used for a plugin that interfaces to xtb, and possibly others in the future.  Having most of the infrastructure built in will eliminate a lot of code duplication between different plugins.

To give a sense of how simple it can be to write a Force with it, here's the code from the test case.  This is a complete, fully functional of implementation of a new Force class that works on all platforms.

```c++
class TestForce : public Force {
protected:
    ForceImpl* createImpl() const;
};

class TestForceImpl : public CustomCPPForceImpl {
public:
    const TestForce& owner;
    TestForceImpl(const TestForce& owner) : CustomCPPForceImpl(owner), owner(owner) {
    }
    double computeForce(ContextImpl& context, const vector<Vec3>& positions, vector<Vec3>& forces) {
        for (int i = 0; i < forces.size(); i++)
            forces[i] = Vec3(i, i+1, i+2);
        return 10.5;
    }
    const TestForce& getOwner() const {
        return owner;
    }
};

ForceImpl* TestForce::createImpl() const {
    return new TestForceImpl(*this);
}
```

### Issue Description

**Problem:**
Developers creating plugins for OpenMM often need to implement custom forces that interface with external libraries or APIs. This process typically requires writing platform-specific code for each supported hardware platform, such as GPU or CPU, which introduces significant complexity and code duplication. Additionally, the need for platform-specific optimizations can be unnecessary when the heavy computations are handled by external libraries, leaving developers to focus solely on transferring data between OpenMM and the external library.

**Expected Behavior:**
Developers should be able to implement custom forces in a straightforward, platform-independent manner using a single C++ implementation. The infrastructure should automatically ensure compatibility across all supported platforms without requiring additional GPU-specific code, kernel factories, or dynamic plugin libraries. This approach should simplify the development process, reduce code duplication, and provide a consistent API for interfacing with external libraries.

## 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

diff --git a/docs-source/developerguide/09_customcppforceimpl.rst b/docs-source/developerguide/09_customcppforceimpl.rst
new file mode 100644
index 0000000000..9c6bccb48e
--- /dev/null
+++ b/docs-source/developerguide/09_customcppforceimpl.rst
@@ -0,0 +1,52 @@
+.. role:: code
+.. raw:: html
+
+    <style> .code {font-family:monospace;} </style>
+    <style> .caption {text-align:center;} </style>
+
+.. highlight:: c++
+
+
+.. _customcppforceimpl:
+
+Platform-Independent Code with CustomCPPForceImpl
+#################################################
+
+In most cases, Forces are written with platform-specific code that runs as
+efficiently as possible on the available hardware.  There are some situations
+where that is unnecessarily complicated.  For example, you might write a plugin
+that interfaces to an external library through its own public API.  Because all
+expensive calculations are done by the external library, there is little
+opportunity for any kind of platform-specific optimization.  All you can do is
+copy positions over to the external library and copy forces back, hopefully in
+a way that is not too inefficient, and preferably with as little code as
+possible.  CustomCPPForceImpl is a tool for doing that.
+
+To use it, you write your Force subclass in the usual way, providing whatever
+API is appropriate for it.  Then for the corresponding ForceImpl, simply
+subclass CustomCPPForceImpl and override :code:`computeForce()` to perform the
+calculation using platform-independent C++ code.  Nothing more is required.  It
+will automatically work on all platforms.  You do not need to write any GPU
+code, provide a Kernel or KernelFactory, define registration functions, or even
+create plugin libraries to be loaded dynamically.  The single library containing
+the Force and ForceImpl is all you need.
+
+Here is an example of what the code to implement your ForceImpl might look like.
+::
+
+    class ExampleForceImpl : public CustomCPPForceImpl {
+    public:
+        ExampleForceImpl(const ExampleForce& owner) : CustomCPPForceImpl(owner), owner(owner) {
+        }
+
+        double computeForce(ContextImpl& context, const vector<Vec3>& positions, vector<Vec3>& forces) {
+            // Compute the forces and energy here.  Store the forces into the
+            // vector and return the energy.
+        }
+
+        const ExampleForce& getOwner() const {
+            return owner;
+        }
+    private:
+        const ExampleForce& owner;
+    };
diff --git a/docs-source/developerguide/index.rst b/docs-source/developerguide/index.rst
index d35a7ddf32..85ae47ff7b 100644
--- a/docs-source/developerguide/index.rst
+++ b/docs-source/developerguide/index.rst
@@ -18,6 +18,7 @@ OpenMM Developer's Guide
    06_opencl_platform.rst
    07_cuda_platform.rst
    08_common_compute.rst
+   09_customcppforceimpl.rst

 .. only:: html

diff --git a/docs-source/developerguide/license.rst b/docs-source/developerguide/license.rst
index 1643bda88c..83e1f5c53b 100644
--- a/docs-source/developerguide/license.rst
+++ b/docs-source/developerguide/license.rst
@@ -1,4 +1,4 @@
-Portions copyright (c) 2011-2020 Stanford University and the Authors
+Portions copyright (c) 2011-2023 Stanford University and the Authors

 Contributors: Peter Eastman

diff --git a/olla/include/openmm/kernels.h b/olla/include/openmm/kernels.h
index 5c6a994bba..a0ca3fb4be 100644
--- a/olla/include/openmm/kernels.h
+++ b/olla/include/openmm/kernels.h
@@ -67,6 +67,7 @@
 #include "openmm/NoseHooverIntegrator.h"
 #include "openmm/NoseHooverChain.h"
 #include "openmm/ATMForce.h"
+#include "openmm/internal/CustomCPPForceImpl.h"
 #include <iosfwd>
 #include <set>
 #include <string>
@@ -1684,8 +1685,33 @@ class CalcATMForceKernel : public KernelImpl {
     virtual void copyState(ContextImpl& context, ContextImpl& innerContext0, ContextImpl& innerContext1) = 0;
 };

-
-
+/**
+ * This kernel is invoked by CustomCPPForce to calculate the forces acting on the system and the energy of the system.
+ */
+class CalcCustomCPPForceKernel : public KernelImpl {
+public:
+    static std::string Name() {
+        return "CalcCustomCPPForce";
+    }
+    CalcCustomCPPForceKernel(std::string name, const Platform& platform) : KernelImpl(name, platform) {
+    }
+    /**
+     * Initialize the kernel.
+     *
+     * @param system     the System this kernel will be applied to
+     * @param force      the CustomCPPForceImpl this kernel will be used for
+     */
+    virtual void initialize(const System& system, CustomCPPForceImpl& force) = 0;
+    /**
+     * Execute the kernel to calculate the forces and/or energy.
+     *
+     * @param context        the context in which to execute this kernel
+     * @param includeForces  true if forces should be calculated
+     * @param includeEnergy  true if the energy should be calculated
+     * @return the potential energy due to the force
+     */
+    virtual double execute(ContextImpl& context, bool includeForces, bool includeEnergy) = 0;
+};

 } // namespace OpenMM

diff --git a/openmmapi/include/openmm/internal/CustomCPPForceImpl.h b/openmmapi/include/openmm/internal/CustomCPPForceImpl.h
new file mode 100644
index 0000000000..41fafb40e9
--- /dev/null
+++ b/openmmapi/include/openmm/internal/CustomCPPForceImpl.h
@@ -0,0 +1,120 @@
+#ifndef OPENMM_CUSTOMCPPFORCEIMPL_H_
+#define OPENMM_CUSTOMCPPFORCEIMPL_H_
+
+/* -------------------------------------------------------------------------- *
+ *                                   OpenMM                                   *
+ * -------------------------------------------------------------------------- *
+ * This is part of the OpenMM molecular simulation toolkit originating from   *
+ * Simbios, the NIH National Center for Physics-Based Simulation of           *
+ * Biological Structures at Stanford, funded under the NIH Roadmap for        *
+ * Medical Research, grant U54 GM072970. See https://simtk.org.               *
+ *                                                                            *
+ * Portions copyright (c) 2023 Stanford University and the Authors.           *
+ * Authors: Peter Eastman                                                     *
+ * Contributors:                                                              *
+ *                                                                            *
+ * Permission is hereby granted, free of charge, to any person obtaining a    *
+ * copy of this software and associated documentation files (the "Software"), *
+ * to deal in the Software without restriction, including without limitation  *
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,   *
+ * and/or sell copies of the Software, and to permit persons to whom the      *
+ * Software is furnished to do so, subject to the following conditions:       *
+ *                                                                            *
+ * The above copyright notice and this permission notice shall be included in *
+ * all copies or substantial portions of the Software.                        *
+ *                                                                            *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR *
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,   *
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL    *
+ * THE AUTHORS, CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,    *
+ * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR      *
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE  *
+ * USE OR OTHER DEALINGS IN THE SOFTWARE.                                     *
+ * -------------------------------------------------------------------------- */
+
+#include "ForceImpl.h"
+#include "openmm/Kernel.h"
+#include "openmm/Vec3.h"
+#include <string>
+#include <vector>
+
+namespace OpenMM {
+
+/**
+ * This class provides an easy way of writing Forces that are implemented entirely
+ * in C++, and do not involve any platform-specific code.  To use it, have your
+ * ForceImpl extend this class, implementing computeForce() to compute the forces
+ * and energy in a platform-independent way.  You can optionally override other
+ * methods as well, such as if your Force defines global parameters or bonds
+ * between atoms.
+ *
+ * Most Forces should not be implemented with this class.  Writing platform-specific
+ * code that can run on a GPU and interact directly with internal data structures
+ * usually leads to much faster performance.  This class is useful for special
+ * situations, such as plugins that interact with external libraries through their
+ * own public APIs.  In that case, there is little opportunity for optimization.
+ * This class allows you to write a single implementation that is automatically
+ * used for all platforms.
+ */
+
+class OPENMM_EXPORT CustomCPPForceImpl : public ForceImpl {
+public:
+    CustomCPPForceImpl(const Force& owner);
+    /**
+     * Subclasses may override this to do their own initialization, but they should
+     * also call CustomCPPForceImpl::initialize(context) to let the parent class
+     * initialize its kernel.
+     *
+     * @param context    the context in which the system is being simulated
+     */
+    void initialize(ContextImpl& context);
+    /**
+     * Calculate the force on each particle generated by this ForceImpl and/or this ForceImpl's
+     * contribution to the potential energy of the system.
+     *
+     * @param context    the context in which the system is being simulated
+     * @param positions  the position of every particle
+     * @param forces     store the force on each particle into this array
+     * @return this force's contribution to the potential energy of the system, or 0 if this
+     * force does not contribute to potential energy
+     */
+    virtual double computeForce(ContextImpl& context, const std::vector<Vec3>& positions, std::vector<Vec3>& forces) = 0;
+    /**
+     * Override this if the force updates the context state directly.  In general
+     * it should only make changes by invoking public methods of the ContextImpl.
+     * If you need to access platform-specific data structures, this class is
+     * probably not appropriate.
+     *
+     * @param context        the context in which the system is being simulated
+     * @param forcesInvalid  if the state was modified in any way that might cause previously
+     *                       calculated forces to no longer be valid (such as modifying
+     *                       positions or parameters), the method should set this to true.
+     */
+    void updateContextState(ContextImpl& context, bool& forcesInvalid) {
+    }
+    /**
+     * Override this if the force defines global parameters.
+     */
+    std::map<std::string, double> getDefaultParameters() {
+        return {};
+    }
+    /**
+     * Override this if the force defines bonds between particles.
+     */
+    std::vector<std::pair<int, int> > getBondedParticles() const {
+        return {};
+    }
+    /**
+     * Override this if the force defines an updateParametersInContext() method.
+     */
+    void updateParametersInContext(ContextImpl& context) {
+    }
+    double calcForcesAndEnergy(ContextImpl& context, bool includeForces, bool includeEnergy, int groups);
+    std::vector<std::string> getKernelNames();
+private:
+    Kernel kernel;
+};
+
+} // namespace OpenMM
+
+#endif /*OPENMM_CUSTOMCPPFORCEIMPL_H_*/
diff --git a/openmmapi/src/CustomCPPForceImpl.cpp b/openmmapi/src/CustomCPPForceImpl.cpp
new file mode 100644
index 0000000000..415662acb1
--- /dev/null
+++ b/openmmapi/src/CustomCPPForceImpl.cpp
@@ -0,0 +1,58 @@
+/* -------------------------------------------------------------------------- *
+ *                                   OpenMM                                   *
+ * -------------------------------------------------------------------------- *
+ * This is part of the OpenMM molecular simulation toolkit originating from   *
+ * Simbios, the NIH National Center for Physics-Based Simulation of           *
+ * Biological Structures at Stanford, funded under the NIH Roadmap for        *
+ * Medical Research, grant U54 GM072970. See https://simtk.org.               *
+ *                                                                            *
+ * Portions copyright (c) 2008-2021 Stanford University and the Authors.      *
+ * Authors: Peter Eastman                                                     *
+ * Contributors:                                                              *
+ *                                                                            *
+ * Permission is hereby granted, free of charge, to any person obtaining a    *
+ * copy of this software and associated documentation files (the "Software"), *
+ * to deal in the Software without restriction, including without limitation  *
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,   *
+ * and/or sell copies of the Software, and to permit persons to whom the      *
+ * Software is furnished to do so, subject to the following conditions:       *
+ *                                                                            *
+ * The above copyright notice and this permission notice shall be included in *
+ * all copies or substantial portions of the Software.                        *
+ *                                                                            *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR *
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,   *
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL    *
+ * THE AUTHORS, CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,    *
+ * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR      *
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE  *
+ * USE OR OTHER DEALINGS IN THE SOFTWARE.                                     *
+ * -------------------------------------------------------------------------- */
+
+#include "openmm/internal/ContextImpl.h"
+#include "openmm/internal/CustomCPPForceImpl.h"
+#include "openmm/kernels.h"
+
+using namespace OpenMM;
+using namespace std;
+
+CustomCPPForceImpl::CustomCPPForceImpl(const Force& owner) {
+    forceGroup = owner.getForceGroup();
+}
+
+void CustomCPPForceImpl::initialize(ContextImpl& context) {
+    kernel = context.getPlatform().createKernel(CalcCustomCPPForceKernel::Name(), context);
+    kernel.getAs<CalcCustomCPPForceKernel>().initialize(context.getSystem(), *this);
+}
+
+double CustomCPPForceImpl::calcForcesAndEnergy(ContextImpl& context, bool includeForces, bool includeEnergy, int groups) {
+    if ((groups&(1<<forceGroup)) != 0)
+        return kernel.getAs<CalcCustomCPPForceKernel>().execute(context, includeForces, includeEnergy);
+    return 0.0;
+}
+
+vector<string> CustomCPPForceImpl::getKernelNames() {
+    vector<string> names;
+    names.push_back(CalcCustomCPPForceKernel::Name());
+    return names;
+}
diff --git a/platforms/common/include/openmm/common/CommonKernels.h b/platforms/common/include/openmm/common/CommonKernels.h
index 3c4badb2f7..b60faf54c6 100644
--- a/platforms/common/include/openmm/common/CommonKernels.h
+++ b/platforms/common/include/openmm/common/CommonKernels.h
@@ -1654,7 +1654,57 @@ class CommonCalcATMForceKernel : public CalcATMForceKernel {
     int numParticles;
 };

-
+/**
+ * This kernel is invoked by CustomCPPForce to calculate the forces acting on the system and the energy of the system.
+ */
+class CommonCalcCustomCPPForceKernel : public CalcCustomCPPForceKernel {
+public:
+    CommonCalcCustomCPPForceKernel(std::string name, const Platform& platform, OpenMM::ContextImpl& contextImpl, ComputeContext& cc) :
+            CalcCustomCPPForceKernel(name, platform), contextImpl(contextImpl), cc(cc), force(NULL) {
+    }
+    /**
+     * Initialize the kernel.
+     *
+     * @param system     the System this kernel will be applied to
+     * @param force      the CustomCPPForceImpl this kernel will be used for
+     */
+    void initialize(const System& system, CustomCPPForceImpl& force);
+    /**
+     * Execute the kernel to calculate the forces and/or energy.
+     *
+     * @param context        the context in which to execute this kernel
+     * @param includeForces  true if forces should be calculated
+     * @param includeEnergy  true if the energy should be calculated
+     * @return the potential energy due to the force
+     */
+    double execute(ContextImpl& context, bool includeForces, bool includeEnergy);
+    /**
+     * The is called by the pre-computation to start the calculation running.
+     */
+    void beginComputation(bool includeForces, bool includeEnergy, int groups);
+    /**
+     * This is called by the worker thread to do the computation.
+     */
+    void executeOnWorkerThread(bool includeForces);
+    /**
+     * This is called by the post-computation to add the forces to the main array.
+     */
+    double addForces(bool includeForces, bool includeEnergy, int groups);
+private:
+    class ExecuteTask;
+    class StartCalculationPreComputation;
+    class AddForcesPostComputation;
+    OpenMM::ContextImpl& contextImpl;
+    ComputeContext& cc;
+    CustomCPPForceImpl* force;
+    ComputeArray forcesArray;
+    ComputeKernel addForcesKernel;
+    std::vector<Vec3> positionsVec, forcesVec;
+    std::vector<float> floatForces;
+    int forceGroupFlag;
+    double energy;
+};
+
 } // namespace OpenMM

 #endif /*OPENMM_COMMONKERNELS_H_*/
diff --git a/platforms/common/src/CommonKernels.cpp b/platforms/common/src/CommonKernels.cpp
index 0bacfdc31b..6474959527 100644
--- a/platforms/common/src/CommonKernels.cpp
+++ b/platforms/common/src/CommonKernels.cpp
@@ -7974,3 +7974,111 @@ void CommonCalcATMForceKernel::copyParametersToContext(ContextImpl& context, con
     displ1.upload(displVectorContext1);
     displ0.upload(displVectorContext0);
 }
+
+class CommonCalcCustomCPPForceKernel::StartCalculationPreComputation : public ComputeContext::ForcePreComputation {
+public:
+    StartCalculationPreComputation(CommonCalcCustomCPPForceKernel& owner) : owner(owner) {
+    }
+    void computeForceAndEnergy(bool includeForces, bool includeEnergy, int groups) {
+        owner.beginComputation(includeForces, includeEnergy, groups);
+    }
+    CommonCalcCustomCPPForceKernel& owner;
+};
+
+class CommonCalcCustomCPPForceKernel::ExecuteTask : public ComputeContext::WorkTask {
+public:
+    ExecuteTask(CommonCalcCustomCPPForceKernel& owner, bool includeForces) : owner(owner), includeForces(includeForces) {
+    }
+    void execute() {
+        owner.executeOnWorkerThread(includeForces);
+    }
+    CommonCalcCustomCPPForceKernel& owner;
+    bool includeForces;
+};
+
+class CommonCalcCustomCPPForceKernel::AddForcesPostComputation : public ComputeContext::ForcePostComputation {
+public:
+    AddForcesPostComputation(CommonCalcCustomCPPForceKernel& owner) : owner(owner) {
+    }
+    double computeForceAndEnergy(bool includeForces, bool includeEnergy, int groups) {
+        return owner.addForces(includeForces, includeEnergy, groups);
+    }
+    CommonCalcCustomCPPForceKernel& owner;
+};
+
+void CommonCalcCustomCPPForceKernel::initialize(const System& system, CustomCPPForceImpl& force) {
+    ContextSelector selector(cc);
+    this->force = &force;
+    int numParticles = system.getNumParticles();
+    forcesVec.resize(numParticles);
+    positionsVec.resize(numParticles);
+    floatForces.resize(3*numParticles);
+    int elementSize = (cc.getUseDoublePrecision() ? sizeof(double) : sizeof(float));
+    forcesArray.initialize(cc, 3*numParticles, elementSize, "forces");
+    map<string, string> defines;
+    defines["NUM_ATOMS"] = cc.intToString(numParticles);
+    defines["PADDED_NUM_ATOMS"] = cc.intToString(cc.getPaddedNumAtoms());
+    ComputeProgram program = cc.compileProgram(CommonKernelSources::customCppForce, defines);
+    addForcesKernel = program->createKernel("addForces");
+    addForcesKernel->addArg(forcesArray);
+    addForcesKernel->addArg(cc.getLongForceBuffer());
+    addForcesKernel->addArg(cc.getAtomIndexArray());
+    forceGroupFlag = (1<<force.getOwner().getForceGroup());
+    cc.addPreComputation(new StartCalculationPreComputation(*this));
+    cc.addPostComputation(new AddForcesPostComputation(*this));
+}
+
+double CommonCalcCustomCPPForceKernel::execute(ContextImpl& context, bool includeForces, bool includeEnergy) {
+    // This method does nothing.  The actual calculation is started by the pre-computation, continued on
+    // the worker thread, and finished by the post-computation.
+
+    return 0;
+}
+
+void CommonCalcCustomCPPForceKernel::beginComputation(bool includeForces, bool includeEnergy, int groups) {
+    if ((groups&forceGroupFlag) == 0)
+        return;
+    contextImpl.getPositions(positionsVec);
+
+    // The actual force computation will be done on a different thread.
+
+    cc.getWorkThread().addTask(new ExecuteTask(*this, includeForces));
+}
+
+void CommonCalcCustomCPPForceKernel::executeOnWorkerThread(bool includeForces) {
+    energy = force->computeForce(contextImpl, positionsVec, forcesVec);
+    if (includeForces) {
+        ContextSelector selector(cc);
+        int numParticles = cc.getNumAtoms();
+        if (cc.getUseDoublePrecision())
+            forcesArray.upload((double*) forcesVec.data());
+        else {
+            for (int i = 0; i < numParticles; i++) {
+                floatForces[3*i] = (float) forcesVec[i][0];
+                floatForces[3*i+1] = (float) forcesVec[i][1];
+                floatForces[3*i+2] = (float) forcesVec[i][2];
+            }
+            forcesArray.upload(floatForces);
+        }
+    }
+}
+
+double CommonCalcCustomCPPForceKernel::addForces(bool includeForces, bool includeEnergy, int groups) {
+    if ((groups&forceGroupFlag) == 0)
+        return 0;
+
+    // Wait until executeOnWorkerThread() is finished.
+
+    cc.getWorkThread().flush();
+
+    // Add in the forces.
+
+    if (includeForces) {
+        ContextSelector selector(cc);
+        addForcesKernel->execute(cc.getNumAtoms());
+    }
+
+    // Return the energy.
+
+    return energy;
+}
\ No newline at end of file
diff --git a/platforms/common/src/kernels/customCppForce.cc b/platforms/common/src/kernels/customCppForce.cc
new file mode 100644
index 0000000000..49e34a7a3a
--- /dev/null
+++ b/platforms/common/src/kernels/customCppForce.cc
@@ -0,0 +1,8 @@
+KERNEL void addForces(GLOBAL const real* RESTRICT forces, GLOBAL mm_long* RESTRICT forceBuffers, GLOBAL int* RESTRICT atomIndex) {
+    for (int atom = GLOBAL_ID; atom < NUM_ATOMS; atom += GLOBAL_SIZE) {
+        int index = atomIndex[atom];
+        forceBuffers[atom] += (mm_long) (forces[3*index]*0x100000000);
+        forceBuffers[atom+PADDED_NUM_ATOMS] += (mm_long) (forces[3*index+1]*0x100000000);
+        forceBuffers[atom+2*PADDED_NUM_ATOMS] += (mm_long) (forces[3*index+2]*0x100000000);
+    }
+}
diff --git a/platforms/cuda/src/CudaKernelFactory.cpp b/platforms/cuda/src/CudaKernelFactory.cpp
index 42b3fd943a..7298264b36 100644
--- a/platforms/cuda/src/CudaKernelFactory.cpp
+++ b/platforms/cuda/src/CudaKernelFactory.cpp
@@ -6,7 +6,7 @@
  * Biological Structures at Stanford, funded under the NIH Roadmap for        *
  * Medical Research, grant U54 GM072970. See https://simtk.org.               *
  *                                                                            *
- * Portions copyright (c) 2008-2019 Stanford University and the Authors.      *
+ * Portions copyright (c) 2008-2023 Stanford University and the Authors.      *
  * Authors: Peter Eastman                                                     *
  * Contributors:                                                              *
  *                                                                            *
@@ -113,6 +113,8 @@ KernelImpl* CudaKernelFactory::createKernelImpl(std::string name, const Platform
         return new CudaCalcCustomCVForceKernel(name, platform, cu);
     if (name == CalcATMForceKernel::Name())
         return new CudaCalcATMForceKernel(name, platform, cu);
+    if (name == CalcCustomCPPForceKernel::Name())
+        return new CommonCalcCustomCPPForceKernel(name, platform, context, cu);
     if (name == CalcRMSDForceKernel::Name())
         return new CommonCalcRMSDForceKernel(name, platform, cu);
     if (name == CalcCustomManyParticleForceKernel::Name())
diff --git a/platforms/cuda/src/CudaPlatform.cpp b/platforms/cuda/src/CudaPlatform.cpp
index 465eb0c944..1ff097ac93 100644
--- a/platforms/cuda/src/CudaPlatform.cpp
+++ b/platforms/cuda/src/CudaPlatform.cpp
@@ -91,6 +91,7 @@ CudaPlatform::CudaPlatform() {
     registerKernelFactory(CalcCustomHbondForceKernel::Name(), factory);
     registerKernelFactory(CalcCustomCentroidBondForceKernel::Name(), factory);
     registerKernelFactory(CalcCustomCompoundBondForceKernel::Name(), factory);
+    registerKernelFactory(CalcCustomCPPForceKernel::Name(), factory);
     registerKernelFactory(CalcCustomCVForceKernel::Name(), factory);
     registerKernelFactory(CalcATMForceKernel::Name(), factory);
     registerKernelFactory(CalcRMSDForceKernel::Name(), factory);
diff --git a/platforms/opencl/src/OpenCLKernelFactory.cpp b/platforms/opencl/src/OpenCLKernelFactory.cpp
index bdd7c4457b..b55f1a07cd 100644
--- a/platforms/opencl/src/OpenCLKernelFactory.cpp
+++ b/platforms/opencl/src/OpenCLKernelFactory.cpp
@@ -6,7 +6,7 @@
  * Biological Structures at Stanford, funded under the NIH Roadmap for        *
  * Medical Research, grant U54 GM072970. See https://simtk.org.               *
  *                                                                            *
- * Portions copyright (c) 2008-2019 Stanford University and the Authors.      *
+ * Portions copyright (c) 2008-2023 Stanford University and the Authors.      *
  * Authors: Peter Eastman                                                     *
  * Contributors:                                                              *
  *                                                                            *
@@ -110,7 +110,9 @@ KernelImpl* OpenCLKernelFactory::createKernelImpl(std::string name, const Platfo
     if (name == CalcCustomCVForceKernel::Name())
         return new OpenCLCalcCustomCVForceKernel(name, platform, cl);
     if (name == CalcATMForceKernel::Name())
-        return new OpenCLCalcATMForceKernel(name, platform, cl);
+        return new OpenCLCalcATMForceKernel(name, platform, cl);
+    if (name == CalcCustomCPPForceKernel::Name())
+        return new CommonCalcCustomCPPForceKernel(name, platform, context, cl);
     if (name == CalcRMSDForceKernel::Name())
         return new CommonCalcRMSDForceKernel(name, platform, cl);
     if (name == CalcCustomManyParticleForceKernel::Name())
diff --git a/platforms/opencl/src/OpenCLPlatform.cpp b/platforms/opencl/src/OpenCLPlatform.cpp
index 8bfed33a3d..feaa15a25d 100644
--- a/platforms/opencl/src/OpenCLPlatform.cpp
+++ b/platforms/opencl/src/OpenCLPlatform.cpp
@@ -6,7 +6,7 @@
  * Biological Structures at Stanford, funded under the NIH Roadmap for        *
  * Medical Research, grant U54 GM072970. See https://simtk.org.               *
  *                                                                            *
- * Portions copyright (c) 2008-2018 Stanford University and the Authors.      *
+ * Portions copyright (c) 2008-2023 Stanford University and the Authors.      *
  * Authors: Peter Eastman                                                     *
  * Contributors:                                                              *
  *                                                                            *
@@ -82,6 +82,7 @@ OpenCLPlatform::OpenCLPlatform() {
     registerKernelFactory(CalcCustomHbondForceKernel::Name(), factory);
     registerKernelFactory(CalcCustomCentroidBondForceKernel::Name(), factory);
     registerKernelFactory(CalcCustomCompoundBondForceKernel::Name(), factory);
+    registerKernelFactory(CalcCustomCPPForceKernel::Name(), factory);
     registerKernelFactory(CalcCustomCVForceKernel::Name(), factory);
     registerKernelFactory(CalcATMForceKernel::Name(), factory);
     registerKernelFactory(CalcRMSDForceKernel::Name(), factory);
diff --git a/platforms/reference/include/ReferenceKernels.h b/platforms/reference/include/ReferenceKernels.h
index 22ea8f71a0..962cc26c13 100644
--- a/platforms/reference/include/ReferenceKernels.h
+++ b/platforms/reference/include/ReferenceKernels.h
@@ -34,6 +34,7 @@

 #include "ReferencePlatform.h"
 #include "openmm/kernels.h"
+#include "openmm/internal/CustomCPPForceImpl.h"
 #include "openmm/internal/CustomNonbondedForceImpl.h"
 #include "SimTKOpenMMRealType.h"
 #include "ReferenceNeighborList.h"
@@ -1680,6 +1681,34 @@ class ReferenceCalcATMForceKernel : public CalcATMForceKernel {
     std::vector<Vec3> displ0;
 };

+/**
+ * This kernel is invoked by CustomCPPForceImpl to calculate the forces acting on the system and the energy of the system.
+ */
+class ReferenceCalcCustomCPPForceKernel : public CalcCustomCPPForceKernel {
+public:
+    ReferenceCalcCustomCPPForceKernel(std::string name, const Platform& platform) : CalcCustomCPPForceKernel(name, platform), force(NULL) {
+    }
+    /**
+     * Initialize the kernel.
+     *
+     * @param system     the System this kernel will be applied to
+     * @param force      the CustomCPPForceImpl this kernel will be used for
+     */
+    void initialize(const System& system, CustomCPPForceImpl& force);
+    /**
+     * Execute the kernel to calculate the forces and/or energy.
+     *
+     * @param context        the context in which to execute this kernel
+     * @param includeForces  true if forces should be calculated
+     * @param includeEnergy  true if the energy should be calculated
+     * @return the potential energy due to the force
+     */
+    double execute(ContextImpl& context, bool includeForces, bool includeEnergy);
+private:
+    CustomCPPForceImpl* force;
+    std::vector<Vec3> forces;
+};
+
 } // namespace OpenMM

 #endif /*OPENMM_REFERENCEKERNELS_H_*/
diff --git a/platforms/reference/src/ReferenceKernelFactory.cpp b/platforms/reference/src/ReferenceKernelFactory.cpp
index bba17d0439..35c72d049d 100644
--- a/platforms/reference/src/ReferenceKernelFactory.cpp
+++ b/platforms/reference/src/ReferenceKernelFactory.cpp
@@ -82,6 +82,8 @@ KernelImpl* ReferenceKernelFactory::createKernelImpl(std::string name, const Pla
         return new ReferenceCalcCustomCVForceKernel(name, platform);
     if (name == CalcATMForceKernel::Name())
         return new ReferenceCalcATMForceKernel(name, platform);
+    if (name == CalcCustomCPPForceKernel::Name())
+        return new ReferenceCalcCustomCPPForceKernel(name, platform);
     if (name == CalcRMSDForceKernel::Name())
         return new ReferenceCalcRMSDForceKernel(name, platform);
     if (name == CalcCustomManyParticleForceKernel::Name())
diff --git a/platforms/reference/src/ReferenceKernels.cpp b/platforms/reference/src/ReferenceKernels.cpp
index 0fc8eb177c..d9e94a4f11 100644
--- a/platforms/reference/src/ReferenceKernels.cpp
+++ b/platforms/reference/src/ReferenceKernels.cpp
@@ -3032,3 +3032,18 @@ void ReferenceCalcATMForceKernel::copyParametersToContext(ContextImpl& context,
         displ0[i] = displacement0;
     }
 }
+
+void ReferenceCalcCustomCPPForceKernel::initialize(const System& system, CustomCPPForceImpl& force) {
+    this->force = &force;
+    forces.resize(system.getNumParticles());
+}
+
+double ReferenceCalcCustomCPPForceKernel::execute(ContextImpl& context, bool includeForces, bool includeEnergy) {
+    vector<Vec3>& posData = extractPositions(context);
+    vector<Vec3>& forceData = extractForces(context);
+    double energy = force->computeForce(context, posData, forces);
+    if (includeForces)
+        for (int i = 0; i < forces.size(); i++)
+            forceData[i] += forces[i];
+    return energy;
+}
diff --git a/platforms/reference/src/ReferencePlatform.cpp b/platforms/reference/src/ReferencePlatform.cpp
index 09c7368058..8269f7cf7a 100644
--- a/platforms/reference/src/ReferencePlatform.cpp
+++ b/platforms/reference/src/ReferencePlatform.cpp
@@ -61,6 +61,7 @@ ReferencePlatform::ReferencePlatform() {
     registerKernelFactory(CalcCustomHbondForceKernel::Name(), factory);
     registerKernelFactory(CalcCustomCentroidBondForceKernel::Name(), factory);
     registerKernelFactory(CalcCustomCompoundBondForceKernel::Name(), factory);
+    registerKernelFactory(CalcCustomCPPForceKernel::Name(), factory);
     registerKernelFactory(CalcCustomCVForceKernel::Name(), factory);
     registerKernelFactory(CalcATMForceKernel::Name(), factory);
     registerKernelFactory(CalcRMSDForceKernel::Name(), factory);

diff --git a/platforms/cpu/tests/TestCpuCustomCPPForce.cpp b/platforms/cpu/tests/TestCpuCustomCPPForce.cpp
new file mode 100644
index 0000000000..aa4c42ea68
--- /dev/null
+++ b/platforms/cpu/tests/TestCpuCustomCPPForce.cpp
@@ -0,0 +1,37 @@
+/* -------------------------------------------------------------------------- *
+ *                                   OpenMM                                   *
+ * -------------------------------------------------------------------------- *
+ * This is part of the OpenMM molecular simulation toolkit originating from   *
+ * Simbios, the NIH National Center for Physics-Based Simulation of           *
+ * Biological Structures at Stanford, funded under the NIH Roadmap for        *
+ * Medical Research, grant U54 GM072970. See https://simtk.org.               *
+ *                                                                            *
+ * Portions copyright (c) 2023 Stanford University and the Authors.           *
+ * Authors: Peter Eastman                                                     *
+ * Contributors:                                                              *
+ *                                                                            *
+ * Permission is hereby granted, free of charge, to any person obtaining a    *
+ * copy of this software and associated documentation files (the "Software"), *
+ * to deal in the Software without restriction, including without limitation  *
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,   *
+ * and/or sell copies of the Software, and to permit persons to whom the      *
+ * Software is furnished to do so, subject to the following conditions:       *
+ *                                                                            *
+ * The above copyright notice and this permission notice shall be included in *
+ * all copies or substantial portions of the Software.                        *
+ *                                                                            *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR *
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,   *
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL    *
+ * THE AUTHORS, CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,    *
+ * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR      *
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE  *
+ * USE OR OTHER DEALINGS IN THE SOFTWARE.                                     *
+ * -------------------------------------------------------------------------- */
+
+#include "CpuTests.h"
+#include "TestCustomCPPForce.h"
+
+void runPlatformTests() {
+}
+
diff --git a/platforms/cuda/tests/TestCudaCustomCPPForce.cpp b/platforms/cuda/tests/TestCudaCustomCPPForce.cpp
new file mode 100644
index 0000000000..f294405cf6
--- /dev/null
+++ b/platforms/cuda/tests/TestCudaCustomCPPForce.cpp
@@ -0,0 +1,37 @@
+/* -------------------------------------------------------------------------- *
+ *                                   OpenMM                                   *
+ * -------------------------------------------------------------------------- *
+ * This is part of the OpenMM molecular simulation toolkit originating from   *
+ * Simbios, the NIH National Center for Physics-Based Simulation of           *
+ * Biological Structures at Stanford, funded under the NIH Roadmap for        *
+ * Medical Research, grant U54 GM072970. See https://simtk.org.               *
+ *                                                                            *
+ * Portions copyright (c) 2023 Stanford University and the Authors.           *
+ * Authors: Peter Eastman                                                     *
+ * Contributors:                                                              *
+ *                                                                            *
+ * Permission is hereby granted, free of charge, to any person obtaining a    *
+ * copy of this software and associated documentation files (the "Software"), *
+ * to deal in the Software without restriction, including without limitation  *
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,   *
+ * and/or sell copies of the Software, and to permit persons to whom the      *
+ * Software is furnished to do so, subject to the following conditions:       *
+ *                                                                            *
+ * The above copyright notice and this permission notice shall be included in *
+ * all copies or substantial portions of the Software.                        *
+ *                                                                            *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR *
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,   *
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL    *
+ * THE AUTHORS, CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,    *
+ * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR      *
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE  *
+ * USE OR OTHER DEALINGS IN THE SOFTWARE.                                     *
+ * -------------------------------------------------------------------------- */
+
+#include "CudaTests.h"
+#include "TestCustomCPPForce.h"
+
+void runPlatformTests() {
+}
+
diff --git a/platforms/opencl/tests/TestOpenCLCustomCPPForce.cpp b/platforms/opencl/tests/TestOpenCLCustomCPPForce.cpp
new file mode 100644
index 0000000000..da89dea302
--- /dev/null
+++ b/platforms/opencl/tests/TestOpenCLCustomCPPForce.cpp
@@ -0,0 +1,37 @@
+/* -------------------------------------------------------------------------- *
+ *                                   OpenMM                                   *
+ * -------------------------------------------------------------------------- *
+ * This is part of the OpenMM molecular simulation toolkit originating from   *
+ * Simbios, the NIH National Center for Physics-Based Simulation of           *
+ * Biological Structures at Stanford, funded under the NIH Roadmap for        *
+ * Medical Research, grant U54 GM072970. See https://simtk.org.               *
+ *                                                                            *
+ * Portions copyright (c) 2023 Stanford University and the Authors.           *
+ * Authors: Peter Eastman                                                     *
+ * Contributors:                                                              *
+ *                                                                            *
+ * Permission is hereby granted, free of charge, to any person obtaining a    *
+ * copy of this software and associated documentation files (the "Software"), *
+ * to deal in the Software without restriction, including without limitation  *
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,   *
+ * and/or sell copies of the Software, and to permit persons to whom the      *
+ * Software is furnished to do so, subject to the following conditions:       *
+ *                                                                            *
+ * The above copyright notice and this permission notice shall be included in *
+ * all copies or substantial portions of the Software.                        *
+ *                                                                            *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR *
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,   *
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL    *
+ * THE AUTHORS, CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,    *
+ * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR      *
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE  *
+ * USE OR OTHER DEALINGS IN THE SOFTWARE.                                     *
+ * -------------------------------------------------------------------------- */
+
+#include "OpenCLTests.h"
+#include "TestCustomCPPForce.h"
+
+void runPlatformTests() {
+}
+
diff --git a/platforms/reference/tests/TestReferenceCustomCPPForce.cpp b/platforms/reference/tests/TestReferenceCustomCPPForce.cpp
new file mode 100644
index 0000000000..57d9e2f2d6
--- /dev/null
+++ b/platforms/reference/tests/TestReferenceCustomCPPForce.cpp
@@ -0,0 +1,37 @@
+/* -------------------------------------------------------------------------- *
+ *                                   OpenMM                                   *
+ * -------------------------------------------------------------------------- *
+ * This is part of the OpenMM molecular simulation toolkit originating from   *
+ * Simbios, the NIH National Center for Physics-Based Simulation of           *
+ * Biological Structures at Stanford, funded under the NIH Roadmap for        *
+ * Medical Research, grant U54 GM072970. See https://simtk.org.               *
+ *                                                                            *
+ * Portions copyright (c) 2023 Stanford University and the Authors.           *
+ * Authors: Peter Eastman                                                     *
+ * Contributors:                                                              *
+ *                                                                            *
+ * Permission is hereby granted, free of charge, to any person obtaining a    *
+ * copy of this software and associated documentation files (the "Software"), *
+ * to deal in the Software without restriction, including without limitation  *
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,   *
+ * and/or sell copies of the Software, and to permit persons to whom the      *
+ * Software is furnished to do so, subject to the following conditions:       *
+ *                                                                            *
+ * The above copyright notice and this permission notice shall be included in *
+ * all copies or substantial portions of the Software.                        *
+ *                                                                            *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR *
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,   *
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL    *
+ * THE AUTHORS, CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,    *
+ * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR      *
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE  *
+ * USE OR OTHER DEALINGS IN THE SOFTWARE.                                     *
+ * -------------------------------------------------------------------------- */
+
+
+#include "ReferenceTests.h"
+#include "TestCustomCPPForce.h"
+
+void runPlatformTests() {
+}
diff --git a/tests/TestCustomCPPForce.h b/tests/TestCustomCPPForce.h
new file mode 100644
index 0000000000..e2832038f1
--- /dev/null
+++ b/tests/TestCustomCPPForce.h
@@ -0,0 +1,111 @@
+/* -------------------------------------------------------------------------- *
+ *                                   OpenMM                                   *
+ * -------------------------------------------------------------------------- *
+ * This is part of the OpenMM molecular simulation toolkit originating from   *
+ * Simbios, the NIH National Center for Physics-Based Simulation of           *
+ * Biological Structures at Stanford, funded under the NIH Roadmap for        *
+ * Medical Research, grant U54 GM072970. See https://simtk.org.               *
+ *                                                                            *
+ * Portions copyright (c) 2023 Stanford University and the Authors.           *
+ * Authors: Peter Eastman                                                     *
+ * Contributors:                                                              *
+ *                                                                            *
+ * Permission is hereby granted, free of charge, to any person obtaining a    *
+ * copy of this software and associated documentation files (the "Software"), *
+ * to deal in the Software without restriction, including without limitation  *
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,   *
+ * and/or sell copies of the Software, and to permit persons to whom the      *
+ * Software is furnished to do so, subject to the following conditions:       *
+ *                                                                            *
+ * The above copyright notice and this permission notice shall be included in *
+ * all copies or substantial portions of the Software.                        *
+ *                                                                            *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR *
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,   *
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL    *
+ * THE AUTHORS, CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,    *
+ * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR      *
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE  *
+ * USE OR OTHER DEALINGS IN THE SOFTWARE.                                     *
+ * -------------------------------------------------------------------------- */
+
+#include "openmm/internal/AssertionUtilities.h"
+#include "openmm/Context.h"
+#include "openmm/NonbondedForce.h"
+#include "openmm/internal/CustomCPPForceImpl.h"
+#include "openmm/System.h"
+#include "openmm/VerletIntegrator.h"
+#include <iostream>
+#include <vector>
+
+using namespace OpenMM;
+using namespace std;
+
+class TestForce : public Force {
+protected:
+    ForceImpl* createImpl() const;
+};
+
+class TestForceImpl : public CustomCPPForceImpl {
+public:
+    const TestForce& owner;
+    TestForceImpl(const TestForce& owner) : CustomCPPForceImpl(owner), owner(owner) {
+    }
+    double computeForce(ContextImpl& context, const vector<Vec3>& positions, vector<Vec3>& forces) {
+        for (int i = 0; i < forces.size(); i++)
+            forces[i] = Vec3(i, i+1, i+2);
+        return 10.5;
+    }
+    const TestForce& getOwner() const {
+        return owner;
+    }
+};
+
+ForceImpl* TestForce::createImpl() const {
+    return new TestForceImpl(*this);
+}
+
+void testForce() {
+    int numParticles = 3;
+    System system;
+    NonbondedForce* nb = new NonbondedForce();
+    TestForce* test = new TestForce();
+    nb->setForceGroup(0);
+    test->setForceGroup(1);
+    system.addForce(test);
+    system.addForce(nb);
+    vector<Vec3> positions;
+    for (int i = 0; i < numParticles; i++) {
+        system.addParticle(1.0);
+        nb->addParticle(0.0, 0.5, 1.0);
+        positions.push_back(Vec3(0.6*i, 0, 0));
+    }
+    VerletIntegrator integrator(0.01);
+    Context context(system, integrator, platform);
+    context.setPositions(positions);
+    State state1 = context.getState(State::Forces | State::Energy);
+    State state2 = context.getState(State::Forces | State::Energy, false, 1<<0);
+    State state3 = context.getState(State::Forces | State::Energy, false, 1<<1);
+    ASSERT_EQUAL_TOL(10.5, state3.getPotentialEnergy(), 1e-6);
+    ASSERT_EQUAL_TOL(state1.getPotentialEnergy(), state2.getPotentialEnergy()+state3.getPotentialEnergy(), 1e-6);
+    for (int i = 0; i < numParticles; i++) {
+        ASSERT_EQUAL_VEC(Vec3(i, i+1, i+2), state3.getForces()[i], 1e-6);
+        ASSERT_EQUAL_VEC(state1.getForces()[i], state2.getForces()[i]+state3.getForces()[i], 1e-6);
+    }
+}
+
+void runPlatformTests();
+
+int main(int argc, char* argv[]) {
+    try {
+        initializeTests(argc, argv);
+        testForce();
+        runPlatformTests();
+    }
+    catch(const exception& e) {
+        cout << "exception: " << e.what() << endl;
+        return 1;
+    }
+    cout << "Done" << endl;
+    return 0;
+}


Serialization proxy for ATMForce did not record the particles (openmm/openmm#4317)

`ATMForce` SIGSEGV's after copying / serialization

Copying (or serializing / deserializing) a system that contains an ATM force, and then using that system in a context yields a segfault:

```
import openmm

nb_force = openmm.NonbondedForce()
nb_force.addParticle(0.0, 1.0, 1.0)

atm_force = openmm.ATMForce(0.0, 0.0, 0.0, 0.0, 0.0, 100.0, 50.0, 1.0 / 16.0, 1)
atm_force.addParticle(openmm.Vec3(0.0, 0.0, 0.0))
atm_force.addForce(nb_force)

system = openmm.System()
system.addParticle(1.0)
system.addForce(atm_force)

system = openmm.XmlSerializer.deserialize(openmm.XmlSerializer.serialize(system))

context = openmm.Context(system, openmm.VerletIntegrator(1.0))
context.setPositions([openmm.Vec3(0.0, 0.0, 0.0)] * openmm.unit.angstrom)

print(context.getState(getEnergy=True).getPotentialEnergy(), flush=True)
```

removing the `system = openmm.XmlSerializer.deserialize(openmm.XmlSerializer.serialize(system))` line yields an energy of 0.0 as exepcted without any segfaults.

## 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

diff --git a/serialization/src/ATMForceProxy.cpp b/serialization/src/ATMForceProxy.cpp
index d86cbd4a50..75d70fb6f8 100644
--- a/serialization/src/ATMForceProxy.cpp
+++ b/serialization/src/ATMForceProxy.cpp
@@ -60,6 +60,13 @@ void ATMForceProxy::serialize(const void* object, SerializationNode& node) const
         SerializationNode& f = forces.createChildNode("Force");
         f.createChildNode("Force", &force.getForce(i));
     }
+    SerializationNode& particles = node.createChildNode("Particles");
+    for (int i = 0; i < force.getNumParticles(); i++) {
+        Vec3 d1, d0;
+        force.getParticleParameters(i, d1, d0);
+        particles.createChildNode("Particle").setDoubleProperty("d1x", d1[0]).setDoubleProperty("d1y", d1[1]).setDoubleProperty("d1z", d1[2])
+                .setDoubleProperty("d0x", d0[0]).setDoubleProperty("d0y", d0[1]).setDoubleProperty("d0z", d0[2]);
+    }
 }

 void* ATMForceProxy::deserialize(const SerializationNode& node) const {
@@ -80,6 +87,10 @@ void* ATMForceProxy::deserialize(const SerializationNode& node) const {
         const SerializationNode& forces = node.getChildNode("Forces");
         for (auto& f : forces.getChildren())
             force->addForce(f.getChildren()[0].decodeObject<Force>());
+        const SerializationNode& particles = node.getChildNode("Particles");
+        for (auto& p : particles.getChildren())
+            force->addParticle(Vec3(p.getDoubleProperty("d1x"), p.getDoubleProperty("d1y"), p.getDoubleProperty("d1z")),
+                               Vec3(p.getDoubleProperty("d0x"), p.getDoubleProperty("d0y"), p.getDoubleProperty("d0z")));
         return force;
     }
     catch (...) {

diff --git a/serialization/tests/TestSerializeATMForce.cpp b/serialization/tests/TestSerializeATMForce.cpp
index e55f2fe5f4..641ff0d46a 100644
--- a/serialization/tests/TestSerializeATMForce.cpp
+++ b/serialization/tests/TestSerializeATMForce.cpp
@@ -55,6 +55,8 @@ void testSerialization() {
     HarmonicAngleForce* v2 = new HarmonicAngleForce();
     v2->addAngle(3, 11, 15, 0.4, 0.2);
     force.addForce(v2);
+    force.addParticle(Vec3(1, 2, 3));
+    force.addParticle(Vec3(0, 0, -1), Vec3(3, 2, 1));

     // Serialize and then deserialize it.

@@ -83,6 +85,14 @@ void testSerialization() {
         XmlSerializer::serialize<Force>(&force2.getForce(i), "Force", buffer2);
         ASSERT_EQUAL(buffer1.str(), buffer2.str());
     }
+    ASSERT_EQUAL(force.getNumParticles(), force2.getNumParticles());
+    for (int i = 0; i < force.getNumParticles(); i++) {
+        Vec3 d1a, d1b, d0a, d0b;
+        force.getParticleParameters(i, d1a, d0a);
+        force2.getParticleParameters(i, d1b, d0b);
+        ASSERT_EQUAL_VEC(d1a, d1b, 0.0);
+        ASSERT_EQUAL_VEC(d0a, d0b, 0.0);
+    }
 }

 int main() {


Fixes to Python API for ATMForce (openmm/openmm#4319)

`ATMForce.getParticleParameters` python wrapper requires displacement vectors

The python wrapper around the `getParticleParameters` method on the new `ATMForce` seems to want you to pass in two `Vec3` pointers that presumably the C++ layer will then update with the displacements set for particle `i`.

Was this the intention, or should the exposed python version of the method take only an index and instead return the `Vec3` displacements?

## 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

diff --git a/wrappers/python/src/swig_doxygen/swigInputConfig.py b/wrappers/python/src/swig_doxygen/swigInputConfig.py
index 24b598ae98..4c110b7be8 100755
--- a/wrappers/python/src/swig_doxygen/swigInputConfig.py
+++ b/wrappers/python/src/swig_doxygen/swigInputConfig.py
@@ -521,4 +521,5 @@
 ("ATMForce", "getDefaultUmax") :  ('unit.kilojoule_per_mole', ()),
 ("ATMForce", "getDefaultUbcore") :  ('unit.kilojoule_per_mole', ()),
 ("ATMForce", "getDefaultAcore") :  (None, ()),
+("ATMForce", "getParticleParameters") : (None, ("unit.nanometer", "unit.nanometer")),
 }
diff --git a/wrappers/python/src/swig_doxygen/swig_lib/python/typemaps.i b/wrappers/python/src/swig_doxygen/swig_lib/python/typemaps.i
index fba646d820..f33f09cf2c 100644
--- a/wrappers/python/src/swig_doxygen/swig_lib/python/typemaps.i
+++ b/wrappers/python/src/swig_doxygen/swig_lib/python/typemaps.i
@@ -537,16 +537,23 @@ int Py_SequenceToVecVecVecDouble(PyObject* obj, std::vector<std::vector<std::vec
 %typemap(argout) const std::vector<Vec3>& {
 }

-
+/* The following typemaps handle the ways a Vec3 can be returned from a function. */
 %typemap(out, fragment="Vec3_to_PyVec3") Vec3 {
     $result = Vec3_to_PyVec3(*$1);
 }

-
 %typemap(out, fragment="Vec3_to_PyVec3") const Vec3& {
     $result = Vec3_to_PyVec3(*$1);
 }

+%typemap(in, numinputs=0) Vec3& OUTPUT (Vec3 temp) {
+    $1 = &temp;
+}
+
+%typemap(argout, fragment="Vec3_to_PyVec3") Vec3& OUTPUT {
+    %append_output(Vec3_to_PyVec3(*$1));
+}
+
 /* Convert C++ (Vec3&, Vec3&, Vec3&) object to python tuple or tuples */
 %typemap(argout, fragment="Vec3_to_PyVec3") (Vec3& a, Vec3& b, Vec3& c) {
     PyObject* pyVec1 = Vec3_to_PyVec3(*$1);

diff --git a/wrappers/python/tests/TestAPIUnits.py b/wrappers/python/tests/TestAPIUnits.py
index f2d0deedff..d7e75f8bad 100644
--- a/wrappers/python/tests/TestAPIUnits.py
+++ b/wrappers/python/tests/TestAPIUnits.py
@@ -526,6 +526,23 @@ def testCustomManyParticleForce(self):
         self.assertEqual(force.getParticleParameters(1)[0][0], 20)
         self.assertEqual(force.getParticleParameters(2)[0][0], 30*4.184)

+    def testATMForce(self):
+        """Tests the ATMForce API features"""
+        force = ATMForce(0.1, 0.2, 0.3, 0.4, 0.5, 0.7, 0.6, 0.8, -1.0);
+        force.addParticle(Vec3(1, 2, 3), Vec3(4, 5, 6))
+        self.assertEqual(0.1, force.getGlobalParameterDefaultValue(0))
+        self.assertEqual(0.2, force.getGlobalParameterDefaultValue(1))
+        self.assertEqual(0.3, force.getGlobalParameterDefaultValue(2))
+        self.assertEqual(0.4, force.getGlobalParameterDefaultValue(3))
+        self.assertEqual(0.5, force.getGlobalParameterDefaultValue(4))
+        self.assertEqual(0.7, force.getGlobalParameterDefaultValue(5))
+        self.assertEqual(0.6, force.getGlobalParameterDefaultValue(6))
+        self.assertEqual(0.8, force.getGlobalParameterDefaultValue(7))
+        self.assertEqual(-1.0, force.getGlobalParameterDefaultValue(8))
+        d1, d0 = force.getParticleParameters(0)
+        self.assertEqual(Vec3(1, 2, 3)*nanometers, d1)
+        self.assertEqual(Vec3(4, 5, 6)*nanometers, d0)
+
     def testDrudeForce(self):
         """ Tests the DrudeForce API features """
         force = DrudeForce()