Radius of gyration force (openmm/openmm#5031)

This adds a RGForce class that computes the radius of gyration.  It is mainly intended for use with CustomCVForce so you can use Rg as part of restraint potentials, metadynamics, etc.

Fixes #5015.

Radius of Gyration restraint force in OpenMM

Hello!

We are recently investigating moving from CHARMM to OpenMM for some coarse-grained MD runs for comparison with SAXS data. We are very excited with the potential performance of some initial runs.

However, we are running into an issue with implementing a RGYR Restraint Force ala CHARMM in OpenMM. We followed your suggestion of creating one group per atom, and one group with all atoms, and a bond for each single-atom group with the group containing all atoms within a CustomCentroidBondForce wrapped in CustomCVForce that does the harmonic restraint.
```
num_atoms = len(atom_indices)
rg2_force = CustomCentroidBondForce(2, f"(distance(g1, g2)^2)/{num_atoms}")
for i in atom_indices:
    rg2_force.addGroup([i], [1.0])  # g1: single atom
rg2_force.addGroup(atom_indices, [1.0] * num_atoms
for i in range(num_atoms):
    rg2_force.addBond([i, num_atoms])  # last group is full group (index = num_atoms)
super().__init__("0.5 * k_rg * (sqrt(cv) - rg0)^2")
self.addGlobalParameter("k_rg", k_rg)
self.addGlobalParameter("rg0", rg0)
self.addCollectiveVariable("cv", rg2_force)
self.setForceGroup(force_group)
```

1. As you cautioned in the other issue - this works when the number of atoms involved in the calc of the collective variable is in 100s - time for setup is a few seconds. When the number of atoms which are involved in the calculation of Rg is closer to a 1000, then the setup time becomes slower but still few tens of seconds. However, when we go to ~8000 atoms, the code doesn't move past the Simulation creation step for ~30 mins (longest we waited to test it). Is this expected?
The attached test case uses ~8000 atoms for the Rg restraint - ```python scripts/md.py``` should repr it in the attached tar. There is an nsys profile attached in the tar in case its helpful. A profile for a successful run (around 1000 atoms) shows that most of this time, it is in the ```findMoleculeGroups()``` function. In the py code it is at the ```simulation = Simulation(modeller.topology, system, integrator)``` step.
2. We see only one thread on the CPU being active when where it possibly hangs. Is this part expected to be multi-threaded or single threaded?  Is the setup part parallelizable?
3. We are under the impression that the Rg / Rg-based force calculation itself is not time consuming, it is the setup part that is currently just hanging (or requiring time > 30 min to complete) - but the dynamics calculation after setup completes proceeds faster since its using the GPU/is optimized - is this right?
4. So, if we implemented the restraint force via a plugin (i.e. without using the bond infrastructure), would that avoid the slow setup problem since we would not need to create a group of >> 1000 atoms involved in a bond - the possibly non-optimized single threaded part of the code? Is that a reasonable approach?

[openmm-test.tar.gz](https://github.com/user-attachments/files/21306632/openmm-test.tar.gz)

How you installed OpenMM (pip, conda, compile from source, etc.).
    - compiled from source
- The operating system and hardware (e.g. GPU model) you are using.
    - Running on Perlmutter, A100
- The platform (CUDA, OpenCL, etc.) you are using.
   - CUDA
- Any other software (Python libraries, setup tools, etc.) you are using along with OpenMM to implement your workflow.
   - None
- The output of the command `python -m openmm.testInstallation`. It reports details about your installation and checks for some basic problems.
```
OpenMM Version: 8.3.1
Git Revision: 6e13f1361dcc3ffb581c7c97e3adfcc61257b813

There are 3 Platforms available:

1 Reference - Successfully computed forces
2 CPU - Successfully computed forces
3 CUDA - Successfully computed forces

Median difference in forces between platforms:

Reference vs. CPU: 6.30823e-06
Reference vs. CUDA: 6.74442e-06
CPU vs. CUDA: 7.12587e-07

All differences are within tolerance.
```
Thanks!

## 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/api-c++/forces.rst b/docs-source/api-c++/forces.rst
index fdb99b272a..f1c45cb818 100644
--- a/docs-source/api-c++/forces.rst
+++ b/docs-source/api-c++/forces.rst
@@ -70,6 +70,7 @@ be combined in arbitrary ways.
     generated/MonteCarloBarostat
     generated/MonteCarloFlexibleBarostat
     generated/MonteCarloMembraneBarostat
+    generated/RGForce
     generated/RMSDForce
     generated/RPMDMonteCarloBarostat

diff --git a/docs-source/usersguide/theory/02_standard_forces.rst b/docs-source/usersguide/theory/02_standard_forces.rst
index 031990da80..9f8f6e5a1c 100644
--- a/docs-source/usersguide/theory/02_standard_forces.rst
+++ b/docs-source/usersguide/theory/02_standard_forces.rst
@@ -790,3 +790,21 @@ This force is normally used with a CustomCVForce (see Section :numref:`customcvf
 One rarely wants a force whose energy exactly equals the RMSD, but there are many
 situations where it is useful to have a restraining or biasing force that depends
 on the RMSD in some way.
+
+RGForce
+*********
+
+RGForce computes the radius of gyration (Rg) of a set of particle positions:
+
+.. math::
+   \text{Rg} = \sqrt{\frac{\sum_{i} \| \mathbf{x}_i - \mathbf{x}_c \|^2}{N}}
+
+where :math:`\mathbf{x}_c` is the center position,
+
+.. math::
+   \mathbf{x}_c = \frac{\sum_{i} \mathbf{x}_i}{N}
+
+This force is normally used with a CustomCVForce (see Section :numref:`customcvforce`).
+One rarely wants a force whose energy exactly equals Rg, but there are many
+situations where it is useful to have a restraining or biasing force that depends
+on Rg in some way.
diff --git a/olla/include/openmm/kernels.h b/olla/include/openmm/kernels.h
index bae1996a4d..be7e0bdbe0 100644
--- a/olla/include/openmm/kernels.h
+++ b/olla/include/openmm/kernels.h
@@ -59,6 +59,7 @@
 #include "openmm/PeriodicTorsionForce.h"
 #include "openmm/QTBIntegrator.h"
 #include "openmm/RBTorsionForce.h"
+#include "openmm/RGForce.h"
 #include "openmm/RMSDForce.h"
 #include "openmm/NonbondedForce.h"
 #include "openmm/System.h"
@@ -1071,6 +1072,34 @@ class CalcRMSDForceKernel : public KernelImpl {
     virtual void copyParametersToContext(ContextImpl& context, const RMSDForce& force) = 0;
 };

+/**
+ * This kernel is invoked by RGForce to calculate the forces acting on the system and the energy of the system.
+ */
+class CalcRGForceKernel : public KernelImpl {
+public:
+    static std::string Name() {
+        return "CalcRGForce";
+    }
+    CalcRGForceKernel(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 RGForce this kernel will be used for
+     */
+    virtual void initialize(const System& system, const RGForce& 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;
+};
+
 /**
  * This kernel is invoked by VerletIntegrator to take one time step.
  */
diff --git a/openmmapi/include/OpenMM.h b/openmmapi/include/OpenMM.h
index df064cbc28..3004cc0c30 100644
--- a/openmmapi/include/OpenMM.h
+++ b/openmmapi/include/OpenMM.h
@@ -70,6 +70,7 @@
 #include "openmm/PeriodicTorsionForce.h"
 #include "openmm/QTBIntegrator.h"
 #include "openmm/RBTorsionForce.h"
+#include "openmm/RGForce.h"
 #include "openmm/RMSDForce.h"
 #include "openmm/State.h"
 #include "openmm/System.h"
diff --git a/openmmapi/include/openmm/RGForce.h b/openmmapi/include/openmm/RGForce.h
new file mode 100644
index 0000000000..3c3e65ccb0
--- /dev/null
+++ b/openmmapi/include/openmm/RGForce.h
@@ -0,0 +1,89 @@
+#ifndef OPENMM_RGFORCE_H_
+#define OPENMM_RGFORCE_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) 2025 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 "Force.h"
+#include "Vec3.h"
+#include <vector>
+#include "internal/windowsExport.h"
+
+namespace OpenMM {
+
+/**
+ * This is a force whose energy equals the radius of gyration (Rg) of a set of
+ * particles.  It is intended for use with CustomCVForce.  You will not normally
+ * want a force that exactly equals the radius of gyration, but there are many
+ * situations where it is useful to have a restraining or biasing force that
+ * depends on Rg in some way.
+ */
+
+class OPENMM_EXPORT RGForce : public Force {
+public:
+    /**
+     * Create an RGForce.
+     *
+     * @param particles    the indices of the particles to use when computing
+     *                     Rg.  If this is empty (the default), all particles in
+     *                     the system will be used.
+     */
+    explicit RGForce(const std::vector<int>& particles=std::vector<int>());
+    /**
+     * Get the indices of the particles to use when computing Rg.  If this
+     * is empty, all particles in the system will be used.
+     */
+    const std::vector<int>& getParticles() const {
+        return particles;
+    }
+    /**
+     * Set the indices of the particles to use when computing Rg.  If this
+     * is empty, all particles in the system will be used.
+     */
+    void setParticles(const std::vector<int>& particles);
+    /**
+     * Returns whether or not this force makes use of periodic boundary
+     * conditions.
+     *
+     * @returns true if force uses PBC and false otherwise
+     */
+    bool usesPeriodicBoundaryConditions() const {
+        return false;
+    }
+protected:
+    ForceImpl* createImpl() const;
+private:
+    std::vector<int> particles;
+};
+
+} // namespace OpenMM
+
+#endif /*OPENMM_RGFORCE_H_*/
diff --git a/openmmapi/include/openmm/internal/RGForceImpl.h b/openmmapi/include/openmm/internal/RGForceImpl.h
new file mode 100644
index 0000000000..7febebc1e3
--- /dev/null
+++ b/openmmapi/include/openmm/internal/RGForceImpl.h
@@ -0,0 +1,71 @@
+#ifndef OPENMM_RGFORCEIMPL_H_
+#define OPENMM_RGFORCEIMPL_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) 2025 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/RGForce.h"
+#include "openmm/Kernel.h"
+#include <utility>
+#include <map>
+#include <string>
+
+namespace OpenMM {
+
+/**
+ * This is the internal implementation of RGForce.
+ */
+
+class RGForceImpl : public ForceImpl {
+public:
+    RGForceImpl(const RGForce& owner);
+    ~RGForceImpl();
+    void initialize(ContextImpl& context);
+    const RGForce& getOwner() const {
+        return owner;
+    }
+    void updateContextState(ContextImpl& context, bool& forcesInvalid) {
+        // This force field doesn't update the state directly.
+    }
+    double calcForcesAndEnergy(ContextImpl& context, bool includeForces, bool includeEnergy, int groups);
+    std::map<std::string, double> getDefaultParameters() {
+        return std::map<std::string, double>(); // This force doesn't define any parameters.
+    }
+    std::vector<std::string> getKernelNames();
+private:
+    const RGForce& owner;
+    Kernel kernel;
+};
+
+} // namespace OpenMM
+
+#endif /*OPENMM_RGFORCEIMPL_H_*/
diff --git a/openmmapi/src/RGForce.cpp b/openmmapi/src/RGForce.cpp
new file mode 100644
index 0000000000..ce30d02c17
--- /dev/null
+++ b/openmmapi/src/RGForce.cpp
@@ -0,0 +1,47 @@
+/* -------------------------------------------------------------------------- *
+ *                                   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) 2025 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/RGForce.h"
+#include "openmm/internal/RGForceImpl.h"
+
+using namespace OpenMM;
+using namespace std;
+
+RGForce::RGForce(const vector<int>& particles) : particles(particles) {
+}
+
+void RGForce::setParticles(const std::vector<int>& particles) {
+    this->particles = particles;
+}
+
+ForceImpl* RGForce::createImpl() const {
+    return new RGForceImpl(*this);
+}
diff --git a/openmmapi/src/RGForceImpl.cpp b/openmmapi/src/RGForceImpl.cpp
new file mode 100644
index 0000000000..df50f77cf9
--- /dev/null
+++ b/openmmapi/src/RGForceImpl.cpp
@@ -0,0 +1,84 @@
+/* -------------------------------------------------------------------------- *
+ *                                   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) 2025 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/OpenMMException.h"
+#include "openmm/internal/ContextImpl.h"
+#include "openmm/internal/RGForceImpl.h"
+#include "openmm/kernels.h"
+#include <set>
+#include <sstream>
+
+using namespace OpenMM;
+using namespace std;
+
+RGForceImpl::RGForceImpl(const RGForce& owner) : owner(owner) {
+    forceGroup = owner.getForceGroup();
+}
+
+RGForceImpl::~RGForceImpl() {
+}
+
+void RGForceImpl::initialize(ContextImpl& context) {
+    kernel = context.getPlatform().createKernel(CalcRGForceKernel::Name(), context);
+
+    // Check for errors in the specification of particles.
+    const System& system = context.getSystem();
+    int numParticles = system.getNumParticles();
+    set<int> particles;
+    for (int i : owner.getParticles()) {
+        if (i < 0 || i >= numParticles) {
+            stringstream msg;
+            msg << "RGForce: Illegal particle index for Rg: ";
+            msg << i;
+            throw OpenMMException(msg.str());
+        }
+        if (particles.find(i) != particles.end()) {
+            stringstream msg;
+            msg << "RGForce: Duplicated particle index for Rg: ";
+            msg << i;
+            throw OpenMMException(msg.str());
+        }
+        particles.insert(i);
+    }
+    kernel.getAs<CalcRGForceKernel>().initialize(context.getSystem(), owner);
+}
+
+double RGForceImpl::calcForcesAndEnergy(ContextImpl& context, bool includeForces, bool includeEnergy, int groups) {
+    if ((groups&(1<<forceGroup)) != 0)
+        return kernel.getAs<CalcRGForceKernel>().execute(context, includeForces, includeEnergy);
+    return 0.0;
+}
+
+vector<string> RGForceImpl::getKernelNames() {
+    vector<string> names;
+    names.push_back(CalcRGForceKernel::Name());
+    return names;
+}
diff --git a/platforms/common/include/openmm/common/CommonKernels.h b/platforms/common/include/openmm/common/CommonKernels.h
index ffda15d675..be7bf01c3c 100644
--- a/platforms/common/include/openmm/common/CommonKernels.h
+++ b/platforms/common/include/openmm/common/CommonKernels.h
@@ -1158,6 +1158,37 @@ class CommonCalcRMSDForceKernel : public CalcRMSDForceKernel {
     ComputeKernel kernel1, kernel2;
 };

+/**
+ * This kernel is invoked by RGForce to calculate the forces acting on the system and the energy of the system.
+ */
+class CommonCalcRGForceKernel : public CalcRGForceKernel {
+public:
+    CommonCalcRGForceKernel(std::string name, const Platform& platform, ComputeContext& cc) : CalcRGForceKernel(name, platform), cc(cc) {
+    }
+    /**
+     * Initialize the kernel.
+     *
+     * @param system     the System this kernel will be applied to
+     * @param force      the RGForce this kernel will be used for
+     */
+    void initialize(const System& system, const RGForce& 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:
+    class ReorderListener;
+    ComputeContext& cc;
+    int blockSize;
+    ComputeArray particles, centerBuffer, rgBuffer;
+    ComputeKernel centerKernel, rgKernel, forceKernel;
+};
+
 /**
  * This kernel is invoked by AndersenThermostat at the start of each time step to adjust the particle velocities.
  */
diff --git a/platforms/common/src/CommonKernels.cpp b/platforms/common/src/CommonKernels.cpp
index 6526df339f..b159bd880a 100644
--- a/platforms/common/src/CommonKernels.cpp
+++ b/platforms/common/src/CommonKernels.cpp
@@ -3592,6 +3592,90 @@ void CommonCalcRMSDForceKernel::copyParametersToContext(ContextImpl& context, co
     cc.invalidateMolecules(info);
 }

+class CommonCalcRGForceKernel::ReorderListener : public ComputeContext::ReorderListener {
+public:
+    ReorderListener(ComputeContext& cc, const vector<int>& particleIndices, ArrayInterface& particles) : cc(cc),
+            particleIndices(particleIndices), particles(particles) {
+    }
+    void execute() {
+        vector<int> particleVec(particles.getSize());
+        const vector<int>& order = cc.getAtomIndex();
+        vector<int> invOrder(cc.getPaddedNumAtoms());
+        for (int i = 0; i < order.size(); i++)
+            invOrder[order[i]] = i;
+        for (int i = 0; i < particleIndices.size(); i++)
+            particleVec[i] = invOrder[particleIndices[i]];
+        particles.upload(particleVec);
+    }
+private:
+    ComputeContext& cc;
+    const vector<int>& particleIndices;
+    ArrayInterface& particles;
+};
+
+void CommonCalcRGForceKernel::initialize(const System& system, const RGForce& force) {
+    // Create data structures.
+
+    ContextSelector selector(cc);
+    bool useDouble = cc.getUseDoublePrecision();
+    int elementSize = (useDouble ? sizeof(double) : sizeof(float));
+    int numParticles = force.getParticles().size();
+    if (numParticles == 0)
+        numParticles = system.getNumParticles();
+    particles.initialize<int>(cc, numParticles, "particles");
+    centerBuffer.initialize(cc, 3*(cc.getNumThreadBlocks()+1), elementSize, "centerBuffer");
+    rgBuffer.initialize(cc, cc.getNumThreadBlocks(), elementSize, "rgBuffer");
+
+    // Create the kernels.
+
+    blockSize = min(256, cc.getMaxThreadBlockSize());
+    map<string, string> defines;
+    defines["THREAD_BLOCK_SIZE"] = cc.intToString(blockSize);
+    defines["PADDED_NUM_ATOMS"] = cc.intToString(cc.getPaddedNumAtoms());
+    ComputeProgram program = cc.compileProgram(CommonKernelSources::rg, defines);
+    centerKernel = program->createKernel("computeCenterPosition");
+    rgKernel = program->createKernel("computeRg");
+    forceKernel = program->createKernel("computeForces");
+    centerKernel->addArg(numParticles);
+    centerKernel->addArg(cc.getPosq());
+    centerKernel->addArg(particles);
+    centerKernel->addArg(centerBuffer);
+    rgKernel->addArg(numParticles);
+    rgKernel->addArg(cc.getPosq());
+    rgKernel->addArg(particles);
+    rgKernel->addArg(centerBuffer);
+    rgKernel->addArg(rgBuffer);
+    forceKernel->addArg(numParticles);
+    forceKernel->addArg(cc.getPosq());
+    forceKernel->addArg(particles);
+    forceKernel->addArg(centerBuffer);
+    forceKernel->addArg(rgBuffer);
+    forceKernel->addArg(cc.getLongForceBuffer());
+    forceKernel->addArg(cc.getEnergyBuffer());
+
+    // Create the listener for updating the list of particles.
+
+    if (force.getParticles().size() == 0) {
+        vector<int> particleVec(numParticles);
+        for (int i = 0; i < numParticles; i++)
+            particleVec[i] = i;
+        particles.upload(particleVec);
+    }
+    else {
+        ReorderListener* listener = new ReorderListener(cc, force.getParticles(), particles);
+        cc.addReorderListener(listener);
+        listener->execute();
+    }
+}
+
+double CommonCalcRGForceKernel::execute(ContextImpl& context, bool includeForces, bool includeEnergy) {
+    ContextSelector selector(cc);
+    centerKernel->execute(particles.getSize(), blockSize);
+    rgKernel->execute(particles.getSize(), blockSize);
+    forceKernel->execute(particles.getSize(), blockSize);
+    return 0.0;
+}
+
 void CommonApplyAndersenThermostatKernel::initialize(const System& system, const AndersenThermostat& thermostat) {
     ContextSelector selector(cc);
     randomSeed = thermostat.getRandomNumberSeed();
diff --git a/platforms/common/src/kernels/rg.cc b/platforms/common/src/kernels/rg.cc
new file mode 100644
index 0000000000..f9ffdcca55
--- /dev/null
+++ b/platforms/common/src/kernels/rg.cc
@@ -0,0 +1,107 @@
+/**
+ * Sum a value over all threads.
+ */
+DEVICE real reduceValue(real value, LOCAL_ARG volatile real* temp) {
+    const int thread = LOCAL_ID;
+    SYNC_THREADS;
+    temp[thread] = value;
+    SYNC_THREADS;
+    for (int step = 1; step < 32; step *= 2) {
+        if (thread+step < LOCAL_SIZE && thread%(2*step) == 0)
+            temp[thread] = temp[thread] + temp[thread+step];
+        SYNC_WARPS;
+    }
+    for (int step = 32; step < LOCAL_SIZE; step *= 2) {
+        if (thread+step < LOCAL_SIZE && thread%(2*step) == 0)
+            temp[thread] = temp[thread] + temp[thread+step];
+        SYNC_THREADS;
+    }
+    return temp[0];
+}
+
+/**
+ * First step of computing the radius of gyration: each thread block computes a contribution
+ * to the center position.
+ */
+KERNEL void computeCenterPosition(int numParticles, GLOBAL const real4* RESTRICT posq, GLOBAL const int* RESTRICT particles,
+        GLOBAL real* centerBuffer) {
+    LOCAL volatile real temp[THREAD_BLOCK_SIZE];
+    real3 center = make_real3(0);
+    for (int i = GLOBAL_ID; i < numParticles; i += GLOBAL_SIZE)
+        center += trimTo3(posq[particles[i]]);
+    center.x = reduceValue(center.x, temp);
+    center.y = reduceValue(center.y, temp);
+    center.z = reduceValue(center.z, temp);
+    if (LOCAL_ID == 0) {
+        centerBuffer[3*GROUP_ID] = center.x;
+        centerBuffer[3*GROUP_ID+1] = center.y;
+        centerBuffer[3*GROUP_ID+2] = center.z;
+    }
+}
+
+/**
+ * Second step of computing the radius of gyration: each thread block computes a contribution to Rg^2.
+ */
+KERNEL void computeRg(int numParticles, GLOBAL const real4* RESTRICT posq, GLOBAL const int* RESTRICT particles,
+        GLOBAL real* centerBuffer, GLOBAL real* rgBuffer) {
+    LOCAL volatile real temp[THREAD_BLOCK_SIZE];
+
+    // Sum the contributions computed in the previous kernel to find the center position.
+
+    real3 center = make_real3(0);
+    for (int i = LOCAL_ID; i < NUM_GROUPS; i += LOCAL_SIZE) {
+        center.x += centerBuffer[3*i];
+        center.y += centerBuffer[3*i+1];
+        center.z += centerBuffer[3*i+2];
+    }
+    center.x = reduceValue(center.x, temp)/numParticles;
+    center.y = reduceValue(center.y, temp)/numParticles;
+    center.z = reduceValue(center.z, temp)/numParticles;
+    if (GLOBAL_ID == 0) {
+        // Save the result so we don't need to compute it again in the next kernel.
+
+        centerBuffer[3*NUM_GROUPS] = center.x;
+        centerBuffer[3*NUM_GROUPS+1] = center.y;
+        centerBuffer[3*NUM_GROUPS+2] = center.z;
+    }
+
+    // Compute the contributions to Rg^2.
+
+    real rg2 = 0;
+    for (int i = GLOBAL_ID; i < numParticles; i += GLOBAL_SIZE) {
+        real3 delta = trimTo3(posq[particles[i]]) - center;
+        rg2 += dot(delta, delta);
+    }
+    rg2 = reduceValue(rg2, temp);
+    if (LOCAL_ID == 0)
+        rgBuffer[GROUP_ID] = rg2;
+}
+
+/**
+ * Third step of computing the radius of gyration: compute the forces.
+ */
+KERNEL void computeForces(int numParticles, GLOBAL const real4* RESTRICT posq, GLOBAL const int* RESTRICT particles,
+        GLOBAL const real* centerBuffer, GLOBAL real* rgBuffer, GLOBAL mm_ulong* RESTRICT forceBuffers, GLOBAL mixed* RESTRICT energyBuffer) {
+    LOCAL volatile real temp[THREAD_BLOCK_SIZE];
+    real3 center = make_real3(centerBuffer[3*NUM_GROUPS], centerBuffer[3*NUM_GROUPS+1], centerBuffer[3*NUM_GROUPS+2]);
+
+    // Sum the contributions computed in the previous kernel to find Rg.
+
+    real rg2 = 0;
+    for (int i = LOCAL_ID; i < NUM_GROUPS; i += LOCAL_SIZE)
+        rg2 += rgBuffer[i];
+    real rg = SQRT(reduceValue(rg2, temp)/numParticles);
+    if (GLOBAL_ID == 0)
+        energyBuffer[0] += rg;
+
+    // Compute the forces.
+
+    real scale = 1/(rg*numParticles);
+    for (int i = GLOBAL_ID; i < numParticles; i += GLOBAL_SIZE) {
+        int particle = particles[i];
+        real3 delta = trimTo3(posq[particle]) - center;
+        ATOMIC_ADD(&forceBuffers[particle], (mm_ulong) realToFixedPoint(-scale*delta.x));
+        ATOMIC_ADD(&forceBuffers[particle+PADDED_NUM_ATOMS], (mm_ulong) realToFixedPoint(-scale*delta.y));
+        ATOMIC_ADD(&forceBuffers[particle+2*PADDED_NUM_ATOMS], (mm_ulong) realToFixedPoint(-scale*delta.z));
+    }
+}
diff --git a/platforms/cuda/src/CudaKernelFactory.cpp b/platforms/cuda/src/CudaKernelFactory.cpp
index 01f493fcc9..989584703f 100644
--- a/platforms/cuda/src/CudaKernelFactory.cpp
+++ b/platforms/cuda/src/CudaKernelFactory.cpp
@@ -123,6 +123,8 @@ KernelImpl* CudaKernelFactory::createKernelImpl(std::string name, const Platform
         return new CudaCalcATMForceKernel(name, platform, cu);
     if (name == CalcCustomCPPForceKernel::Name())
         return new CommonCalcCustomCPPForceKernel(name, platform, context, cu);
+    if (name == CalcRGForceKernel::Name())
+        return new CommonCalcRGForceKernel(name, platform, 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 6adc21fb23..4625eb2cd2 100644
--- a/platforms/cuda/src/CudaPlatform.cpp
+++ b/platforms/cuda/src/CudaPlatform.cpp
@@ -94,6 +94,7 @@ CudaPlatform::CudaPlatform() {
     registerKernelFactory(CalcCustomCPPForceKernel::Name(), factory);
     registerKernelFactory(CalcCustomCVForceKernel::Name(), factory);
     registerKernelFactory(CalcATMForceKernel::Name(), factory);
+    registerKernelFactory(CalcRGForceKernel::Name(), factory);
     registerKernelFactory(CalcRMSDForceKernel::Name(), factory);
     registerKernelFactory(CalcCustomManyParticleForceKernel::Name(), factory);
     registerKernelFactory(CalcGayBerneForceKernel::Name(), factory);
diff --git a/platforms/hip/src/HipKernelFactory.cpp b/platforms/hip/src/HipKernelFactory.cpp
index 16d2f7b782..b3eade589c 100644
--- a/platforms/hip/src/HipKernelFactory.cpp
+++ b/platforms/hip/src/HipKernelFactory.cpp
@@ -122,6 +122,8 @@ KernelImpl* HipKernelFactory::createKernelImpl(std::string name, const Platform&
         return new HipCalcCustomCVForceKernel(name, platform, cu);
     if (name == CalcCustomCPPForceKernel::Name())
         return new CommonCalcCustomCPPForceKernel(name, platform, context, cu);
+    if (name == CalcRGForceKernel::Name())
+        return new CommonCalcRGForceKernel(name, platform, cu);
     if (name == CalcRMSDForceKernel::Name())
         return new CommonCalcRMSDForceKernel(name, platform, cu);
     if (name == CalcCustomManyParticleForceKernel::Name())
diff --git a/platforms/hip/src/HipPlatform.cpp b/platforms/hip/src/HipPlatform.cpp
index dc0f9c2d52..69817904f6 100644
--- a/platforms/hip/src/HipPlatform.cpp
+++ b/platforms/hip/src/HipPlatform.cpp
@@ -94,6 +94,7 @@ HipPlatform::HipPlatform() {
     registerKernelFactory(CalcCustomCompoundBondForceKernel::Name(), factory);
     registerKernelFactory(CalcCustomCPPForceKernel::Name(), factory);
     registerKernelFactory(CalcCustomCVForceKernel::Name(), factory);
+    registerKernelFactory(CalcRGForceKernel::Name(), factory);
     registerKernelFactory(CalcRMSDForceKernel::Name(), factory);
     registerKernelFactory(CalcCustomManyParticleForceKernel::Name(), factory);
     registerKernelFactory(CalcGayBerneForceKernel::Name(), factory);
diff --git a/platforms/opencl/src/OpenCLKernelFactory.cpp b/platforms/opencl/src/OpenCLKernelFactory.cpp
index c758df70c7..2909350086 100644
--- a/platforms/opencl/src/OpenCLKernelFactory.cpp
+++ b/platforms/opencl/src/OpenCLKernelFactory.cpp
@@ -121,6 +121,8 @@ KernelImpl* OpenCLKernelFactory::createKernelImpl(std::string name, const Platfo
         return new OpenCLCalcATMForceKernel(name, platform, cl);
     if (name == CalcCustomCPPForceKernel::Name())
         return new CommonCalcCustomCPPForceKernel(name, platform, context, cl);
+    if (name == CalcRGForceKernel::Name())
+        return new CommonCalcRGForceKernel(name, platform, 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 fe81690b06..30388cb936 100644
--- a/platforms/opencl/src/OpenCLPlatform.cpp
+++ b/platforms/opencl/src/OpenCLPlatform.cpp
@@ -85,6 +85,7 @@ OpenCLPlatform::OpenCLPlatform() {
     registerKernelFactory(CalcCustomCPPForceKernel::Name(), factory);
     registerKernelFactory(CalcCustomCVForceKernel::Name(), factory);
     registerKernelFactory(CalcATMForceKernel::Name(), factory);
+    registerKernelFactory(CalcRGForceKernel::Name(), factory);
     registerKernelFactory(CalcRMSDForceKernel::Name(), factory);
     registerKernelFactory(CalcCustomManyParticleForceKernel::Name(), factory);
     registerKernelFactory(CalcGayBerneForceKernel::Name(), factory);
diff --git a/platforms/reference/include/ReferenceKernels.h b/platforms/reference/include/ReferenceKernels.h
index 6c614ff47e..9dd2b2edc0 100644
--- a/platforms/reference/include/ReferenceKernels.h
+++ b/platforms/reference/include/ReferenceKernels.h
@@ -1148,6 +1148,33 @@ class ReferenceCalcRMSDForceKernel : public CalcRMSDForceKernel {
     std::vector<int> particles;
 };

+/**
+ * This kernel is invoked by RGForce to calculate the forces acting on the system and the energy of the system.
+ */
+class ReferenceCalcRGForceKernel : public CalcRGForceKernel {
+public:
+    ReferenceCalcRGForceKernel(std::string name, const Platform& platform) : CalcRGForceKernel(name, platform) {
+    }
+    /**
+     * Initialize the kernel.
+     *
+     * @param system     the System this kernel will be applied to
+     * @param force      the RGForce this kernel will be used for
+     */
+    void initialize(const System& system, const RGForce& 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:
+    std::vector<int> particles;
+};
+
 /**
  * This kernel is invoked by VerletIntegrator to take one time step.
  */
diff --git a/platforms/reference/include/ReferenceRGForce.h b/platforms/reference/include/ReferenceRGForce.h
new file mode 100644
index 0000000000..a4948e0d71
--- /dev/null
+++ b/platforms/reference/include/ReferenceRGForce.h
@@ -0,0 +1,54 @@
+/* Portions copyright (c) 2025 Stanford University and Simbios.
+ * Contributors: Peter Eastman
+ *
+ * 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.
+ */
+
+#ifndef __ReferenceRGForce_H__
+#define __ReferenceRGForce_H__
+
+#include "openmm/RGForce.h"
+#include <vector>
+
+namespace OpenMM {
+
+class ReferenceRGForce {
+private:
+    std::vector<int> particles;
+
+public:
+    /**
+     * Constructor
+     */
+    ReferenceRGForce(std::vector<int>& particles);
+
+    /**
+     * Calculate the interaction.
+     *
+     * @param atomCoordinates    atom coordinates
+     * @param forces             the forces are added to this
+     * @return the energy of the interaction
+     */
+   double calculateIxn(std::vector<OpenMM::Vec3>& atomCoordinates, std::vector<OpenMM::Vec3>& forces) const;
+};
+
+} // namespace OpenMM
+
+#endif // __ReferenceRGForce_H__
diff --git a/platforms/reference/src/ReferenceKernelFactory.cpp b/platforms/reference/src/ReferenceKernelFactory.cpp
index dbe1421596..54951966ff 100644
--- a/platforms/reference/src/ReferenceKernelFactory.cpp
+++ b/platforms/reference/src/ReferenceKernelFactory.cpp
@@ -84,6 +84,8 @@ KernelImpl* ReferenceKernelFactory::createKernelImpl(std::string name, const Pla
         return new ReferenceCalcATMForceKernel(name, platform);
     if (name == CalcCustomCPPForceKernel::Name())
         return new ReferenceCalcCustomCPPForceKernel(name, platform);
+    if (name == CalcRGForceKernel::Name())
+        return new ReferenceCalcRGForceKernel(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 4c8e0dea12..aaae045710 100644
--- a/platforms/reference/src/ReferenceKernels.cpp
+++ b/platforms/reference/src/ReferenceKernels.cpp
@@ -62,6 +62,7 @@
 #include "ReferenceProperDihedralBond.h"
 #include "ReferenceQTBDynamics.h"
 #include "ReferenceRbDihedralBond.h"
+#include "ReferenceRGForce.h"
 #include "ReferenceRMSDForce.h"
 #include "ReferenceTabulatedFunction.h"
 #include "ReferenceVariableStochasticDynamics.h"
@@ -2308,6 +2309,20 @@ void ReferenceCalcRMSDForceKernel::copyParametersToContext(ContextImpl& context,
         p -= center;
 }

+void ReferenceCalcRGForceKernel::initialize(const System& system, const RGForce& force) {
+    particles = force.getParticles();
+    if (particles.size() == 0)
+        for (int i = 0; i < system.getNumParticles(); i++)
+            particles.push_back(i);
+}
+
+double ReferenceCalcRGForceKernel::execute(ContextImpl& context, bool includeForces, bool includeEnergy) {
+    vector<Vec3>& posData = extractPositions(context);
+    vector<Vec3>& forceData = extractForces(context);
+    ReferenceRGForce rg(particles);
+    return rg.calculateIxn(posData, forceData);
+}
+
 ReferenceIntegrateVerletStepKernel::~ReferenceIntegrateVerletStepKernel() {
     if (dynamics)
         delete dynamics;
diff --git a/platforms/reference/src/ReferencePlatform.cpp b/platforms/reference/src/ReferencePlatform.cpp
index 70fc071ec4..17bd8b31c6 100644
--- a/platforms/reference/src/ReferencePlatform.cpp
+++ b/platforms/reference/src/ReferencePlatform.cpp
@@ -65,6 +65,7 @@ ReferencePlatform::ReferencePlatform() {
     registerKernelFactory(CalcCustomCPPForceKernel::Name(), factory);
     registerKernelFactory(CalcCustomCVForceKernel::Name(), factory);
     registerKernelFactory(CalcATMForceKernel::Name(), factory);
+    registerKernelFactory(CalcRGForceKernel::Name(), factory);
     registerKernelFactory(CalcRMSDForceKernel::Name(), factory);
     registerKernelFactory(CalcCustomManyParticleForceKernel::Name(), factory);
     registerKernelFactory(CalcGayBerneForceKernel::Name(), factory);
diff --git a/platforms/reference/src/SimTKReference/ReferenceRGForce.cpp b/platforms/reference/src/SimTKReference/ReferenceRGForce.cpp
new file mode 100644
index 0000000000..481814bebf
--- /dev/null
+++ b/platforms/reference/src/SimTKReference/ReferenceRGForce.cpp
@@ -0,0 +1,59 @@
+/* Portions copyright (c) 2025 Stanford University and Simbios.
+ * Contributors: Peter Eastman
+ *
+ * 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 "ReferenceRGForce.h"
+#include <cmath>
+
+using namespace OpenMM;
+using namespace std;
+
+ReferenceRGForce::ReferenceRGForce(vector<int>& particles) : particles(particles) {
+}
+
+double ReferenceRGForce::calculateIxn(vector<Vec3>& atomCoordinates, vector<Vec3>& forces) const {
+    // Compute the center position.
+
+    int numParticles = particles.size();
+    Vec3 center;
+    for (int i : particles)
+        center += atomCoordinates[i];
+    center /= numParticles;
+
+    // Compute the radius of gyration.
+
+    double sum = 0.0;
+    for (int i = 0; i < numParticles; i++) {
+        Vec3 delta = atomCoordinates[particles[i]]-center;
+        sum += delta.dot(delta);
+    }
+    double rg = sqrt(sum/numParticles);
+
+    // Compute the forces.
+
+    double scale = 1.0/(rg*numParticles);
+    for (int i = 0; i < numParticles; i++) {
+        Vec3 delta = atomCoordinates[particles[i]]-center;
+        forces[particles[i]] -= scale*delta;
+    }
+    return rg;
+}
diff --git a/serialization/include/openmm/serialization/RGForceProxy.h b/serialization/include/openmm/serialization/RGForceProxy.h
new file mode 100644
index 0000000000..3b218695b1
--- /dev/null
+++ b/serialization/include/openmm/serialization/RGForceProxy.h
@@ -0,0 +1,53 @@
+#ifndef OPENMM_RGFORCE_PROXY_H_
+#define OPENMM_RGFORCE_PROXY_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) 2025 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/windowsExport.h"
+#include "openmm/serialization/SerializationProxy.h"
+
+namespace OpenMM {
+
+/**
+ * This is a proxy for serializing RGForce objects.
+ */
+
+class OPENMM_EXPORT RGForceProxy : public SerializationProxy {
+public:
+    RGForceProxy();
+    void serialize(const void* object, SerializationNode& node) const;
+    void* deserialize(const SerializationNode& node) const;
+};
+
+} // namespace OpenMM
+
+#endif /*OPENMM_RGFORCE_PROXY_H_*/
diff --git a/serialization/src/RGForceProxy.cpp b/serialization/src/RGForceProxy.cpp
new file mode 100644
index 0000000000..32f5af722b
--- /dev/null
+++ b/serialization/src/RGForceProxy.cpp
@@ -0,0 +1,73 @@
+/* -------------------------------------------------------------------------- *
+ *                                   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) 2025 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/serialization/RGForceProxy.h"
+#include "openmm/serialization/SerializationNode.h"
+#include "openmm/Force.h"
+#include "openmm/RGForce.h"
+#include <sstream>
+
+using namespace OpenMM;
+using namespace std;
+
+RGForceProxy::RGForceProxy() : SerializationProxy("RGForce") {
+}
+
+void RGForceProxy::serialize(const void* object, SerializationNode& node) const {
+    node.setIntProperty("version", 0);
+    const RGForce& force = *reinterpret_cast<const RGForce*>(object);
+    node.setIntProperty("forceGroup", force.getForceGroup());
+    node.setStringProperty("name", force.getName());
+    SerializationNode& particlesNode = node.createChildNode("Particles");
+    for (int i : force.getParticles())
+       particlesNode.createChildNode("Particle").setIntProperty("index", i);
+}
+
+void* RGForceProxy::deserialize(const SerializationNode& node) const {
+    int version = node.getIntProperty("version");
+    if (version != 0)
+        throw OpenMMException("Unsupported version number");
+    RGForce* force = NULL;
+    try {
+        vector<int> particles;
+        for (auto& particle : node.getChildNode("Particles").getChildren())
+            particles.push_back(particle.getIntProperty("index"));
+        force = new RGForce(particles);
+        force->setForceGroup(node.getIntProperty("forceGroup", 0));
+        force->setName(node.getStringProperty("name", force->getName()));
+        return force;
+    }
+    catch (...) {
+        if (force != NULL)
+            delete force;
+        throw;
+    }
+}
diff --git a/serialization/src/SerializationProxyRegistration.cpp b/serialization/src/SerializationProxyRegistration.cpp
index cfeb746aca..eabafa0841 100644
--- a/serialization/src/SerializationProxyRegistration.cpp
+++ b/serialization/src/SerializationProxyRegistration.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) 2010-2021 Stanford University and the Authors.      *
+ * Portions copyright (c) 2010-2025 Stanford University and the Authors.      *
  * Authors: Peter Eastman                                                     *
  * Contributors:                                                              *
  *                                                                            *
@@ -64,6 +64,7 @@
 #include "openmm/PeriodicTorsionForce.h"
 #include "openmm/QTBIntegrator.h"
 #include "openmm/RBTorsionForce.h"
+#include "openmm/RGForce.h"
 #include "openmm/RMSDForce.h"
 #include "openmm/System.h"
 #include "openmm/TabulatedFunction.h"
@@ -107,6 +108,7 @@
 #include "openmm/serialization/PeriodicTorsionForceProxy.h"
 #include "openmm/serialization/QTBIntegratorProxy.h"
 #include "openmm/serialization/RBTorsionForceProxy.h"
+#include "openmm/serialization/RGForceProxy.h"
 #include "openmm/serialization/RMSDForceProxy.h"
 #include "openmm/serialization/StateProxy.h"
 #include "openmm/serialization/SystemProxy.h"
@@ -169,8 +171,9 @@ extern "C" void registerSerializationProxies() {
     SerializationProxy::registerProxy(typeid(NonbondedForce), new NonbondedForceProxy());
     SerializationProxy::registerProxy(typeid(NoseHooverIntegrator), new NoseHooverIntegratorProxy());
     SerializationProxy::registerProxy(typeid(PeriodicTorsionForce), new PeriodicTorsionForceProxy());
-    SerializationProxy::registerProxy(typeid(RBTorsionForce), new RBTorsionForceProxy());
     SerializationProxy::registerProxy(typeid(QTBIntegrator), new QTBIntegratorProxy());
+    SerializationProxy::registerProxy(typeid(RBTorsionForce), new RBTorsionForceProxy());
+    SerializationProxy::registerProxy(typeid(RGForce), new RGForceProxy());
     SerializationProxy::registerProxy(typeid(RMSDForce), new RMSDForceProxy());
     SerializationProxy::registerProxy(typeid(System), new SystemProxy());
     SerializationProxy::registerProxy(typeid(State), new StateProxy());
diff --git a/wrappers/python/src/swig_doxygen/swigInputConfig.py b/wrappers/python/src/swig_doxygen/swigInputConfig.py
index ebc155eac5..410d1a6039 100755
--- a/wrappers/python/src/swig_doxygen/swigInputConfig.py
+++ b/wrappers/python/src/swig_doxygen/swigInputConfig.py
@@ -491,6 +491,7 @@
 ("RMSDForce", "getReferencePositions") : ("unit.nanometer", ()),
 ("RMSDForce", "setReferencePositions") : (None, ("unit.nanometer",)),
 ("RMSDForce", "getParticles") : (None, ()),
+("RGForce", "getParticles") : (None, ()),
 ("NoseHooverChain", "NoseHooverChain") : (None, ("unit.kelvin", "unit.kelvin", "unit.picosecond**-1", "unit.picosecond**-1", None, None, None, None, None, None, None)),
 ("NoseHooverChain", "getThermostatedPairs") : (None, ()),
 ("NoseHooverChain", "getThermostatedAtoms") : (None, ()),

diff --git a/platforms/cuda/tests/TestCudaRGForce.cpp b/platforms/cuda/tests/TestCudaRGForce.cpp
new file mode 100644
index 0000000000..cb30ae5b87
--- /dev/null
+++ b/platforms/cuda/tests/TestCudaRGForce.cpp
@@ -0,0 +1,36 @@
+/* -------------------------------------------------------------------------- *
+ *                                   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) 2025 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 "TestRGForce.h"
+
+void runPlatformTests() {
+}
diff --git a/platforms/hip/tests/TestHipRGForce.cpp b/platforms/hip/tests/TestHipRGForce.cpp
new file mode 100644
index 0000000000..5b8caeac28
--- /dev/null
+++ b/platforms/hip/tests/TestHipRGForce.cpp
@@ -0,0 +1,36 @@
+/* -------------------------------------------------------------------------- *
+ *                                   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) 2025 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 "HipTests.h"
+#include "TestRGForce.h"
+
+void runPlatformTests() {
+}
diff --git a/platforms/opencl/tests/TestOpenCLRGForce.cpp b/platforms/opencl/tests/TestOpenCLRGForce.cpp
new file mode 100644
index 0000000000..d47fb0dd65
--- /dev/null
+++ b/platforms/opencl/tests/TestOpenCLRGForce.cpp
@@ -0,0 +1,36 @@
+/* -------------------------------------------------------------------------- *
+ *                                   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) 2025 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 "TestRGForce.h"
+
+void runPlatformTests() {
+}
diff --git a/platforms/reference/tests/TestReferenceRGForce.cpp b/platforms/reference/tests/TestReferenceRGForce.cpp
new file mode 100644
index 0000000000..5bdeb83d9d
--- /dev/null
+++ b/platforms/reference/tests/TestReferenceRGForce.cpp
@@ -0,0 +1,36 @@
+/* -------------------------------------------------------------------------- *
+ *                                   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) 2025 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 "TestRGForce.h"
+
+void runPlatformTests() {
+}
diff --git a/serialization/tests/TestSerializeRGForce.cpp b/serialization/tests/TestSerializeRGForce.cpp
new file mode 100644
index 0000000000..03f463143e
--- /dev/null
+++ b/serialization/tests/TestSerializeRGForce.cpp
@@ -0,0 +1,77 @@
+/* -------------------------------------------------------------------------- *
+ *                                   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) 2025 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/RGForce.h"
+#include "openmm/serialization/XmlSerializer.h"
+#include <iostream>
+#include <sstream>
+
+using namespace OpenMM;
+using namespace std;
+
+void testSerialization() {
+    // Create a Force.
+
+    vector<int> particles;
+    for (int i = 0; i < 5; i++)
+        particles.push_back(i*i);
+    RGForce force(particles);
+    force.setForceGroup(3);
+    force.setName("custom name");
+
+    // Serialize and then deserialize it.
+
+    stringstream buffer;
+    XmlSerializer::serialize<RGForce>(&force, "Force", buffer);
+    RGForce* copy = XmlSerializer::deserialize<RGForce>(buffer);
+
+    // Compare the two forces to see if they are identical.
+
+    RGForce& force2 = *copy;
+    ASSERT_EQUAL(force.getForceGroup(), force2.getForceGroup());
+    ASSERT_EQUAL(force.getName(), force2.getName());
+    ASSERT_EQUAL(force.getParticles().size(), force2.getParticles().size());
+    for (int i = 0; i < force.getParticles().size(); i++)
+        ASSERT_EQUAL(force.getParticles()[i], force2.getParticles()[i]);
+}
+
+int main() {
+    try {
+        testSerialization();
+    }
+    catch(const exception& e) {
+        cout << "exception: " << e.what() << endl;
+        return 1;
+    }
+    cout << "Done" << endl;
+    return 0;
+}
diff --git a/tests/TestRGForce.h b/tests/TestRGForce.h
new file mode 100644
index 0000000000..1a1cf59159
--- /dev/null
+++ b/tests/TestRGForce.h
@@ -0,0 +1,161 @@
+/* -------------------------------------------------------------------------- *
+ *                                   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) 2025 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/RGForce.h"
+#include "openmm/Context.h"
+#include "openmm/NonbondedForce.h"
+#include "openmm/System.h"
+#include "openmm/VerletIntegrator.h"
+#include "sfmt/SFMT.h"
+#include <cmath>
+#include <iostream>
+#include <vector>
+
+using namespace OpenMM;
+using namespace std;
+
+void testRG(bool allParticles) {
+    const int numParticles = 30;
+    System system;
+    vector<Vec3> positions(numParticles);
+    vector<int> particles;
+    OpenMM_SFMT::SFMT sfmt;
+    init_gen_rand(0, sfmt);
+    for (int i = 0; i < numParticles; ++i) {
+        system.addParticle(1.0);
+        if (genrand_real2(sfmt) < 0.5 || allParticles)
+            particles.push_back(i);
+    }
+    if (allParticles)
+        system.addForce(new RGForce()); // Omitting the list of particles should mean all particles.
+    else
+        system.addForce(new RGForce(particles));
+    VerletIntegrator integrator(0.001);
+    Context context(system, integrator, platform);
+    for (int i = 0; i < 10; i++) {
+        // Set all particles to random positions.
+
+        for (int j = 0; j < numParticles; j++)
+            positions[j] = Vec3(genrand_real2(sfmt), genrand_real2(sfmt), genrand_real2(sfmt))*5;
+        context.setPositions(positions);
+
+        // Compute Rg.
+
+        Vec3 center;
+        for (int j : particles)
+            center += positions[j];
+        center /= particles.size();
+        double sum = 0;
+        for (int j : particles) {
+            Vec3 v = positions[j]-center;
+            sum += v.dot(v);
+        }
+        double rg = sqrt(sum/particles.size());
+
+        // Compare to the value computed by the force.
+
+        State state = context.getState(State::Energy | State::Forces);
+        ASSERT_EQUAL_TOL(rg, state.getPotentialEnergy(), 1e-6);
+
+        // Take a small step in the direction of the energy gradient and see whether the potential energy changes by the expected amount.
+
+        const vector<Vec3>& forces = state.getForces();
+        double norm = 0.0;
+        for (int j = 0; j < (int) forces.size(); ++j)
+            norm += forces[j].dot(forces[j]);
+        norm = sqrt(norm);
+        const double stepSize = 0.1;
+        double step = 0.5*stepSize/norm;
+        vector<Vec3> positions2(numParticles), positions3(numParticles);
+        for (int j = 0; j < positions.size(); ++j) {
+            Vec3 p = positions[j];
+            Vec3 f = forces[j];
+            positions2[j] = Vec3(p[0]-f[0]*step, p[1]-f[1]*step, p[2]-f[2]*step);
+            positions3[j] = Vec3(p[0]+f[0]*step, p[1]+f[1]*step, p[2]+f[2]*step);
+        }
+        context.setPositions(positions2);
+        State state2 = context.getState(State::Energy);
+        context.setPositions(positions3);
+        State state3 = context.getState(State::Energy);
+        ASSERT_EQUAL_TOL(norm, (state2.getPotentialEnergy()-state3.getPotentialEnergy())/stepSize, 1e-4);
+    }
+}
+
+void testEnergyConservation() {
+    const int numParticles = 50;
+    System system;
+    vector<Vec3> positions(numParticles);
+    vector<int> particles;
+    OpenMM_SFMT::SFMT sfmt;
+    init_gen_rand(0, sfmt);
+    NonbondedForce* nb = new NonbondedForce(); // Add a nonbonded force to activate reordering on the GPU
+    nb->setNonbondedMethod(NonbondedForce::CutoffNonPeriodic);
+    system.addForce(nb);
+    for (int i = 0; i < numParticles; ++i) {
+        system.addParticle(2.0);
+        nb->addParticle(0.0, 0.1, 0.01);
+        positions[i] = Vec3(genrand_real2(sfmt), genrand_real2(sfmt), genrand_real2(sfmt))*5;
+        if (genrand_real2(sfmt) < 0.5)
+            particles.push_back(i);
+    }
+    system.addForce(new RGForce(particles));
+    VerletIntegrator integrator(0.001);
+    Context context(system, integrator, platform);
+    context.setPositions(positions);
+    context.setVelocitiesToTemperature(300.0, 0);
+    integrator.step(5);
+    State initialState = context.getState(State::Energy);
+    double energy = initialState.getPotentialEnergy()+initialState.getKineticEnergy();
+    for (int i = 0; i < 100; i++) {
+        integrator.step(5);
+        State state = context.getState(State::Energy);
+        ASSERT_EQUAL_TOL(energy, state.getPotentialEnergy()+state.getKineticEnergy(), 1e-4);
+    }
+}
+
+void runPlatformTests();
+
+int main(int argc, char* argv[]) {
+    try {
+        initializeTests(argc, argv);
+        testRG(true);
+        testRG(false);
+        testEnergyConservation();
+        runPlatformTests();
+    }
+    catch(const exception& e) {
+        cout << "exception: " << e.what() << endl;
+        return 1;
+    }
+    cout << "Done" << endl;
+    return 0;
+}


Don't allow LJPME when LJ interactions use `CustomNonbondedForce` (openmm/openmm#5044)

Updates Amber, CHARMM, Desmond, `ForceField`, and GROMACS loaders to raise `ValueError` if LJPME is requested on a system that requires `CustomNonbondedForce` to implement its Lennard-Jones interactions.  See https://github.com/openmm/openmm/discussions/5039#discussioncomment-14077098.  This is usually because of NBFIX, but it can also be due to non-standard mixing rules in Desmond and GROMACS.

### Issue Description

**Problem:**
When using Lennard-Jones Particle Mesh Ewald (LJPME) as the nonbonded method in systems that require `CustomNonbondedForce` to handle Lennard-Jones interactions, the current implementation does not properly handle cases involving non-standard mixing rules or NBFIX terms. This can lead to incorrect behavior or unsupported configurations without clear feedback to the user.

**Expected Behavior:**
The system should detect when LJPME is requested in scenarios where it is incompatible, such as when non-standard mixing rules or NBFIX terms are present. In such cases, the system should raise a clear and descriptive error, informing the user that LJPME is not supported in these configurations. This ensures that users are aware of the limitation and can adjust their setup accordingly.

## 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/charmmpsffile.py b/wrappers/python/openmm/app/charmmpsffile.py
index 46f9a176ac..126a2467e3 100644
--- a/wrappers/python/openmm/app/charmmpsffile.py
+++ b/wrappers/python/openmm/app/charmmpsffile.py
@@ -1217,6 +1217,8 @@ def _is_bond_in_water(bond):
                 force.addParticle(atm.charge, sigma_scale*atm.type.rmin*length_conv,
                                   abs(atm.type.epsilon*ene_conv))
         else:
+            if nonbondedMethod is ff.LJPME:
+                raise ValueError('LJPME is not supported when NBFIX terms are present')
             for atm in self.atom_list:
                 force.addParticle(atm.charge, 1.0, 0.0)
             # Now add the custom nonbonded force that implements NBFIX. First
diff --git a/wrappers/python/openmm/app/desmonddmsfile.py b/wrappers/python/openmm/app/desmonddmsfile.py
index c85d026655..33dab4b97d 100644
--- a/wrappers/python/openmm/app/desmonddmsfile.py
+++ b/wrappers/python/openmm/app/desmonddmsfile.py
@@ -368,12 +368,14 @@ def createSystem(self, nonbondedMethod=ff.NoCutoff, nonbondedCutoff=1.0*nanomete
         nb.setEwaldErrorTolerance(ewaldErrorTolerance)
         if cnb is not None:
             nb.setUseDispersionCorrection(False)
-            if nonbondedMethod in (ff.CutoffPeriodic, ff.Ewald, ff.PME, ff.LJPME):
+            if nonbondedMethod in (ff.CutoffPeriodic, ff.Ewald, ff.PME):
                 cnb.setNonbondedMethod(methodMap[ff.CutoffPeriodic])
                 cnb.setCutoffDistance(nonbondedCutoff)
             elif nonbondedMethod == ff.CutoffNonPeriodic:
                 cnb.setNonbondedMethod(methodMap[ff.CutoffNonPeriodic])
                 cnb.setCutoffDistance(nonbondedCutoff)
+            elif nonbondedMethod is ff.LJPME:
+                raise ValueError('LJPME is not supported with OPLS combining rules')
             else:
                 cnb.setNonbondedMethod(methodMap[ff.NoCutoff])
             cnb.setUseSwitchingFunction(False)
diff --git a/wrappers/python/openmm/app/forcefield.py b/wrappers/python/openmm/app/forcefield.py
index 5f8fa8c44c..9d7131a89c 100644
--- a/wrappers/python/openmm/app/forcefield.py
+++ b/wrappers/python/openmm/app/forcefield.py
@@ -2836,12 +2836,14 @@ def createForce(self, sys, data, nonbondedMethod, nonbondedCutoff, args):
         self.force.addTabulatedFunction('bcoef', mm.Discrete2DFunction(numLjTypes, numLjTypes, bcoef))
         self.force.addPerParticleParameter('type')
         self.force.setName('LennardJones')
-        if nonbondedMethod in [CutoffPeriodic, Ewald, PME, LJPME]:
+        if nonbondedMethod in [CutoffPeriodic, Ewald, PME]:
             self.force.setNonbondedMethod(mm.CustomNonbondedForce.CutoffPeriodic)
         elif nonbondedMethod is NoCutoff:
             self.force.setNonbondedMethod(mm.CustomNonbondedForce.NoCutoff)
         elif nonbondedMethod is CutoffNonPeriodic:
             self.force.setNonbondedMethod(mm.CustomNonbondedForce.CutoffNonPeriodic)
+        elif nonbondedMethod is LJPME:
+            raise ValueError('LJPME is not supported by LennardJonesForce')
         else:
             raise AssertionError('Unrecognized nonbonded method [%s]' % nonbondedMethod)
         if args['switchDistance'] is not None:
diff --git a/wrappers/python/openmm/app/gromacstopfile.py b/wrappers/python/openmm/app/gromacstopfile.py
index 343c8ff9f2..dec167c0a7 100644
--- a/wrappers/python/openmm/app/gromacstopfile.py
+++ b/wrappers/python/openmm/app/gromacstopfile.py
@@ -1239,6 +1239,8 @@ def convertParams(params):
             nb.setUseSwitchingFunction(True)
             nb.setSwitchingDistance(switchDistance)

+        if not (lj is None and ljnbfix is None) and nonbondedMethod is ff.LJPME:
+            raise ValueError('LJPME is not supported with active nonbond_params or combining rules other than 2')
         if lj is not None:
             methodMap = {ff.NoCutoff:mm.CustomNonbondedForce.NoCutoff,
                          ff.CutoffNonPeriodic:mm.CustomNonbondedForce.CutoffNonPeriodic,
diff --git a/wrappers/python/openmm/app/internal/amber_file_parser.py b/wrappers/python/openmm/app/internal/amber_file_parser.py
index 55cbb83b26..1425de068d 100644
--- a/wrappers/python/openmm/app/internal/amber_file_parser.py
+++ b/wrappers/python/openmm/app/internal/amber_file_parser.py
@@ -1037,6 +1037,8 @@ def readAmberSystem(topology, prmtop_filename=None, prmtop_loader=None, shake=No

     # Copy the exceptions as exclusions to the CustomNonbondedForce if we have
     # NBFIX terms
+    if nbfix and nonbondedMethod == 'LJPME':
+        raise ValueError('LJPME is not supported with modified off-diagonal Lennard-Jones coefficients')
     if nbfix or has_1264:
         for i in range(force.getNumExceptions()):
             ii, jj, chg, sig, eps = force.getExceptionParameters(i)

diff --git a/wrappers/python/tests/TestAmberPrmtopFile.py b/wrappers/python/tests/TestAmberPrmtopFile.py
index 5f8678542d..74c87edb50 100644
--- a/wrappers/python/tests/TestAmberPrmtopFile.py
+++ b/wrappers/python/tests/TestAmberPrmtopFile.py
@@ -39,6 +39,12 @@ def test_NonbondedMethod(self):
                                 f.getNonbondedMethod()==methodMap[method]
                                 for f in forces))

+    def test_LJPME_NBFIX(self):
+        """Test LJPME with NBFIX."""
+
+        with self.assertRaisesRegex(ValueError, 'LJPME is not supported'):
+            prmtop3.createSystem(nonbondedMethod=LJPME)
+
     def test_Cutoff(self):
         """Test to make sure the nonbondedCutoff parameter is passed correctly."""

diff --git a/wrappers/python/tests/TestCharmmFiles.py b/wrappers/python/tests/TestCharmmFiles.py
index 7ac43e13a4..23f9ae9935 100644
--- a/wrappers/python/tests/TestCharmmFiles.py
+++ b/wrappers/python/tests/TestCharmmFiles.py
@@ -287,6 +287,8 @@ def testSystemOptions(self):
         # Check some illegal options
         self.assertRaises(ValueError, lambda:
                     psf.createSystem(params, nonbondedMethod=5))
+        self.assertRaisesRegex(ValueError, 'LJPME is not supported', lambda:
+                    psf.createSystem(params, nonbondedMethod=LJPME))
         self.assertRaises(TypeError, lambda:
                     psf.createSystem(params, nonbondedMethod=PME,
                                      nonbondedCutoff=1*radian)
@@ -302,6 +304,9 @@ def testSystemOptions(self):
         psf.createSystem(params, nonbondedMethod=PME, switchDistance=0.8,
                          nonbondedCutoff=1.2*nanometer)

+        psf_no_nbfix = CharmmPsfFile('systems/ala_ala_ala.psf', unitCellDimensions=Vec3(30, 30, 30)*angstroms)
+        psf_no_nbfix.createSystem(self.params, nonbondedMethod=LJPME)
+
     def test_ImplicitSolventForces(self):
         """Compute forces for different implicit solvent types, and compare them to ones generated with a previous version of OpenMM to ensure they haven't changed."""
         solventType = [HCT, OBC1, OBC2, GBn, GBn2]
diff --git a/wrappers/python/tests/TestDesmondDMSFile.py b/wrappers/python/tests/TestDesmondDMSFile.py
index fbe7c0bb6c..9e4b6711cd 100644
--- a/wrappers/python/tests/TestDesmondDMSFile.py
+++ b/wrappers/python/tests/TestDesmondDMSFile.py
@@ -37,7 +37,19 @@ def test_NonbondedMethod(self):
             self.assertTrue(any(isinstance(f, NonbondedForce) and
                                 f.getNonbondedMethod()==methodMap[method]
                                 for f in forces))
-
+
+    def test_LJPME_OPLS(self):
+        """Test LJPME with and without OPLS mixing rules."""
+
+        system = self.dms_opls1.createSystem(nonbondedMethod=LJPME, OPLS=False)
+        forces = system.getForces()
+        self.assertTrue(any(isinstance(f, NonbondedForce) and
+                            f.getNonbondedMethod()==NonbondedForce.LJPME
+                            for f in forces))
+
+        with self.assertRaisesRegex(ValueError, 'LJPME is not supported'):
+            self.dms_opls1.createSystem(nonbondedMethod=LJPME, OPLS=True)
+
     def test_Cutoff(self):
         """Test to make sure the nonbondedCutoff parameter is passed correctly."""

diff --git a/wrappers/python/tests/TestForceField.py b/wrappers/python/tests/TestForceField.py
index 9147635d66..13c5a4b86a 100644
--- a/wrappers/python/tests/TestForceField.py
+++ b/wrappers/python/tests/TestForceField.py
@@ -1314,6 +1314,10 @@ def test_LennardJonesGenerator(self):
         ene2 = state2.getPotentialEnergy().value_in_unit(kilocalories_per_mole)
         self.assertAlmostEqual(ene, ene2)

+        # LJPME should be forbidden with LennardJonesForce since it makes a CustomNonbondedForce to handle NBFix
+        with self.assertRaisesRegex(ValueError, 'LJPME is not supported'):
+            ff.createSystem(pdb.topology, nonbondedMethod=LJPME)
+
     def test_NBFix(self):
         """Test using LennardJonesGenerator to implement NBFix terms."""
         # Create a chain of seven atoms.
diff --git a/wrappers/python/tests/TestGromacsTopFile.py b/wrappers/python/tests/TestGromacsTopFile.py
index b8b9c3c30f..c27cebf2e6 100644
--- a/wrappers/python/tests/TestGromacsTopFile.py
+++ b/wrappers/python/tests/TestGromacsTopFile.py
@@ -36,6 +36,36 @@ def test_NonbondedMethod(self):
                                 f.getNonbondedMethod()==methodMap[method]
                                 for f in forces))

+    def test_LJPME_mixing(self):
+        """Test that LJPME is not allowed with non-standard LJ mixing or NBFIX."""
+
+        tests = {
+           'apgr.nbfix.pairs.comb1.top': False,
+           'apgr.nbfix.pairs.comb2.top': False,
+           'apgr.nbfix.pairs.comb3.top': False,
+           'apgr.nbfix.nopairs.comb1.top': False,
+           'apgr.nbfix.nopairs.comb2.top': False,
+           'apgr.nbfix.nopairs.comb3.top': False,
+           'apgr.nonbfix.pairs.comb1.top': False,
+           'apgr.nonbfix.pairs.comb2.top': True,
+           'apgr.nonbfix.pairs.comb3.top': False,
+           'apgr.nonbfix.nopairs.comb1.top': False,
+           'apgr.nonbfix.nopairs.comb2.top': True,
+           'apgr.nonbfix.nopairs.comb3.top': False,
+        }
+
+        for topfile, ljpme_allowed in tests.items():
+            top = GromacsTopFile(f'systems/{topfile}', unitCellDimensions=Vec3(30, 30, 30)*angstroms)
+            if ljpme_allowed:
+                system = top.createSystem(nonbondedMethod=LJPME)
+                forces = system.getForces()
+                self.assertTrue(any(isinstance(f, NonbondedForce) and
+                                    f.getNonbondedMethod()==NonbondedForce.LJPME
+                                    for f in forces))
+            else:
+                with self.assertRaisesRegex(ValueError, 'LJPME is not supported'):
+                    top.createSystem(nonbondedMethod=LJPME)
+
     def test_ff99SBILDN(self):
         """ Test Gromacs topology #define replacement as used in ff99SB-ILDN """
         top = GromacsTopFile('systems/aidilnaaaaa.top')


Fix error message formatting for bonds to elements without atoms (openmm/openmm#5065)

Fixes a minor bug in #5020 in which error messages for incorrect bonds to elements without atoms don't get formatted and an unrelated error gets shown instead.  Normally, there shouldn't be bonds to extra sites that would trigger this, but it can happen if one attempts to parameterize a topology with explicitly specified bonds to atoms for which OpenMM couldn't guess the elements.

### Issue Description

**Problem:**
When attempting to parameterize a molecular topology that includes explicitly specified bonds to atoms with undefined or unrecognized elements, the software fails to generate a meaningful error message. Instead, an unrelated error message is displayed, which does not accurately describe the issue. This can lead to confusion for users trying to debug their input files, as the actual cause of the error is obscured.

**Expected Behavior:**
When bonds to atoms with undefined or unrecognized elements are encountered during topology parameterization, the software should provide a clear and accurate error message. The message should explicitly indicate the issue, such as the presence of bonds to extra sites or elements without atoms, allowing users to identify and resolve the problem in their input files.

## 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/forcefield.py b/wrappers/python/openmm/app/forcefield.py
index 9d7131a89c..9d178e01c4 100644
--- a/wrappers/python/openmm/app/forcefield.py
+++ b/wrappers/python/openmm/app/forcefield.py
@@ -1888,8 +1888,8 @@ def formatAtomDiff(key, diff):

     def formatBondDiff(key, diff):
         """Formats a string describing elements associated with a different number of bonds."""
-        name1 = elem.Element.getByAtomicNumber(key[0]).symbol if key else 'extra site'
-        name2 = elem.Element.getByAtomicNumber(key[1]).symbol if key else 'extra site'
+        name1 = elem.Element.getByAtomicNumber(key[0]).symbol if key[0] else 'extra site'
+        name2 = elem.Element.getByAtomicNumber(key[1]).symbol if key[1] else 'extra site'
         return f'{name1}-{name2} bond' + ('' if diff == 1 else 's')

     def pickBestMatch(bestMatches):

diff --git a/wrappers/python/tests/TestForceField.py b/wrappers/python/tests/TestForceField.py
index 13c5a4b86a..c0f0d34c2b 100644
--- a/wrappers/python/tests/TestForceField.py
+++ b/wrappers/python/tests/TestForceField.py
@@ -987,6 +987,18 @@ def makeSystem(lines):
         with self.assertRaisesRegex(ValueError, 'No template found for residue.*HOH.*The force field contains no residue templates'):
             makeSystem(pdbLines)

+        # Make water with an extra site and an (invalid) bond to it.
+        pdbLines = [
+            'ATOM      0 O    HOH A   1       0       0       0                           O',
+            'ATOM      1 H1   HOH A   1       0       0       0                           H',
+            'ATOM      2 H2   HOH A   1       0       0       0                           H',
+            'ATOM      3 M    HOH A   1       0       0       0                          EP',
+            'CONECT    0    3'
+        ]
+        forcefield = ForceField('opc.xml')
+        with self.assertRaisesRegex(ValueError, 'No template found for residue.*HOH.*The set of atoms matches HOH, but the residue has 1 extra site-O bond too many'):
+            makeSystem(pdbLines)
+
     def test_Wildcard(self):
         """Test that PeriodicTorsionForces using wildcard ('') for atom types / classes in the ffxml are correctly registered"""