Increase describeNextReport readability and allow requesting more values (openmm/openmm#4671)

Fixes #4663
Also harmonizes _generate_reports with #4437

describeNextReport can now return a dict instead of a tuple with the format
`return {'steps':steps, 'periodic':False, 'include':['positions', 'forces']}`

The old tuple format is still supported.

This makes it mainly possible to also request parameterDerivatives for TI calculations without having to get the state for that reporter separately.
It also makes the output of describeNextReport much more readable than the previous tuple.

---
# Example
Following is an example reporter utilizing the new format

```python
class dUdLReporter(object):
    """
    Reporter for printing out current dUdL values.

    Calculate derivative needs to be set for this to work.

    Based on openMM source code for StateDataReporter, and openMM user guide http://docs.openmm.org/latest/userguide/application/04_advanced_sim_examples.html#extracting-and-reporting-forces-and-other-data

    Args:
        object (_type_): _description_
    """
    def __init__(self, file, reportInterval: int, derivativeNames: list, append: bool =False, timeUnit: unit = picosecond):
        """
        Create reporter for writing out energies of individual force groups

        Args:
            file (str or stdout): where to print the values
            reportInterval (int): report every n steps
            derivativeNames (list, optional): List of str defining the names of derivatives to report.
            append (bool, optional): append to file. Defaults to False.
            timeUnit (mm.unit, optional): time unit to use. Defaults to unit.picosecond.
        """

        self._append = append
        self._derivativeNames = derivativeNames
        self._reportInterval = reportInterval
        self._timeUnit = timeUnit

        # Open the file if it is a file, otherwise write directly to it (probably stdout)
        self._openedFile = isinstance(file, str)
        if self._openedFile:
            self._out = open(file, 'a' if append else 'w')
        else:
            self._out = file

        self._hasInitialized = False

    def __del__(self):
        # Close the file upon deletion
        if self._openedFile:
            self._out.close()

    def describeNextReport(self, simulation):
        # returns in how many steps the next report is due, and which kind of data it needs
        steps = self._reportInterval - simulation.currentStep%self._reportInterval
        return {'steps': steps, 'periodic': None, 'include':['parameterDerivatives']}

    def report(self, simulation, state=None):
        # Generates the report. Simulation and state is passed through by the openMM engine.

        # print header if it is the first time this object has been called and is not set to append.
        if not self._hasInitialized:
            self._hasInitialized = True
            if not self._append:
                header = ','.join(self._derivativeNames)
                header = "time," + header

                print(header, file=self._out)
                try:
                    self._out.flush()
                except AttributeError:
                    pass

        derivatives = []
        if state is None:
            state = simulation.context.getState(getParameterDerivatives=True)

        for derivativeName in self._derivativeNames:
            derivatives.append(f"{state.getEnergyParameterDerivatives()[derivativeName]: .8f}")

        values = ','.join(derivatives)
        values = f"{state.getTime().value_in_unit(self._timeUnit): .2f},{values}"

        print(values, file=self._out)
        try:
            self._out.flush()
        except AttributeError:
            pass
```

---
# Todo

- [x] write documentation

---
# Additional thoughts

It might also make sense to include a reporter similar as the one above.

Should existing reporters be adjusted to match the proposed format?

Allow to request all parameters context.getState hast to offer from a reporters describeNextReport() function

At the moment, custom reporters are not able to request parameter derivatives or integrator parameters for their next report.

https://github.com/openmm/openmm/blob/fed44c579e9ac3136fe38056968a9c6dc1b6931e/wrappers/python/openmm/app/simulation.py#L254-L272

Currently, to get parameter derivatives using a custom reporter, the state needs to be fetched separately.

I can make the changes and create a pull request if desired.

## IMPORTANT INSTRUCTIONS

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

diff --git a/docs-source/usersguide/application/04_advanced_sim_examples.rst b/docs-source/usersguide/application/04_advanced_sim_examples.rst
index 88e550caf3..88f49959e3 100644
--- a/docs-source/usersguide/application/04_advanced_sim_examples.rst
+++ b/docs-source/usersguide/application/04_advanced_sim_examples.rst
@@ -137,7 +137,7 @@ Here is the definition of the :class:`ForceReporter` class:

             def describeNextReport(self, simulation):
                 steps = self._reportInterval - simulation.currentStep%self._reportInterval
-                return (steps, False, False, True, False, None)
+                return = {'steps': steps, 'periodic': None, 'include':['forces']}

             def report(self, simulation, state):
                 forces = state.getForces().value_in_unit(kilojoules/mole/nanometer)
@@ -157,18 +157,14 @@ We then have two methods that every reporter must implement:
 :meth:`describeNextReport()` and :meth:`report()`.  A Simulation object
 periodically calls :meth:`describeNextReport()` on each of its reporters to
 find out when that reporter will next generate a report, and what information
-will be needed to generate it.  The return value should be a six element tuple,
-whose elements are as follows:
+will be needed to generate it.  The return value should be a dictionary with the following content:

-* The number of time steps until the next report.  We calculate this as
+* :code:`steps` (int): The number of time steps until the next report.  We calculate this as
   *(report interval)*\ -\ *(current step)*\ %\ *(report interval)*\ .  For
   example, if we want a report every 100 steps and the simulation is currently on
   step 530, we will return 100-(530%100) = 70.
-* Whether the next report will need particle positions.
-* Whether the next report will need particle velocities.
-* Whether the next report will need forces.
-* Whether the next report will need energies.
-* Whether the positions should be wrapped to the periodic box.  If None, it will
+* :code:`include` (list of strings): The types of information that need to be included in the next report. The values in the list correspond to arguments to :meth:`Context.getState()`. Allowed values include :code:`'positions'`, :code:`'velocities'`, :code:`'forces'`, :code:`'energy'`, :code:`'parameters'`, :code:`'parameterDerivatives'`, and :code:`'integratorParameters'`.
+* :code:`periodic` (bool, optional): Whether the positions should be wrapped to the periodic box.  If None or not set, it will
   automatically decide whether to wrap positions based on whether the System uses
   periodic boundary conditions.

diff --git a/wrappers/python/openmm/app/checkpointreporter.py b/wrappers/python/openmm/app/checkpointreporter.py
index 9177d2c17e..ebff7e8645 100644
--- a/wrappers/python/openmm/app/checkpointreporter.py
+++ b/wrappers/python/openmm/app/checkpointreporter.py
@@ -110,14 +110,11 @@ def describeNextReport(self, simulation):

         Returns
         -------
-        tuple
-            A five element tuple. The first element is the number of steps
-            until the next report. The remaining elements specify whether
-            that report will require positions, velocities, forces, and
-            energies respectively.
+        dict
+            A dictionary describing the required information for the next report
         """
         steps = self._reportInterval - simulation.currentStep%self._reportInterval
-        return (steps, False, False, False, False)
+        return {'steps':steps, 'periodic':None, 'include':[]}

     def report(self, simulation, state):
         """Generate a report.
diff --git a/wrappers/python/openmm/app/dcdreporter.py b/wrappers/python/openmm/app/dcdreporter.py
index f9020a6a82..09b1d9439a 100644
--- a/wrappers/python/openmm/app/dcdreporter.py
+++ b/wrappers/python/openmm/app/dcdreporter.py
@@ -78,15 +78,11 @@ def describeNextReport(self, simulation):

         Returns
         -------
-        tuple
-            A six element tuple. The first element is the number of steps
-            until the next report. The next four elements specify whether
-            that report will require positions, velocities, forces, and
-            energies respectively.  The final element specifies whether
-            positions should be wrapped to lie in a single periodic box.
+        dict
+            A dictionary describing the required information for the next report
         """
         steps = self._reportInterval - simulation.currentStep%self._reportInterval
-        return (steps, True, False, False, False, self._enforcePeriodicBox)
+        return {'steps':steps, 'periodic':self._enforcePeriodicBox, 'include':['positions']}

     def report(self, simulation, state):
         """Generate a report.
diff --git a/wrappers/python/openmm/app/internal/debuggingreporter.py b/wrappers/python/openmm/app/internal/debuggingreporter.py
index 2889b4dfd8..05eebf3a4c 100644
--- a/wrappers/python/openmm/app/internal/debuggingreporter.py
+++ b/wrappers/python/openmm/app/internal/debuggingreporter.py
@@ -67,14 +67,10 @@ def describeNextReport(self, simulation):

         Returns
         -------
-        tuple
-            A six element tuple. The first element is the number of steps
-            until the next report. The next four elements specify whether
-            that report will require positions, velocities, forces, and
-            energies respectively.  The final element specifies whether
-            positions should be wrapped to lie in a single periodic box.
+        dict
+            A dictionary describing the required information for the next report
         """
-        return (1, True, True, True, False, False)
+        return {'steps':1, 'periodic':False, 'include':['positions', 'velocities', 'forces']}

     def report(self, simulation, state):
         """Generate a report.
diff --git a/wrappers/python/openmm/app/pdbreporter.py b/wrappers/python/openmm/app/pdbreporter.py
index d9b6c7593c..256ed36f17 100644
--- a/wrappers/python/openmm/app/pdbreporter.py
+++ b/wrappers/python/openmm/app/pdbreporter.py
@@ -77,15 +77,11 @@ def describeNextReport(self, simulation):

         Returns
         -------
-        tuple
-            A six element tuple. The first element is the number of steps
-            until the next report. The next four elements specify whether
-            that report will require positions, velocities, forces, and
-            energies respectively.  The final element specifies whether
-            positions should be wrapped to lie in a single periodic box.
+        dict
+            A dictionary describing the required information for the next report
         """
         steps = self._reportInterval - simulation.currentStep%self._reportInterval
-        return (steps, True, False, False, False, self._enforcePeriodicBox)
+        return {'steps':steps, 'periodic':self._enforcePeriodicBox, 'include':['positions']}

     def report(self, simulation, state):
         """Generate a report.
diff --git a/wrappers/python/openmm/app/simulatedtempering.py b/wrappers/python/openmm/app/simulatedtempering.py
index 1451b69b91..b4a23fafa4 100644
--- a/wrappers/python/openmm/app/simulatedtempering.py
+++ b/wrappers/python/openmm/app/simulatedtempering.py
@@ -176,7 +176,10 @@ def describeNextReport(self, simulation):
                 steps2 = st.reportInterval - simulation.currentStep%st.reportInterval
                 steps = min(steps1, steps2)
                 isUpdateAttempt = (steps1 == steps)
-                return (steps, False, isUpdateAttempt, False, isUpdateAttempt)
+                if isUpdateAttempt:
+                    return {'steps': steps, 'periodic':None, 'include':['velocities', 'energy']}
+                else:
+                    return {'steps': steps, 'periodic':None, 'include':[]}

             def report(self, simulation, state):
                 st = self.st
diff --git a/wrappers/python/openmm/app/simulation.py b/wrappers/python/openmm/app/simulation.py
index 3270f6f785..4e7b78f628 100644
--- a/wrappers/python/openmm/app/simulation.py
+++ b/wrappers/python/openmm/app/simulation.py
@@ -206,9 +206,23 @@ def _simulate(self, endStep=None, endTime=None):

             anyReport = False
             for i, reporter in enumerate(self.reporters):
-                nextReport[i] = reporter.describeNextReport(self)
-                if nextReport[i][0] > 0 and nextReport[i][0] <= nextSteps:
-                    nextSteps = nextReport[i][0]
+                report = reporter.describeNextReport(self)
+                # convert to new dict format if the report is in the old tuple format
+                if isinstance(report, tuple):
+                    report_dict = {'steps': report[0]}
+
+                    if len(report) > 5:
+                        report_dict['periodic'] = report[5]
+
+                    includes = ['positions', 'velocities', 'forces', 'energy']
+                    report_dict['include'] = [includes[i] for i in range(4) if report[i+1]]
+                    report = report_dict
+                nextReport[i] = report
+
+                steps = nextReport[i]['steps']
+
+                if steps > 0 and steps <= nextSteps:
+                    nextSteps = steps
                     anyReport = True
             stepsToGo = nextSteps
             while stepsToGo > 10:
@@ -226,19 +240,16 @@ def _simulate(self, endStep=None, endTime=None):
                 unwrapped = []
                 either = []
                 for reporter, report in zip(self.reporters, nextReport):
-                    if report[0] == nextSteps:
-                        if len(report) > 5:
-                            wantWrap = report[5]
-                            if wantWrap is None:
-                                wantWrap = self._usesPBC
-                        else:
-                            wantWrap = self._usesPBC
-                        if not report[1]:
+                    if report['steps'] == nextSteps:
+                        wantWrap = self._usesPBC if report.get('periodic') is None else report['periodic']
+
+                        if not 'positions' in report['include']: # if no positions are requested, we don't care about pbc
                             either.append((reporter, report))
                         elif wantWrap:
                             wrapped.append((reporter, report))
                         else:
                             unwrapped.append((reporter, report))
+
                 if len(wrapped) > len(unwrapped):
                     wrapped += either
                 else:
@@ -252,23 +263,22 @@ def _simulate(self, endStep=None, endTime=None):
                     self._generate_reports(unwrapped, False)

     def _generate_reports(self, reports, periodic):
-        getPositions = False
-        getVelocities = False
-        getForces = False
-        getEnergy = False
-        for reporter, next in reports:
-            if next[1]:
-                getPositions = True
-            if next[2]:
-                getVelocities = True
-            if next[3]:
-                getForces = True
-            if next[4]:
-                getEnergy = True
-        state = self.context.getState(positions=getPositions, velocities=getVelocities, forces=getForces,
-                                      energy=getEnergy, parameters=True, enforcePeriodicBox=periodic,
-                                      groups=self.context.getIntegrator().getIntegrationForceGroups())
-        for reporter, next in reports:
+        '''Generate reports for all requested reporters
+
+        Parameters
+        ----------
+        reports :  list of tuples
+                each tuple in the list contains a reporter object and a description of the next report produced by reporter.describeNextReport().
+                Note that the old format of returning a tuple from reporter.describeNextReport() is not supported in this function.
+        periodic : bool
+                Specifies whether particle positions should be translated so the center of every molecule lies in the same periodic box.
+        '''
+
+        includes = set.union(*[set(report[1]['include']) for report in reports])
+        includeArgs = {property:True for property in includes}
+
+        state = self.context.getState(groups=self.context.getIntegrator().getIntegrationForceGroups(), enforcePeriodicBox=periodic, parameters=True, **includeArgs)
+        for reporter, nextReport in reports:
             reporter.report(self, state)

     def saveCheckpoint(self, file):
diff --git a/wrappers/python/openmm/app/statedatareporter.py b/wrappers/python/openmm/app/statedatareporter.py
index 64b3b14a79..9ea620bb23 100644
--- a/wrappers/python/openmm/app/statedatareporter.py
+++ b/wrappers/python/openmm/app/statedatareporter.py
@@ -158,6 +158,7 @@ def __init__(self, file, reportInterval, step=False, time=False, potentialEnergy
         self._needsVelocities = False
         self._needsForces = False
         self._needEnergy = potentialEnergy or kineticEnergy or totalEnergy or temperature
+        self._includes = ['energy'] if self._needEnergy else []

     def describeNextReport(self, simulation):
         """Get information about the next report this object will generate.
@@ -169,14 +170,11 @@ def describeNextReport(self, simulation):

         Returns
         -------
-        tuple
-            A five element tuple. The first element is the number of steps
-            until the next report. The remaining elements specify whether
-            that report will require positions, velocities, forces, and
-            energies respectively.
+        dict
+            A dictionary describing the required information for the next report
         """
         steps = self._reportInterval - simulation.currentStep%self._reportInterval
-        return (steps, self._needsPositions, self._needsVelocities, self._needsForces, self._needEnergy)
+        return {'steps':steps, 'periodic':None, 'include':self._includes}

     def report(self, simulation, state):
         """Generate a report.
diff --git a/wrappers/python/openmm/app/xtcreporter.py b/wrappers/python/openmm/app/xtcreporter.py
index c37d15e08a..38ef3b4d2d 100644
--- a/wrappers/python/openmm/app/xtcreporter.py
+++ b/wrappers/python/openmm/app/xtcreporter.py
@@ -42,15 +42,11 @@ def describeNextReport(self, simulation):

         Returns
         -------
-        tuple
-            A six element tuple. The first element is the number of steps
-            until the next report. The next four elements specify whether
-            that report will require positions, velocities, forces, and
-            energies respectively.  The final element specifies whether
-            positions should be wrapped to lie in a single periodic box.
+        dict
+            A dictionary describing the required information for the next report
         """
-        steps = self._reportInterval - simulation.currentStep % self._reportInterval
-        return (steps, True, False, False, False, self._enforcePeriodicBox)
+        steps = self._reportInterval - simulation.currentStep%self._reportInterval
+        return {'steps':steps, 'periodic':self._enforcePeriodicBox, 'include':['positions']}

     def report(self, simulation, state):
         """Generate a report.

diff --git a/wrappers/python/tests/TestSimulation.py b/wrappers/python/tests/TestSimulation.py
index aa26895634..68ba979670 100644
--- a/wrappers/python/tests/TestSimulation.py
+++ b/wrappers/python/tests/TestSimulation.py
@@ -179,7 +179,7 @@ def __init__(self, periodic):

             def describeNextReport(self, simulation):
                 steps = self.interval - simulation.currentStep%self.interval
-                return (steps, True, False, False, False, self.periodic)
+                return {'steps':steps, 'periodic':self.periodic, 'include':['positions']}

             def report(self, simulation, state):
                 state2 = simulation.context.getState(getPositions=True, enforcePeriodicBox=self.periodic)


updateParametersInContext() can modify parameter offsets (openmm/openmm#4732)

Update offset parameters in context?

Dear Devs,

Was playing around with particle offsets and realised that you cannot update them in context without reinitializing.

Does it make sense that `updateParametersInContext` should also be suitable for updating offset parameters such as `chargeScale`?

Small example extended from #3054
```
import openmm
from openmm import unit

def generateSystem():
    system = openmm.System()
    system.addParticle(1)
    system.addParticle(1)
    return system

def generateContext(system):
    context = openmm.Context(system, openmm.LangevinIntegrator(298 * unit.kelvin, 1 / unit.picosecond, 2 * unit.femtosecond))
    context.setPeriodicBoxVectors(openmm.Vec3(1, 0, 0), openmm.Vec3(0, 1, 0), openmm.Vec3(0, 0, 1))
    context.setPositions([openmm.Vec3(0, 0, 0), openmm.Vec3(0.4, 0, 0)])
    return context

# generate a simple system of two Lennard-Jones particles
system = generateSystem()
force = openmm.NonbondedForce()
system.addForce(force)
force.addParticle(5 * unit.elementary_charge, 1 * unit.nanometer, 0 * unit.kilocalories_per_mole)
force.addParticle(-5 * unit.elementary_charge, 1 * unit.nanometer, 0 * unit.kilocalories_per_mole)
force.addGlobalParameter("lambda", 0)
force.setNonbondedMethod(openmm.NonbondedForce.CutoffPeriodic)
force.setCutoffDistance(0.5 * unit.nanometer)
context = generateContext(system)

print("Unperturbed:", context.getState(getEnergy=True).getPotentialEnergy())

offset_id = force.addParticleParameterOffset("lambda", 1, 0, 0, 0)  # Charge scale is 0 so will not impact the energy regardless lamba
context = generateContext(system)
print("Should be the same:", context.getState(getEnergy=True).getPotentialEnergy())

context.setParameter('lambda', 1)
print("Should still be the same:", context.getState(getEnergy=True).getPotentialEnergy())

force.setParticleParameterOffset(offset_id, "lambda", 1, 100, 0, 0)  # Change charge scale to something a bit crazy
force.updateParametersInContext(context)
print("Should be different:", context.getState(getEnergy=True).getPotentialEnergy())

context.setParameter('lambda', 0.5)
print("Should be different again:", context.getState(getEnergy=True).getPotentialEnergy())

context.reinitialize(preserveState=True)
print("Does this work?:", context.getState(getEnergy=True).getPotentialEnergy())
```

Many thanks as always!

## 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/openmmapi/include/openmm/NonbondedForce.h b/openmmapi/include/openmm/NonbondedForce.h
index f320b611ce..cec596e28d 100644
--- a/openmmapi/include/openmm/NonbondedForce.h
+++ b/openmmapi/include/openmm/NonbondedForce.h
@@ -572,11 +572,13 @@ class OPENMM_EXPORT NonbondedForce : public Force {
      * Simply call setParticleParameters() and setExceptionParameters() to modify this object's parameters, then call
      * updateParametersInContext() to copy them over to the Context.
      *
-     * This method has several limitations.  The only information it updates is the parameters of particles and exceptions.
-     * All other aspects of the Force (the nonbonded method, the cutoff distance, etc.) are unaffected and can only be
-     * changed by reinitializing the Context.  Furthermore, only the chargeProd, sigma, and epsilon values of an exception
-     * can be changed; the pair of particles involved in the exception cannot change.  Finally, this method cannot be used
-     * to add new particles or exceptions, only to change the parameters of existing ones.
+     * This method has several limitations.  The only information it updates is the parameters of particles and exceptions,
+     * as well as parameter offsets for particles and exceptions.  All other aspects of the Force (the nonbonded method,
+     * the cutoff distance, etc.) are unaffected and can only be changed by reinitializing the Context.  Furthermore,
+     * only the chargeProd, sigma, and epsilon values of an exception can be changed; the pair of particles involved in the
+     * exception cannot change.  Likewise, it can update charge, sigma and epsilon for a parameter offset, but not the
+     * identify of the particle or exception the offset is applied to, or which global parameter it is based on.  Finally,
+     * this method cannot be used to add new particles or exceptions, only to change the parameters of existing ones.
      */
     void updateParametersInContext(Context& context);
     /**
diff --git a/platforms/cpu/include/CpuKernels.h b/platforms/cpu/include/CpuKernels.h
index 80ed68cfdb..c51e7594d1 100644
--- a/platforms/cpu/include/CpuKernels.h
+++ b/platforms/cpu/include/CpuKernels.h
@@ -278,6 +278,7 @@ class CpuCalcNonbondedForceKernel : public CalcNonbondedForceKernel {
     int numParticles, num14, chargePosqIndex, ljPosqIndex;
     std::vector<std::vector<int> > bonded14IndexArray;
     std::vector<std::vector<double> > bonded14ParamArray;
+    std::map<int, int> nb14Index;
     double nonbondedCutoff, switchingDistance, rfDielectric, ewaldAlpha, ewaldDispersionAlpha, ewaldSelfEnergy, dispersionCoefficient;
     int kmax[3], gridSize[3], dispersionGridSize[3];
     bool useSwitchingFunction, exceptionsArePeriodic, useOptimizedPme, hasInitializedPme, hasInitializedDispersionPme, hasParticleOffsets, hasExceptionOffsets;
diff --git a/platforms/cpu/src/CpuKernels.cpp b/platforms/cpu/src/CpuKernels.cpp
index 0d642adba4..d4d2a78096 100644
--- a/platforms/cpu/src/CpuKernels.cpp
+++ b/platforms/cpu/src/CpuKernels.cpp
@@ -506,7 +506,6 @@ void CpuCalcNonbondedForceKernel::initialize(const System& system, const Nonbond
     numParticles = force.getNumParticles();
     exclusions.resize(numParticles);
     vector<int> nb14s;
-    map<int, int> nb14Index;
     for (int i = 0; i < force.getNumExceptions(); i++) {
         int particle1, particle2;
         double chargeProd, sigma, epsilon;
@@ -753,6 +752,32 @@ void CpuCalcNonbondedForceKernel::copyParametersToContext(ContextImpl& context,
         bonded14IndexArray[i][0] = particle1;
         bonded14IndexArray[i][1] = particle2;
     }
+    particleParamOffsets.clear();
+    exceptionParamOffsets.clear();
+    particleParamOffsets.resize(force.getNumParticles());
+    exceptionParamOffsets.resize(force.getNumExceptions());
+    for (int i = 0; i < force.getNumParticleParameterOffsets(); i++) {
+        string param;
+        int particle;
+        double charge, sigma, epsilon;
+        force.getParticleParameterOffset(i, param, particle, charge, sigma, epsilon);
+        auto paramPos = find(paramNames.begin(), paramNames.end(), param);
+        if (paramPos == paramNames.end())
+            throw OpenMMException("updateParametersInContext: The parameter of a particle parameter offset has changed");
+        int paramIndex = paramPos-paramNames.begin();
+        particleParamOffsets[particle].push_back(make_tuple(charge, sigma, epsilon, paramIndex));
+    }
+    for (int i = 0; i < force.getNumExceptionParameterOffsets(); i++) {
+        string param;
+        int exception;
+        double charge, sigma, epsilon;
+        force.getExceptionParameterOffset(i, param, exception, charge, sigma, epsilon);
+        auto paramPos = find(paramNames.begin(), paramNames.end(), param);
+        if (paramPos == paramNames.end())
+            throw OpenMMException("updateParametersInContext: The parameter of an exception parameter offset has changed");
+        int paramIndex = paramPos-paramNames.begin();
+        exceptionParamOffsets[nb14Index[exception]].push_back(make_tuple(charge, sigma, epsilon, paramIndex));
+    }
     computeParameters(context, false);

     // Recompute the coefficient for the dispersion correction.
diff --git a/platforms/cuda/include/CudaKernels.h b/platforms/cuda/include/CudaKernels.h
index 4f4c8587ff..968bb38192 100644
--- a/platforms/cuda/include/CudaKernels.h
+++ b/platforms/cuda/include/CudaKernels.h
@@ -211,6 +211,7 @@ class CudaCalcNonbondedForceKernel : public CalcNonbondedForceKernel {
     std::vector<std::pair<int, int> > exceptionAtoms;
     std::vector<std::string> paramNames;
     std::vector<double> paramValues;
+    std::map<int, int> exceptionIndex;
     double ewaldSelfEnergy, dispersionCoefficient, alpha, dispersionAlpha;
     int interpolateForceThreads;
     int gridSizeX, gridSizeY, gridSizeZ;
diff --git a/platforms/cuda/src/CudaKernels.cpp b/platforms/cuda/src/CudaKernels.cpp
index 224e18c68a..a807716c29 100644
--- a/platforms/cuda/src/CudaKernels.cpp
+++ b/platforms/cuda/src/CudaKernels.cpp
@@ -89,8 +89,35 @@ double CudaCalcForcesAndEnergyKernel::finishComputation(ContextImpl& context, bo
 class CudaCalcNonbondedForceKernel::ForceInfo : public CudaForceInfo {
 public:
     ForceInfo(const NonbondedForce& force) : force(force) {
+        particleOffset.resize(force.getNumParticles(), -1);
+        exceptionOffset.resize(force.getNumExceptions(), -1);
+        for (int i = 0; i < force.getNumParticleParameterOffsets(); i++) {
+            string parameter;
+            int particleIndex;
+            double chargeScale, sigmaScale, epsilonScale;
+            force.getParticleParameterOffset(i, parameter, particleIndex, chargeScale, sigmaScale, epsilonScale);
+            particleOffset[particleIndex] = i;
+        }
+        for (int i = 0; i < force.getNumExceptionParameterOffsets(); i++) {
+            string parameter;
+            int exceptionIndex;
+            double chargeProdScale, sigmaScale, epsilonScale;
+            force.getExceptionParameterOffset(i, parameter, exceptionIndex, chargeProdScale, sigmaScale, epsilonScale);
+            exceptionOffset[exceptionIndex] = i;
+        }
     }
     bool areParticlesIdentical(int particle1, int particle2) {
+        if (particleOffset[particle1] != -1 || particleOffset[particle2] != -1) {
+            if (particleOffset[particle1] == -1 || particleOffset[particle2] == -1)
+                return false;
+            string parameter1, parameter2;
+            int particleIndex1, particleIndex2;
+            double chargeScale1, sigmaScale1, epsilonScale1, chargeScale2, sigmaScale2, epsilonScale2;
+            force.getParticleParameterOffset(particleOffset[particle1], parameter1, particleIndex1, chargeScale1, sigmaScale1, epsilonScale1);
+            force.getParticleParameterOffset(particleOffset[particle2], parameter2, particleIndex2, chargeScale2, sigmaScale2, epsilonScale2);
+            if (parameter1 != parameter2 || chargeScale1 != chargeScale2 || sigmaScale1 != sigmaScale2 || epsilonScale1 != epsilonScale2)
+                return false;
+        }
         double charge1, charge2, sigma1, sigma2, epsilon1, epsilon2;
         force.getParticleParameters(particle1, charge1, sigma1, epsilon1);
         force.getParticleParameters(particle2, charge2, sigma2, epsilon2);
@@ -108,6 +135,17 @@ class CudaCalcNonbondedForceKernel::ForceInfo : public CudaForceInfo {
         particles[1] = particle2;
     }
     bool areGroupsIdentical(int group1, int group2) {
+        if (exceptionOffset[group1] != -1 || exceptionOffset[group2] != -1) {
+            if (exceptionOffset[group1] == -1 || exceptionOffset[group2] == -1)
+                return false;
+            string parameter1, parameter2;
+            int exceptionIndex1, exceptionIndex2;
+            double chargeProdScale1, sigmaScale1, epsilonScale1, chargeProdScale2, sigmaScale2, epsilonScale2;
+            force.getExceptionParameterOffset(exceptionOffset[group1], parameter1, exceptionIndex1, chargeProdScale1, sigmaScale1, epsilonScale1);
+            force.getExceptionParameterOffset(exceptionOffset[group2], parameter2, exceptionIndex2, chargeProdScale2, sigmaScale2, epsilonScale2);
+            if (parameter1 != parameter2 || chargeProdScale1 != chargeProdScale2 || sigmaScale1 != sigmaScale2 || epsilonScale1 != epsilonScale2)
+                return false;
+        }
         int particle1, particle2;
         double chargeProd1, chargeProd2, sigma1, sigma2, epsilon1, epsilon2;
         force.getExceptionParameters(group1, particle1, particle2, chargeProd1, sigma1, epsilon1);
@@ -116,6 +154,7 @@ class CudaCalcNonbondedForceKernel::ForceInfo : public CudaForceInfo {
     }
 private:
     const NonbondedForce& force;
+    vector<int> particleOffset, exceptionOffset;
 };

 class CudaCalcNonbondedForceKernel::PmeIO : public CalcPmeReciprocalForceKernel::IO {
@@ -253,7 +292,6 @@ void CudaCalcNonbondedForceKernel::initialize(const System& system, const Nonbon
     }
     vector<pair<int, int> > exclusions;
     vector<int> exceptions;
-    map<int, int> exceptionIndex;
     for (int i = 0; i < force.getNumExceptions(); i++) {
         int particle1, particle2;
         double chargeProd, sigma, epsilon;
@@ -1126,12 +1164,58 @@ void CudaCalcNonbondedForceKernel::copyParametersToContext(ContextImpl& context,
         }
         baseExceptionParams.upload(baseExceptionParamsVec);
     }
-
+
+    // Record parameter offsets.
+
+    vector<vector<mm_float4> > particleOffsetVec(force.getNumParticles());
+    vector<vector<mm_float4> > exceptionOffsetVec(numExceptions);
+    for (int i = 0; i < force.getNumParticleParameterOffsets(); i++) {
+        string param;
+        int particle;
+        double charge, sigma, epsilon;
+        force.getParticleParameterOffset(i, param, particle, charge, sigma, epsilon);
+        auto paramPos = find(paramNames.begin(), paramNames.end(), param);
+        if (paramPos == paramNames.end())
+            throw OpenMMException("updateParametersInContext: The parameter of a particle parameter offset has changed");
+        int paramIndex = paramPos-paramNames.begin();
+        particleOffsetVec[particle].push_back(mm_float4(charge, sigma, epsilon, paramIndex));
+    }
+    for (int i = 0; i < force.getNumExceptionParameterOffsets(); i++) {
+        string param;
+        int exception;
+        double charge, sigma, epsilon;
+        force.getExceptionParameterOffset(i, param, exception, charge, sigma, epsilon);
+        int index = exceptionIndex[exception];
+        if (index < startIndex || index >= endIndex)
+            continue;
+        auto paramPos = find(paramNames.begin(), paramNames.end(), param);
+        if (paramPos == paramNames.end())
+            throw OpenMMException("updateParametersInContext: The parameter of an exception parameter offset has changed");
+        int paramIndex = paramPos-paramNames.begin();
+        exceptionOffsetVec[index-startIndex].push_back(mm_float4(charge, sigma, epsilon, paramIndex));
+    }
+    if (max(force.getNumParticleParameterOffsets(), 1) != particleParamOffsets.getSize())
+        throw OpenMMException("updateParametersInContext: The number of particle parameter offsets has changed");
+    vector<mm_float4> p, e;
+    for (int i = 0; i < particleOffsetVec.size(); i++)
+        for (int j = 0; j < particleOffsetVec[i].size(); j++)
+            p.push_back(particleOffsetVec[i][j]);
+    for (int i = 0; i < exceptionOffsetVec.size(); i++)
+        for (int j = 0; j < exceptionOffsetVec[i].size(); j++)
+            e.push_back(exceptionOffsetVec[i][j]);
+    if (force.getNumParticleParameterOffsets() > 0)
+        particleParamOffsets.upload(p);
+    if (max((int) e.size(), 1) != exceptionParamOffsets.getSize())
+        throw OpenMMException("updateParametersInContext: The number of exception parameter offsets has changed");
+    if (e.size() > 0)
+        exceptionParamOffsets.upload(e);
+
     // Compute other values.
-
+
     if (force.getUseDispersionCorrection() && cu.getContextIndex() == 0 && (nonbondedMethod == CutoffPeriodic || nonbondedMethod == Ewald || nonbondedMethod == PME))
         dispersionCoefficient = NonbondedForceImpl::calcDispersionCorrection(context.getSystem(), force);
-    cu.invalidateMolecules(info, firstParticle <= lastParticle, firstException <= lastException);
+    cu.invalidateMolecules(info, firstParticle <= lastParticle || force.getNumParticleParameterOffsets() > 0,
+                           firstException <= lastException || force.getNumExceptionParameterOffsets() > 0);
     recomputeParams = true;
 }

diff --git a/platforms/hip/include/HipKernels.h b/platforms/hip/include/HipKernels.h
index 3931fad4f1..37b0ea040f 100644
--- a/platforms/hip/include/HipKernels.h
+++ b/platforms/hip/include/HipKernels.h
@@ -207,6 +207,7 @@ class HipCalcNonbondedForceKernel : public CalcNonbondedForceKernel {
     std::vector<std::pair<int, int> > exceptionAtoms;
     std::vector<std::string> paramNames;
     std::vector<double> paramValues;
+    std::map<int, int> exceptionIndex;
     double ewaldSelfEnergy, dispersionCoefficient, alpha, dispersionAlpha;
     int interpolateForceThreads;
     int gridSizeX, gridSizeY, gridSizeZ;
diff --git a/platforms/hip/src/HipKernels.cpp b/platforms/hip/src/HipKernels.cpp
index edb46c0ac8..ebaa672d62 100644
--- a/platforms/hip/src/HipKernels.cpp
+++ b/platforms/hip/src/HipKernels.cpp
@@ -91,8 +91,35 @@ double HipCalcForcesAndEnergyKernel::finishComputation(ContextImpl& context, boo
 class HipCalcNonbondedForceKernel::ForceInfo : public HipForceInfo {
 public:
     ForceInfo(const NonbondedForce& force) : force(force) {
+        particleOffset.resize(force.getNumParticles(), -1);
+        exceptionOffset.resize(force.getNumExceptions(), -1);
+        for (int i = 0; i < force.getNumParticleParameterOffsets(); i++) {
+            string parameter;
+            int particleIndex;
+            double chargeScale, sigmaScale, epsilonScale;
+            force.getParticleParameterOffset(i, parameter, particleIndex, chargeScale, sigmaScale, epsilonScale);
+            particleOffset[particleIndex] = i;
+        }
+        for (int i = 0; i < force.getNumExceptionParameterOffsets(); i++) {
+            string parameter;
+            int exceptionIndex;
+            double chargeProdScale, sigmaScale, epsilonScale;
+            force.getExceptionParameterOffset(i, parameter, exceptionIndex, chargeProdScale, sigmaScale, epsilonScale);
+            exceptionOffset[exceptionIndex] = i;
+        }
     }
     bool areParticlesIdentical(int particle1, int particle2) {
+        if (particleOffset[particle1] != -1 || particleOffset[particle2] != -1) {
+            if (particleOffset[particle1] == -1 || particleOffset[particle2] == -1)
+                return false;
+            string parameter1, parameter2;
+            int particleIndex1, particleIndex2;
+            double chargeScale1, sigmaScale1, epsilonScale1, chargeScale2, sigmaScale2, epsilonScale2;
+            force.getParticleParameterOffset(particleOffset[particle1], parameter1, particleIndex1, chargeScale1, sigmaScale1, epsilonScale1);
+            force.getParticleParameterOffset(particleOffset[particle2], parameter2, particleIndex2, chargeScale2, sigmaScale2, epsilonScale2);
+            if (parameter1 != parameter2 || chargeScale1 != chargeScale2 || sigmaScale1 != sigmaScale2 || epsilonScale1 != epsilonScale2)
+                return false;
+        }
         double charge1, charge2, sigma1, sigma2, epsilon1, epsilon2;
         force.getParticleParameters(particle1, charge1, sigma1, epsilon1);
         force.getParticleParameters(particle2, charge2, sigma2, epsilon2);
@@ -110,6 +137,17 @@ class HipCalcNonbondedForceKernel::ForceInfo : public HipForceInfo {
         particles[1] = particle2;
     }
     bool areGroupsIdentical(int group1, int group2) {
+        if (exceptionOffset[group1] != -1 || exceptionOffset[group2] != -1) {
+            if (exceptionOffset[group1] == -1 || exceptionOffset[group2] == -1)
+                return false;
+            string parameter1, parameter2;
+            int exceptionIndex1, exceptionIndex2;
+            double chargeProdScale1, sigmaScale1, epsilonScale1, chargeProdScale2, sigmaScale2, epsilonScale2;
+            force.getExceptionParameterOffset(exceptionOffset[group1], parameter1, exceptionIndex1, chargeProdScale1, sigmaScale1, epsilonScale1);
+            force.getExceptionParameterOffset(exceptionOffset[group2], parameter2, exceptionIndex2, chargeProdScale2, sigmaScale2, epsilonScale2);
+            if (parameter1 != parameter2 || chargeProdScale1 != chargeProdScale2 || sigmaScale1 != sigmaScale2 || epsilonScale1 != epsilonScale2)
+                return false;
+        }
         int particle1, particle2;
         double chargeProd1, chargeProd2, sigma1, sigma2, epsilon1, epsilon2;
         force.getExceptionParameters(group1, particle1, particle2, chargeProd1, sigma1, epsilon1);
@@ -118,6 +156,7 @@ class HipCalcNonbondedForceKernel::ForceInfo : public HipForceInfo {
     }
 private:
     const NonbondedForce& force;
+    vector<int> particleOffset, exceptionOffset;
 };

 class HipCalcNonbondedForceKernel::PmeIO : public CalcPmeReciprocalForceKernel::IO {
@@ -247,7 +286,6 @@ void HipCalcNonbondedForceKernel::initialize(const System& system, const Nonbond
     }
     vector<pair<int, int> > exclusions;
     vector<int> exceptions;
-    map<int, int> exceptionIndex;
     for (int i = 0; i < force.getNumExceptions(); i++) {
         int particle1, particle2;
         double chargeProd, sigma, epsilon;
@@ -1035,11 +1073,57 @@ void HipCalcNonbondedForceKernel::copyParametersToContext(ContextImpl& context,
         baseExceptionParams.upload(baseExceptionParamsVec);
     }

+    // Record parameter offsets.
+
+    vector<vector<mm_float4> > particleOffsetVec(force.getNumParticles());
+    vector<vector<mm_float4> > exceptionOffsetVec(numExceptions);
+    for (int i = 0; i < force.getNumParticleParameterOffsets(); i++) {
+        string param;
+        int particle;
+        double charge, sigma, epsilon;
+        force.getParticleParameterOffset(i, param, particle, charge, sigma, epsilon);
+        auto paramPos = find(paramNames.begin(), paramNames.end(), param);
+        if (paramPos == paramNames.end())
+            throw OpenMMException("updateParametersInContext: The parameter of a particle parameter offset has changed");
+        int paramIndex = paramPos-paramNames.begin();
+        particleOffsetVec[particle].push_back(mm_float4(charge, sigma, epsilon, paramIndex));
+    }
+    for (int i = 0; i < force.getNumExceptionParameterOffsets(); i++) {
+        string param;
+        int exception;
+        double charge, sigma, epsilon;
+        force.getExceptionParameterOffset(i, param, exception, charge, sigma, epsilon);
+        int index = exceptionIndex[exception];
+        if (index < startIndex || index >= endIndex)
+            continue;
+        auto paramPos = find(paramNames.begin(), paramNames.end(), param);
+        if (paramPos == paramNames.end())
+            throw OpenMMException("updateParametersInContext: The parameter of an exception parameter offset has changed");
+        int paramIndex = paramPos-paramNames.begin();
+        exceptionOffsetVec[index-startIndex].push_back(mm_float4(charge, sigma, epsilon, paramIndex));
+    }
+    if (max(force.getNumParticleParameterOffsets(), 1) != particleParamOffsets.getSize())
+        throw OpenMMException("updateParametersInContext: The number of particle parameter offsets has changed");
+    vector<mm_float4> p, e;
+    for (int i = 0; i < particleOffsetVec.size(); i++)
+        for (int j = 0; j < particleOffsetVec[i].size(); j++)
+            p.push_back(particleOffsetVec[i][j]);
+    for (int i = 0; i < exceptionOffsetVec.size(); i++)
+        for (int j = 0; j < exceptionOffsetVec[i].size(); j++)
+            e.push_back(exceptionOffsetVec[i][j]);
+    if (force.getNumParticleParameterOffsets() > 0)
+        particleParamOffsets.upload(p);
+    if (max((int) e.size(), 1) != exceptionParamOffsets.getSize())
+        throw OpenMMException("updateParametersInContext: The number of exception parameter offsets has changed");
+    if (e.size() > 0)
+        exceptionParamOffsets.upload(e);
+
     // Compute other values.

     if (force.getUseDispersionCorrection() && cu.getContextIndex() == 0 && (nonbondedMethod == CutoffPeriodic || nonbondedMethod == Ewald || nonbondedMethod == PME))
         dispersionCoefficient = NonbondedForceImpl::calcDispersionCorrection(context.getSystem(), force);
-    cu.invalidateMolecules(info, firstParticle <= lastParticle, firstException <= lastException);
+    cu.invalidateMolecules(info, firstParticle <= lastParticle || force.getNumParticleParameterOffsets() > 0,
+                           firstException <= lastException || force.getNumExceptionParameterOffsets() > 0);
     recomputeParams = true;
 }

diff --git a/platforms/opencl/include/OpenCLKernels.h b/platforms/opencl/include/OpenCLKernels.h
index afd17f3a49..286089b9ef 100644
--- a/platforms/opencl/include/OpenCLKernels.h
+++ b/platforms/opencl/include/OpenCLKernels.h
@@ -214,6 +214,7 @@ class OpenCLCalcNonbondedForceKernel : public CalcNonbondedForceKernel {
     std::vector<std::pair<int, int> > exceptionAtoms;
     std::vector<std::string> paramNames;
     std::vector<double> paramValues;
+    std::map<int, int> exceptionIndex;
     double ewaldSelfEnergy, dispersionCoefficient, alpha, dispersionAlpha;
     int gridSizeX, gridSizeY, gridSizeZ;
     int dispersionGridSizeX, dispersionGridSizeY, dispersionGridSizeZ;
diff --git a/platforms/opencl/src/OpenCLKernels.cpp b/platforms/opencl/src/OpenCLKernels.cpp
index 38a9ceff87..00cdbaa570 100644
--- a/platforms/opencl/src/OpenCLKernels.cpp
+++ b/platforms/opencl/src/OpenCLKernels.cpp
@@ -104,8 +104,35 @@ double OpenCLCalcForcesAndEnergyKernel::finishComputation(ContextImpl& context,
 class OpenCLCalcNonbondedForceKernel::ForceInfo : public OpenCLForceInfo {
 public:
     ForceInfo(int requiredBuffers, const NonbondedForce& force) : OpenCLForceInfo(requiredBuffers), force(force) {
+        particleOffset.resize(force.getNumParticles(), -1);
+        exceptionOffset.resize(force.getNumExceptions(), -1);
+        for (int i = 0; i < force.getNumParticleParameterOffsets(); i++) {
+            string parameter;
+            int particleIndex;
+            double chargeScale, sigmaScale, epsilonScale;
+            force.getParticleParameterOffset(i, parameter, particleIndex, chargeScale, sigmaScale, epsilonScale);
+            particleOffset[particleIndex] = i;
+        }
+        for (int i = 0; i < force.getNumExceptionParameterOffsets(); i++) {
+            string parameter;
+            int exceptionIndex;
+            double chargeProdScale, sigmaScale, epsilonScale;
+            force.getExceptionParameterOffset(i, parameter, exceptionIndex, chargeProdScale, sigmaScale, epsilonScale);
+            exceptionOffset[exceptionIndex] = i;
+        }
     }
     bool areParticlesIdentical(int particle1, int particle2) {
+        if (particleOffset[particle1] != -1 || particleOffset[particle2] != -1) {
+            if (particleOffset[particle1] == -1 || particleOffset[particle2] == -1)
+                return false;
+            string parameter1, parameter2;
+            int particleIndex1, particleIndex2;
+            double chargeScale1, sigmaScale1, epsilonScale1, chargeScale2, sigmaScale2, epsilonScale2;
+            force.getParticleParameterOffset(particleOffset[particle1], parameter1, particleIndex1, chargeScale1, sigmaScale1, epsilonScale1);
+            force.getParticleParameterOffset(particleOffset[particle2], parameter2, particleIndex2, chargeScale2, sigmaScale2, epsilonScale2);
+            if (parameter1 != parameter2 || chargeScale1 != chargeScale2 || sigmaScale1 != sigmaScale2 || epsilonScale1 != epsilonScale2)
+                return false;
+        }
         double charge1, charge2, sigma1, sigma2, epsilon1, epsilon2;
         force.getParticleParameters(particle1, charge1, sigma1, epsilon1);
         force.getParticleParameters(particle2, charge2, sigma2, epsilon2);
@@ -123,6 +150,17 @@ class OpenCLCalcNonbondedForceKernel::ForceInfo : public OpenCLForceInfo {
         particles[1] = particle2;
     }
     bool areGroupsIdentical(int group1, int group2) {
+        if (exceptionOffset[group1] != -1 || exceptionOffset[group2] != -1) {
+            if (exceptionOffset[group1] == -1 || exceptionOffset[group2] == -1)
+                return false;
+            string parameter1, parameter2;
+            int exceptionIndex1, exceptionIndex2;
+            double chargeProdScale1, sigmaScale1, epsilonScale1, chargeProdScale2, sigmaScale2, epsilonScale2;
+            force.getExceptionParameterOffset(exceptionOffset[group1], parameter1, exceptionIndex1, chargeProdScale1, sigmaScale1, epsilonScale1);
+            force.getExceptionParameterOffset(exceptionOffset[group2], parameter2, exceptionIndex2, chargeProdScale2, sigmaScale2, epsilonScale2);
+            if (parameter1 != parameter2 || chargeProdScale1 != chargeProdScale2 || sigmaScale1 != sigmaScale2 || epsilonScale1 != epsilonScale2)
+                return false;
+        }
         int particle1, particle2;
         double chargeProd1, chargeProd2, sigma1, sigma2, epsilon1, epsilon2;
         force.getExceptionParameters(group1, particle1, particle2, chargeProd1, sigma1, epsilon1);
@@ -131,6 +169,7 @@ class OpenCLCalcNonbondedForceKernel::ForceInfo : public OpenCLForceInfo {
     }
 private:
     const NonbondedForce& force;
+    vector<int> particleOffset, exceptionOffset;
 };

 class OpenCLCalcNonbondedForceKernel::PmeIO : public CalcPmeReciprocalForceKernel::IO {
@@ -257,7 +296,6 @@ void OpenCLCalcNonbondedForceKernel::initialize(const System& system, const Nonb
     }
     vector<pair<int, int> > exclusions;
     vector<int> exceptions;
-    map<int, int> exceptionIndex;
     for (int i = 0; i < force.getNumExceptions(); i++) {
         int particle1, particle2;
         double chargeProd, sigma, epsilon;
@@ -1146,12 +1184,58 @@ void OpenCLCalcNonbondedForceKernel::copyParametersToContext(ContextImpl& contex
         }
         baseExceptionParams.upload(baseExceptionParamsVec);
     }
-
+
+    // Record parameter offsets.
+
+    vector<vector<mm_float4> > particleOffsetVec(force.getNumParticles());
+    vector<vector<mm_float4> > exceptionOffsetVec(numExceptions);
+    for (int i = 0; i < force.getNumParticleParameterOffsets(); i++) {
+        string param;
+        int particle;
+        double charge, sigma, epsilon;
+        force.getParticleParameterOffset(i, param, particle, charge, sigma, epsilon);
+        auto paramPos = find(paramNames.begin(), paramNames.end(), param);
+        if (paramPos == paramNames.end())
+            throw OpenMMException("updateParametersInContext: The parameter of a particle parameter offset has changed");
+        int paramIndex = paramPos-paramNames.begin();
+        particleOffsetVec[particle].push_back(mm_float4(charge, sigma, epsilon, paramIndex));
+    }
+    for (int i = 0; i < force.getNumExceptionParameterOffsets(); i++) {
+        string param;
+        int exception;
+        double charge, sigma, epsilon;
+        force.getExceptionParameterOffset(i, param, exception, charge, sigma, epsilon);
+        int index = exceptionIndex[exception];
+        if (index < startIndex || index >= endIndex)
+            continue;
+        auto paramPos = find(paramNames.begin(), paramNames.end(), param);
+        if (paramPos == paramNames.end())
+            throw OpenMMException("updateParametersInContext: The parameter of an exception parameter offset has changed");
+        int paramIndex = paramPos-paramNames.begin();
+        exceptionOffsetVec[index-startIndex].push_back(mm_float4(charge, sigma, epsilon, paramIndex));
+    }
+    if (max(force.getNumParticleParameterOffsets(), 1) != particleParamOffsets.getSize())
+        throw OpenMMException("updateParametersInContext: The number of particle parameter offsets has changed");
+    vector<mm_float4> p, e;
+    for (int i = 0; i < particleOffsetVec.size(); i++)
+        for (int j = 0; j < particleOffsetVec[i].size(); j++)
+            p.push_back(particleOffsetVec[i][j]);
+    for (int i = 0; i < exceptionOffsetVec.size(); i++)
+        for (int j = 0; j < exceptionOffsetVec[i].size(); j++)
+            e.push_back(exceptionOffsetVec[i][j]);
+    if (force.getNumParticleParameterOffsets() > 0)
+        particleParamOffsets.upload(p);
+    if (max((int) e.size(), 1) != exceptionParamOffsets.getSize())
+        throw OpenMMException("updateParametersInContext: The number of exception parameter offsets has changed");
+    if (e.size() > 0)
+        exceptionParamOffsets.upload(e);
+
     // Compute other values.
-
+
     if (force.getUseDispersionCorrection() && cl.getContextIndex() == 0 && (nonbondedMethod == CutoffPeriodic || nonbondedMethod == Ewald || nonbondedMethod == PME))
         dispersionCoefficient = NonbondedForceImpl::calcDispersionCorrection(context.getSystem(), force);
-    cl.invalidateMolecules(info, firstParticle <= lastParticle, firstException <= lastException);
+    cl.invalidateMolecules(info, firstParticle <= lastParticle || force.getNumParticleParameterOffsets() > 0,
+                           firstException <= lastException || force.getNumExceptionParameterOffsets() > 0);
     recomputeParams = true;
 }

diff --git a/platforms/reference/include/ReferenceKernels.h b/platforms/reference/include/ReferenceKernels.h
index bb2289e3d0..2c73542c8a 100644
--- a/platforms/reference/include/ReferenceKernels.h
+++ b/platforms/reference/include/ReferenceKernels.h
@@ -662,6 +662,7 @@ class ReferenceCalcNonbondedForceKernel : public CalcNonbondedForceKernel {
     std::vector<std::vector<double> > particleParamArray, bonded14ParamArray;
     std::vector<std::array<double, 3> > baseParticleParams, baseExceptionParams;
     std::map<std::pair<std::string, int>, std::array<double, 3> > particleParamOffsets, exceptionParamOffsets;
+    std::map<int, int> nb14Index;
     double nonbondedCutoff, switchingDistance, rfDielectric, ewaldAlpha, ewaldDispersionAlpha, dispersionCoefficient;
     int kmax[3], gridSize[3], dispersionGridSize[3];
     bool useSwitchingFunction, exceptionsArePeriodic;
diff --git a/platforms/reference/src/ReferenceKernels.cpp b/platforms/reference/src/ReferenceKernels.cpp
index c04ea01910..3b8dc89418 100644
--- a/platforms/reference/src/ReferenceKernels.cpp
+++ b/platforms/reference/src/ReferenceKernels.cpp
@@ -903,7 +903,6 @@ void ReferenceCalcNonbondedForceKernel::initialize(const System& system, const N
     numParticles = force.getNumParticles();
     exclusions.resize(numParticles);
     vector<int> nb14s;
-    map<int, int> nb14Index;
     for (int i = 0; i < force.getNumExceptions(); i++) {
         int particle1, particle2;
         double chargeProd, sigma, epsilon;
@@ -1038,6 +1037,18 @@ double ReferenceCalcNonbondedForceKernel::execute(ContextImpl& context, bool inc
 void ReferenceCalcNonbondedForceKernel::copyParametersToContext(ContextImpl& context, const NonbondedForce& force, int firstParticle, int lastParticle, int firstException, int lastException) {
     if (force.getNumParticles() != numParticles)
         throw OpenMMException("updateParametersInContext: The number of particles has changed");
+    if (force.getNumParticleParameterOffsets() != particleParamOffsets.size())
+        throw OpenMMException("updateParametersInContext: The number of particle parameter offsets has changed");
+    if (force.getNumExceptionParameterOffsets() != exceptionParamOffsets.size())
+        throw OpenMMException("updateParametersInContext: The number of exception parameter offsets has changed");
+    for (int i = 0; i < force.getNumParticleParameterOffsets(); i++) {
+        string param;
+        int particle;
+        double charge, sigma, epsilon;
+        force.getParticleParameterOffset(i, param, particle, charge, sigma, epsilon);
+        if (particleParamOffsets.find(make_pair(param, particle)) == particleParamOffsets.end())
+            throw OpenMMException("updateParametersInContext: The parameter or particle index of a particle parameter offset has changed");
+    }

     // Identify which exceptions are 1-4 interactions.

@@ -1048,6 +1059,8 @@ void ReferenceCalcNonbondedForceKernel::copyParametersToContext(ContextImpl& con
         double charge, sigma, epsilon;
         force.getExceptionParameterOffset(i, param, exception, charge, sigma, epsilon);
         exceptionsWithOffsets.insert(exception);
+        if (exceptionParamOffsets.find(make_pair(param, nb14Index[exception])) == exceptionParamOffsets.end())
+            throw OpenMMException("updateParametersInContext: The parameter or exception index of an exception parameter offset has changed");
     }
     vector<int> nb14s;
     for (int i = 0; i < force.getNumExceptions(); i++) {
@@ -1070,6 +1083,22 @@ void ReferenceCalcNonbondedForceKernel::copyParametersToContext(ContextImpl& con
         bonded14IndexArray[i][0] = particle1;
         bonded14IndexArray[i][1] = particle2;
     }
+    particleParamOffsets.clear();
+    exceptionParamOffsets.clear();
+    for (int i = 0; i < force.getNumParticleParameterOffsets(); i++) {
+        string param;
+        int particle;
+        double charge, sigma, epsilon;
+        force.getParticleParameterOffset(i, param, particle, charge, sigma, epsilon);
+        particleParamOffsets[make_pair(param, particle)] = {charge, sigma, epsilon};
+    }
+    for (int i = 0; i < force.getNumExceptionParameterOffsets(); i++) {
+        string param;
+        int exception;
+        double charge, sigma, epsilon;
+        force.getExceptionParameterOffset(i, param, exception, charge, sigma, epsilon);
+        exceptionParamOffsets[make_pair(param, nb14Index[exception])] = {charge, sigma, epsilon};
+    }

     // Recompute the coefficient for the dispersion correction.

diff --git a/tests/TestNonbondedForce.h b/tests/TestNonbondedForce.h
index 1efaefdfde..e1d5aed87d 100644
--- a/tests/TestNonbondedForce.h
+++ b/tests/TestNonbondedForce.h
@@ -901,6 +901,39 @@ void testParameterOffsets() {
             energy += ONE_4PI_EPS0*pairChargeProd[i][j]/dist + 4.0*pairEpsilon[i][j]*(pow(x, 12.0)-pow(x, 6.0));
         }
     ASSERT_EQUAL_TOL(energy, context.getState(State::Energy).getPotentialEnergy(), 1e-5);
+
+    // Update the offsets and see if the energy is still correct.
+
+    force->setParticleParameterOffset(0, "p1", 0, 2.0, 0.6, 0.6);
+    force->setParticleParameterOffset(1, "p2", 1, 1.1, 1.1, 2.1);
+    force->setExceptionParameterOffset(0, "p1", 1, 0.4, 0.4, 1.4);
+    force->updateParametersInContext(context);
+    particleCharge = {0.0+2.0*0.5, 1.0+1.1*1.5, -1.0, 0.5};
+    particleSigma = {1.0+0.6*0.5, 0.5+1.1*1.5, 2.0, 2.0};
+    particleEpsilon = {0.5+0.6*0.5, 0.6+2.1*1.5, 0.7, 0.8};
+    for (int i = 0; i < 4; i++)
+        for (int j = i+1; j < 4; j++) {
+            pairChargeProd[i][j] = particleCharge[i]*particleCharge[j];
+            pairSigma[i][j] = 0.5*(particleSigma[i]+particleSigma[j]);
+            pairEpsilon[i][j] = sqrt(particleEpsilon[i]*particleEpsilon[j]);
+        }
+    pairChargeProd[0][3] = 0.0;
+    pairSigma[0][3] = 1.0;
+    pairEpsilon[0][3] = 0.0;
+    pairChargeProd[2][3] = 0.5+0.4*0.5;
+    pairSigma[2][3] = 1.0+0.4*0.5;
+    pairEpsilon[2][3] = 1.5+1.4*0.5;
+    pairChargeProd[0][1] = 1.0;
+    pairSigma[0][1] = 1.5;
+    pairEpsilon[0][1] = 1.0;
+    energy = 0.0;
+    for (int i = 0; i < 4; i++)
+        for (int j = i+1; j < 4; j++) {
+            double dist = j-i;
+            double x = pairSigma[i][j]/dist;
+            energy += ONE_4PI_EPS0*pairChargeProd[i][j]/dist + 4.0*pairEpsilon[i][j]*(pow(x, 12.0)-pow(x, 6.0));
+        }
+    ASSERT_EQUAL_TOL(energy, context.getState(State::Energy).getPotentialEnergy(), 1e-5);
 }

 void testEwaldExceptions() {


Serialization records integration force groups (openmm/openmm#4734)

Fixes #4730.

While fixing this, I realized our handling of force groups for CompoundIntegrator was inconsistent.  The CompoundIntegrator stored a list of force groups, and so did each integrator it contained.  They were set independently and used inconsistently.  Performing integration would use the groups for the currently selected integrator.  Energy minimization would use the groups for the CompoundIntegrator itself.

I therefore changed the behavior so the CompoundIntegrator doesn't have its own independent set of force groups.  `getIntegrationForceGroups()` returns the groups for the currently selected integrator.  `setIntegrationForceGroups()` sets the groups for *all* contained integrators.

Deepcopying integrators resets integration force groups

It seems that serializing an integrator doesn't record the integration force group, which means any deepcopy of an integrator will have them reset to the default (all force groups).

```
import openmm as mm
from openmm import unit as u
from copy import deepcopy

integrator = mm.LangevinMiddleIntegrator(300*u.kelvin, 1/u.picosecond, 0.002*u.picosecond)
integrator.setIntegrationForceGroups({0})
integrator_copy = deepcopy(integrator)
print(integrator.getIntegrationForceGroups())
print(integrator_copy.getIntegrationForceGroups())
```
1
-1

Could this be an oversight?

### Minimal API referenced by tests (typed signatures)
_Added only when tests call brand‑new symbols introduced by the fix; types reflect annotations present in the repo or `Unknown` if not annotated._

- Function in `openmmapi/src/CompoundIntegrator.cpp`: `def int CompoundIntegrator::getIntegrationForceGroups() -> int: ...`
  **Output:** int

- Function in `openmmapi/src/CompoundIntegrator.cpp`: `def void CompoundIntegrator::setIntegrationForceGroups(groups: int) -> void: ...`
  **Inputs:** groups: int
  **Output:** void

```python
def int CompoundIntegrator::getIntegrationForceGroups() -> int: ...
def void CompoundIntegrator::setIntegrationForceGroups(groups) -> void: ...
```

## 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/openmmapi/include/openmm/CompoundIntegrator.h b/openmmapi/include/openmm/CompoundIntegrator.h
index 3839648988..dea22f1fbc 100644
--- a/openmmapi/include/openmm/CompoundIntegrator.h
+++ b/openmmapi/include/openmm/CompoundIntegrator.h
@@ -9,7 +9,7 @@
  * Biological Structures at Stanford, funded under the NIH Roadmap for        *
  * Medical Research, grant U54 GM072970. See https://simtk.org.               *
  *                                                                            *
- * Portions copyright (c) 2015-2020 Stanford University and the Authors.      *
+ * Portions copyright (c) 2015-2024 Stanford University and the Authors.      *
  * Authors: Peter Eastman                                                     *
  * Contributors:                                                              *
  *                                                                            *
@@ -158,6 +158,23 @@ class OPENMM_EXPORT CompoundIntegrator : public Integrator {
      * @param steps   the number of time steps to take
      */
     void step(int steps);
+    /**
+     * Get which force groups to use for integration.  By default, all force groups
+     * are included.  This is interpreted as a set of bit flags: the forces from group i
+     * will be included if (groups&(1<<i)) != 0.
+     *
+     * This method returns the integration force groups for the current Integator.
+     */
+    int getIntegrationForceGroups() const;
+    /**
+     * Set which force groups to use for integration.  By default, all force groups
+     * are included.  This is interpreted as a set of bit flags: the forces from group i
+     * will be included if (groups&(1<<i)) != 0.
+     *
+     * Calling this method sets the integration force groups for all Integrators
+     * containined in this CompoundIntegrator.
+     */
+    void setIntegrationForceGroups(int groups);
 protected:
     /**
      * This will be called by the Context when it is created.  It informs the Integrator
diff --git a/openmmapi/src/CompoundIntegrator.cpp b/openmmapi/src/CompoundIntegrator.cpp
index d75760276d..aa22a245ab 100644
--- a/openmmapi/src/CompoundIntegrator.cpp
+++ b/openmmapi/src/CompoundIntegrator.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) 2015-2020 Stanford University and the Authors.      *
+ * Portions copyright (c) 2015-2024 Stanford University and the Authors.      *
  * Authors: Peter Eastman                                                     *
  * Contributors:                                                              *
  *                                                                            *
@@ -99,6 +99,15 @@ void CompoundIntegrator::step(int steps) {
     integrators[currentIntegrator]->step(steps);
 }

+int CompoundIntegrator::getIntegrationForceGroups() const {
+    return integrators[currentIntegrator]->getIntegrationForceGroups();
+}
+
+void CompoundIntegrator::setIntegrationForceGroups(int groups) {
+    for (int i = 0; i < integrators.size(); i++)
+        integrators[i]->setIntegrationForceGroups(groups);
+}
+
 void CompoundIntegrator::initialize(ContextImpl& context) {
     if (integrators.size() == 0)
         throw OpenMMException("CompoundIntegrator must contain at least one Integrator");
diff --git a/plugins/drude/serialization/src/DrudeLangevinIntegratorProxy.cpp b/plugins/drude/serialization/src/DrudeLangevinIntegratorProxy.cpp
index 1952ab05b4..ddf3d3f49c 100644
--- a/plugins/drude/serialization/src/DrudeLangevinIntegratorProxy.cpp
+++ b/plugins/drude/serialization/src/DrudeLangevinIntegratorProxy.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) 2013 Stanford University and the Authors.           *
+ * Portions copyright (c) 2013-2024 Stanford University and the Authors.      *
  * Authors: Peter Eastman                                                     *
  * Contributors:                                                              *
  *                                                                            *
@@ -50,6 +50,7 @@ void DrudeLangevinIntegratorProxy::serialize(const void* object, SerializationNo
     node.setDoubleProperty("drudeTemperature", integrator.getDrudeTemperature());
     node.setDoubleProperty("drudeFriction", integrator.getDrudeFriction());
     node.setIntProperty("randomSeed", integrator.getRandomNumberSeed());
+    node.setIntProperty("integrationForceGroups", integrator.getIntegrationForceGroups());
 }

 void* DrudeLangevinIntegratorProxy::deserialize(const SerializationNode& node) const {
@@ -60,5 +61,6 @@ void* DrudeLangevinIntegratorProxy::deserialize(const SerializationNode& node) c
             node.getDoubleProperty("drudeFriction"), node.getDoubleProperty("stepSize"));
     integrator->setConstraintTolerance(node.getDoubleProperty("constraintTolerance"));
     integrator->setRandomNumberSeed(node.getIntProperty("randomSeed"));
+    integrator->setIntegrationForceGroups(node.getIntProperty("integrationForceGroups", 0xFFFFFFFF));
     return integrator;
 }
\ No newline at end of file
diff --git a/plugins/drude/serialization/src/DrudeNoseHooverIntegratorProxy.cpp b/plugins/drude/serialization/src/DrudeNoseHooverIntegratorProxy.cpp
index 452d16d136..62a745913f 100644
--- a/plugins/drude/serialization/src/DrudeNoseHooverIntegratorProxy.cpp
+++ b/plugins/drude/serialization/src/DrudeNoseHooverIntegratorProxy.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 Stanford University and the Authors.           *
+ * Portions copyright (c) 2010-2024 Stanford University and the Authors.      *
  * Authors: Andrew C. Simmonett, Andreas Krämer                               *
  * Contributors:                                                              *
  *                                                                            *
@@ -57,6 +57,7 @@ void DrudeNoseHooverIntegratorProxy::serialize(const void* object, Serialization
     node.setIntProperty("chainLength", integrator.getThermostat().getChainLength());
     node.setIntProperty("numMTS", integrator.getThermostat().getNumMultiTimeSteps());
     node.setIntProperty("numYS", integrator.getThermostat().getNumYoshidaSuzukiTimeSteps());
+    node.setIntProperty("integrationForceGroups", integrator.getIntegrationForceGroups());
 }

 void* DrudeNoseHooverIntegratorProxy::deserialize(const SerializationNode& node) const {
@@ -75,6 +76,7 @@ void* DrudeNoseHooverIntegratorProxy::deserialize(const SerializationNode& node)
     );
     integrator->setConstraintTolerance(node.getDoubleProperty("constraintTolerance"));
     integrator->setMaximumPairDistance(node.getDoubleProperty("maximumPairDistance"));
+    integrator->setIntegrationForceGroups(node.getIntProperty("integrationForceGroups", 0xFFFFFFFF));

     return integrator;
 }
diff --git a/serialization/src/BrownianIntegratorProxy.cpp b/serialization/src/BrownianIntegratorProxy.cpp
index 132d00e9f3..725305b13e 100644
--- a/serialization/src/BrownianIntegratorProxy.cpp
+++ b/serialization/src/BrownianIntegratorProxy.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 Stanford University and the Authors.           *
+ * Portions copyright (c) 2010-2024 Stanford University and the Authors.      *
  * Authors: Peter Eastman, Yutong Zhao                                        *
  * Contributors:                                                              *
  *                                                                            *
@@ -47,6 +47,7 @@ void BrownianIntegratorProxy::serialize(const void* object, SerializationNode& n
     node.setDoubleProperty("temperature", integrator.getTemperature());
     node.setDoubleProperty("friction", integrator.getFriction());
     node.setIntProperty("randomSeed", integrator.getRandomNumberSeed());
+    node.setIntProperty("integrationForceGroups", integrator.getIntegrationForceGroups());
 }

 void* BrownianIntegratorProxy::deserialize(const SerializationNode& node) const {
@@ -57,5 +58,6 @@ void* BrownianIntegratorProxy::deserialize(const SerializationNode& node) const
                                                             node.getDoubleProperty("stepSize"));
     integrator->setConstraintTolerance(node.getDoubleProperty("constraintTolerance"));
     integrator->setRandomNumberSeed(node.getIntProperty("randomSeed"));
+    integrator->setIntegrationForceGroups(node.getIntProperty("integrationForceGroups", 0xFFFFFFFF));
     return integrator;
 }
\ No newline at end of file
diff --git a/serialization/src/CustomIntegratorProxy.cpp b/serialization/src/CustomIntegratorProxy.cpp
index 86ab647835..ca370fb5a5 100644
--- a/serialization/src/CustomIntegratorProxy.cpp
+++ b/serialization/src/CustomIntegratorProxy.cpp
@@ -65,6 +65,7 @@ void CustomIntegratorProxy::serialize(const void* object, SerializationNode& nod
         functions.createChildNode("Function", &integrator.getTabulatedFunction(i)).setStringProperty("name", integrator.getTabulatedFunctionName(i));
     node.setStringProperty("kineticEnergyExpression", integrator.getKineticEnergyExpression());
     node.setIntProperty("randomSeed", integrator.getRandomNumberSeed());
+    node.setIntProperty("integrationForceGroups", integrator.getIntegrationForceGroups());
     node.setDoubleProperty("stepSize", integrator.getStepSize());
     node.setDoubleProperty("constraintTolerance", integrator.getConstraintTolerance());
 }
@@ -124,6 +125,7 @@ void* CustomIntegratorProxy::deserialize(const SerializationNode& node) const {
     }
     integrator->setKineticEnergyExpression(node.getStringProperty("kineticEnergyExpression"));
     integrator->setRandomNumberSeed(node.getIntProperty("randomSeed"));
+    integrator->setIntegrationForceGroups(node.getIntProperty("integrationForceGroups", 0xFFFFFFFF));
     integrator->setConstraintTolerance(node.getDoubleProperty("constraintTolerance"));
     return integrator;
 }
diff --git a/serialization/src/LangevinIntegratorProxy.cpp b/serialization/src/LangevinIntegratorProxy.cpp
index e978b6ea17..0773503eb9 100644
--- a/serialization/src/LangevinIntegratorProxy.cpp
+++ b/serialization/src/LangevinIntegratorProxy.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 Stanford University and the Authors.           *
+ * Portions copyright (c) 2010-2024 Stanford University and the Authors.      *
  * Authors: Peter Eastman, Yutong Zhao                                        *
  * Contributors:                                                              *
  *                                                                            *
@@ -47,6 +47,7 @@ void LangevinIntegratorProxy::serialize(const void* object, SerializationNode& n
     node.setDoubleProperty("temperature", integrator.getTemperature());
     node.setDoubleProperty("friction", integrator.getFriction());
     node.setIntProperty("randomSeed", integrator.getRandomNumberSeed());
+    node.setIntProperty("integrationForceGroups", integrator.getIntegrationForceGroups());
 }

 void* LangevinIntegratorProxy::deserialize(const SerializationNode& node) const {
@@ -57,5 +58,6 @@ void* LangevinIntegratorProxy::deserialize(const SerializationNode& node) const
                                                             node.getDoubleProperty("stepSize"));
     integrator->setConstraintTolerance(node.getDoubleProperty("constraintTolerance"));
     integrator->setRandomNumberSeed(node.getIntProperty("randomSeed"));
+    integrator->setIntegrationForceGroups(node.getIntProperty("integrationForceGroups", 0xFFFFFFFF));
     return integrator;
 }
\ No newline at end of file
diff --git a/serialization/src/LangevinMiddleIntegratorProxy.cpp b/serialization/src/LangevinMiddleIntegratorProxy.cpp
index 4d6d664e20..7516df850f 100644
--- a/serialization/src/LangevinMiddleIntegratorProxy.cpp
+++ b/serialization/src/LangevinMiddleIntegratorProxy.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-2019 Stanford University and the Authors.      *
+ * Portions copyright (c) 2010-2024 Stanford University and the Authors.      *
  * Authors: Peter Eastman, Yutong Zhao                                        *
  * Contributors:                                                              *
  *                                                                            *
@@ -47,6 +47,7 @@ void LangevinMiddleIntegratorProxy::serialize(const void* object, SerializationN
     node.setDoubleProperty("temperature", integrator.getTemperature());
     node.setDoubleProperty("friction", integrator.getFriction());
     node.setIntProperty("randomSeed", integrator.getRandomNumberSeed());
+    node.setIntProperty("integrationForceGroups", integrator.getIntegrationForceGroups());
 }

 void* LangevinMiddleIntegratorProxy::deserialize(const SerializationNode& node) const {
@@ -56,5 +57,6 @@ void* LangevinMiddleIntegratorProxy::deserialize(const SerializationNode& node)
             node.getDoubleProperty("friction"), node.getDoubleProperty("stepSize"));
     integrator->setConstraintTolerance(node.getDoubleProperty("constraintTolerance"));
     integrator->setRandomNumberSeed(node.getIntProperty("randomSeed"));
+    integrator->setIntegrationForceGroups(node.getIntProperty("integrationForceGroups", 0xFFFFFFFF));
     return integrator;
 }
\ No newline at end of file
diff --git a/serialization/src/NoseHooverIntegratorProxy.cpp b/serialization/src/NoseHooverIntegratorProxy.cpp
index a181d09bef..620281e02d 100644
--- a/serialization/src/NoseHooverIntegratorProxy.cpp
+++ b/serialization/src/NoseHooverIntegratorProxy.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 Stanford University and the Authors.           *
+ * Portions copyright (c) 2010-2024 Stanford University and the Authors.      *
  * Authors: Andrew C. Simmonett, Andreas Krämer                               *
  * Contributors:                                                              *
  *                                                                            *
@@ -47,6 +47,7 @@ void NoseHooverIntegratorProxy::serialize(const void* object, SerializationNode&
     node.setDoubleProperty("constraintTolerance", integrator.getConstraintTolerance());
     node.setDoubleProperty("maximumPairDistance", integrator.getMaximumPairDistance());
     node.setBoolProperty("hasSubsystemThermostats", integrator.hasSubsystemThermostats());
+    node.setIntProperty("integrationForceGroups", integrator.getIntegrationForceGroups());
     if (integrator.hasSubsystemThermostats()) {
         // Serialize all thermostats separately
         for (int i = 0; i < integrator.getNumThermostats(); i++){
@@ -123,6 +124,7 @@ void* NoseHooverIntegratorProxy::deserialize(const SerializationNode& node) cons
     }
     integrator->setConstraintTolerance(node.getDoubleProperty("constraintTolerance"));
     integrator->setMaximumPairDistance(node.getDoubleProperty("maximumPairDistance"));
+    integrator->setIntegrationForceGroups(node.getIntProperty("integrationForceGroups", 0xFFFFFFFF));

     return integrator;
 }
diff --git a/serialization/src/VariableLangevinIntegratorProxy.cpp b/serialization/src/VariableLangevinIntegratorProxy.cpp
index 079cd54636..6a7c8c3389 100644
--- a/serialization/src/VariableLangevinIntegratorProxy.cpp
+++ b/serialization/src/VariableLangevinIntegratorProxy.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-2019 Stanford University and the Authors.      *
+ * Portions copyright (c) 2010-2024 Stanford University and the Authors.      *
  * Authors: Peter Eastman, Yutong Zhao                                        *
  * Contributors:                                                              *
  *                                                                            *
@@ -48,6 +48,7 @@ void VariableLangevinIntegratorProxy::serialize(const void* object, Serializatio
     node.setDoubleProperty("errorTol", integrator.getErrorTolerance());
     node.setDoubleProperty("maxStepSize", integrator.getMaximumStepSize());
     node.setIntProperty("randomSeed", integrator.getRandomNumberSeed());
+    node.setIntProperty("integrationForceGroups", integrator.getIntegrationForceGroups());
 }

 void* VariableLangevinIntegratorProxy::deserialize(const SerializationNode& node) const {
@@ -60,6 +61,7 @@ void* VariableLangevinIntegratorProxy::deserialize(const SerializationNode& node
     integrator->setStepSize(node.getDoubleProperty("stepSize"));
     integrator->setConstraintTolerance(node.getDoubleProperty("constraintTolerance"));
     integrator->setRandomNumberSeed(node.getIntProperty("randomSeed"));
+    integrator->setIntegrationForceGroups(node.getIntProperty("integrationForceGroups", 0xFFFFFFFF));
     if (version > 1)
         integrator->setMaximumStepSize(node.getDoubleProperty("maxStepSize"));
     return integrator;
diff --git a/serialization/src/VariableVerletIntegratorProxy.cpp b/serialization/src/VariableVerletIntegratorProxy.cpp
index 957db3eaad..a6162d1e1a 100644
--- a/serialization/src/VariableVerletIntegratorProxy.cpp
+++ b/serialization/src/VariableVerletIntegratorProxy.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-2019 Stanford University and the Authors.      *
+ * Portions copyright (c) 2010-2024 Stanford University and the Authors.      *
  * Authors: Peter Eastman, Yutong Zhao                                        *
  * Contributors:                                                              *
  *                                                                            *
@@ -45,6 +45,7 @@ void VariableVerletIntegratorProxy::serialize(const void* object, SerializationN
     node.setDoubleProperty("maxStepSize", integrator.getMaximumStepSize());
     node.setDoubleProperty("stepSize", integrator.getStepSize());
     node.setDoubleProperty("constraintTolerance", integrator.getConstraintTolerance());
+    node.setIntProperty("integrationForceGroups", integrator.getIntegrationForceGroups());
 }

 void* VariableVerletIntegratorProxy::deserialize(const SerializationNode& node) const {
@@ -54,6 +55,7 @@ void* VariableVerletIntegratorProxy::deserialize(const SerializationNode& node)
     VariableVerletIntegrator *integrator = new VariableVerletIntegrator(node.getDoubleProperty("errorTol"));
     integrator->setStepSize(node.getDoubleProperty("stepSize"));
     integrator->setConstraintTolerance(node.getDoubleProperty("constraintTolerance"));
+    integrator->setIntegrationForceGroups(node.getIntProperty("integrationForceGroups", 0xFFFFFFFF));
     if (version > 1)
         integrator->setMaximumStepSize(node.getDoubleProperty("maxStepSize"));
     return integrator;
diff --git a/serialization/src/VerletIntegratorProxy.cpp b/serialization/src/VerletIntegratorProxy.cpp
index 53f7f77fe8..c3e4a35e98 100644
--- a/serialization/src/VerletIntegratorProxy.cpp
+++ b/serialization/src/VerletIntegratorProxy.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 Stanford University and the Authors.           *
+ * Portions copyright (c) 2010-2024 Stanford University and the Authors.      *
  * Authors: Peter Eastman, Yutong Zhao                                        *
  * Contributors:                                                              *
  *                                                                            *
@@ -44,6 +44,7 @@ void VerletIntegratorProxy::serialize(const void* object, SerializationNode& nod
     const VerletIntegrator& integrator = *reinterpret_cast<const VerletIntegrator*>(object);
     node.setDoubleProperty("stepSize", integrator.getStepSize());
     node.setDoubleProperty("constraintTolerance", integrator.getConstraintTolerance());
+    node.setIntProperty("integrationForceGroups", integrator.getIntegrationForceGroups());
 }

 void* VerletIntegratorProxy::deserialize(const SerializationNode& node) const {
@@ -51,5 +52,6 @@ void* VerletIntegratorProxy::deserialize(const SerializationNode& node) const {
         throw OpenMMException("Unsupported version number");
     VerletIntegrator *integrator = new VerletIntegrator(node.getDoubleProperty("stepSize"));
     integrator->setConstraintTolerance(node.getDoubleProperty("constraintTolerance"));
+    integrator->setIntegrationForceGroups(node.getIntProperty("integrationForceGroups", 0xFFFFFFFF));
     return integrator;
 }
\ No newline at end of file

diff --git a/plugins/drude/serialization/tests/TestSerializeDrudeLangevinIntegrator.cpp b/plugins/drude/serialization/tests/TestSerializeDrudeLangevinIntegrator.cpp
index d11e4f4fca..df366aabce 100644
--- a/plugins/drude/serialization/tests/TestSerializeDrudeLangevinIntegrator.cpp
+++ b/plugins/drude/serialization/tests/TestSerializeDrudeLangevinIntegrator.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) 2013 Stanford University and the Authors.           *
+ * Portions copyright (c) 2013-2024 Stanford University and the Authors.      *
  * Authors: Peter Eastman                                                     *
  * Contributors:                                                              *
  *                                                                            *
@@ -46,6 +46,7 @@ void testSerialization() {

     DrudeLangevinIntegrator integ1(301.1, 0.95, 10.5, 1.2, 0.001);
     integ1.setRandomNumberSeed(18);
+    integ1.setIntegrationForceGroups(3);

     // Serialize and then deserialize it.

@@ -61,6 +62,7 @@ void testSerialization() {
     ASSERT_EQUAL(integ1.getDrudeTemperature(), integ2.getDrudeTemperature());
     ASSERT_EQUAL(integ1.getDrudeFriction(), integ2.getDrudeFriction());
     ASSERT_EQUAL(integ1.getRandomNumberSeed(), integ2.getRandomNumberSeed());
+    ASSERT_EQUAL(integ1.getIntegrationForceGroups(), integ2.getIntegrationForceGroups());
 }

 int main() {
diff --git a/plugins/drude/serialization/tests/TestSerializeDrudeNoseHooverIntegrator.cpp b/plugins/drude/serialization/tests/TestSerializeDrudeNoseHooverIntegrator.cpp
index fd6859c35b..696f89a612 100644
--- a/plugins/drude/serialization/tests/TestSerializeDrudeNoseHooverIntegrator.cpp
+++ b/plugins/drude/serialization/tests/TestSerializeDrudeNoseHooverIntegrator.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-2014 Stanford University and the Authors.      *
+ * Portions copyright (c) 2010-2024 Stanford University and the Authors.      *
  * Authors: Andrew C. Simmonett and Andreas Kraemer
  * Contributors:                                                              *
  *                                                                            *
@@ -51,6 +51,7 @@ void assertIntegratorsEqual(const DrudeNoseHooverIntegrator& integrator1, const
     ASSERT_EQUAL(integrator1.getConstraintTolerance(), integrator2.getConstraintTolerance());
     ASSERT_EQUAL(integrator1.getMaximumPairDistance(), integrator2.getMaximumPairDistance());
     ASSERT_EQUAL(integrator1.getNumThermostats(), integrator2.getNumThermostats());
+    ASSERT_EQUAL(integrator1.getIntegrationForceGroups(), integrator2.getIntegrationForceGroups());
     for (int i = 0; i < integrator1.getNumThermostats(); i++) {
         const auto &thermostat1 = integrator1.getThermostat(i);
         const auto &thermostat2 = integrator2.getThermostat(i);
@@ -84,6 +85,7 @@ void testSerialization() {
     system.addForce(new DrudeForce());
     for (int i=0; i<10; i++) system.addParticle(1.0);
     DrudeNoseHooverIntegrator integrator(331, 0.21, 1.1, 0.1, 0.004, 5, 5, 5);
+    integrator.setIntegrationForceGroups(3);

     // Serialize and then deserialize it.
     stringstream buffer2;
diff --git a/serialization/tests/TestSerializeIntegrator.cpp b/serialization/tests/TestSerializeIntegrator.cpp
index 2663de097a..4bafd0377f 100644
--- a/serialization/tests/TestSerializeIntegrator.cpp
+++ b/serialization/tests/TestSerializeIntegrator.cpp
@@ -52,17 +52,21 @@ using namespace std;

 void testSerializeVerletIntegrator() {
     VerletIntegrator *intg = new VerletIntegrator(0.00342);
+    intg->setIntegrationForceGroups(3);
     stringstream ss;
     XmlSerializer::serialize<Integrator>(intg, "VerletIntegrator", ss);
     VerletIntegrator *intg2 = dynamic_cast<VerletIntegrator*>(XmlSerializer::deserialize<Integrator>(ss));
     ASSERT_EQUAL(intg->getConstraintTolerance(), intg2->getConstraintTolerance());
     ASSERT_EQUAL(intg->getStepSize(), intg2->getStepSize());
+    ASSERT_EQUAL(intg->getIntegrationForceGroups(), intg2->getIntegrationForceGroups());
     delete intg;
     delete intg2;
 }

 void testSerializeLangevinIntegrator() {
     LangevinIntegrator *intg = new LangevinIntegrator(372.4, 1.234, 0.0018);
+    intg->setRandomNumberSeed(10);
+    intg->setIntegrationForceGroups(3);
     stringstream ss;
     XmlSerializer::serialize<Integrator>(intg, "LangevinIntegrator", ss);
     LangevinIntegrator *intg2 = dynamic_cast<LangevinIntegrator*>(XmlSerializer::deserialize<Integrator>(ss));
@@ -71,12 +75,15 @@ void testSerializeLangevinIntegrator() {
     ASSERT_EQUAL(intg->getTemperature(), intg2->getTemperature());
     ASSERT_EQUAL(intg->getFriction(), intg2->getFriction());
     ASSERT_EQUAL(intg->getRandomNumberSeed(), intg2->getRandomNumberSeed());
+    ASSERT_EQUAL(intg->getIntegrationForceGroups(), intg2->getIntegrationForceGroups());
     delete intg;
     delete intg2;
 }

 void testSerializeLangevinMiddleIntegrator() {
     LangevinMiddleIntegrator *intg = new LangevinMiddleIntegrator(372.4, 1.234, 0.0018);
+    intg->setRandomNumberSeed(10);
+    intg->setIntegrationForceGroups(3);
     stringstream ss;
     XmlSerializer::serialize<Integrator>(intg, "LangevinMiddleIntegrator", ss);
     LangevinMiddleIntegrator *intg2 = dynamic_cast<LangevinMiddleIntegrator*>(XmlSerializer::deserialize<Integrator>(ss));
@@ -85,12 +92,15 @@ void testSerializeLangevinMiddleIntegrator() {
     ASSERT_EQUAL(intg->getTemperature(), intg2->getTemperature());
     ASSERT_EQUAL(intg->getFriction(), intg2->getFriction());
     ASSERT_EQUAL(intg->getRandomNumberSeed(), intg2->getRandomNumberSeed());
+    ASSERT_EQUAL(intg->getIntegrationForceGroups(), intg2->getIntegrationForceGroups());
     delete intg;
     delete intg2;
 }

 void testSerializeBrownianIntegrator() {
     BrownianIntegrator *intg = new BrownianIntegrator(243.1, 3.234, 0.0021);
+    intg->setRandomNumberSeed(10);
+    intg->setIntegrationForceGroups(3);
     stringstream ss;
     XmlSerializer::serialize<Integrator>(intg, "BrownianIntegrator", ss);
     BrownianIntegrator *intg2 = dynamic_cast<BrownianIntegrator*>(XmlSerializer::deserialize<Integrator>(ss));
@@ -99,6 +109,7 @@ void testSerializeBrownianIntegrator() {
     ASSERT_EQUAL(intg->getTemperature(), intg2->getTemperature());
     ASSERT_EQUAL(intg->getFriction(), intg2->getFriction());
     ASSERT_EQUAL(intg->getRandomNumberSeed(), intg2->getRandomNumberSeed());
+    ASSERT_EQUAL(intg->getIntegrationForceGroups(), intg2->getIntegrationForceGroups());
     delete intg;
     delete intg2;
 }
@@ -106,6 +117,7 @@ void testSerializeBrownianIntegrator() {
 void testSerializeVariableVerletIntegrator() {
     VariableVerletIntegrator intg(0.04234);
     intg.setMaximumStepSize(0.32);
+    intg.setIntegrationForceGroups(3);
     stringstream ss;
     XmlSerializer::serialize<Integrator>(&intg, "VariableVerletIntegrator", ss);
     VariableVerletIntegrator *intg2 = dynamic_cast<VariableVerletIntegrator*>(XmlSerializer::deserialize<Integrator>(ss));
@@ -113,12 +125,15 @@ void testSerializeVariableVerletIntegrator() {
     ASSERT_EQUAL(intg.getStepSize(), intg2->getStepSize());
     ASSERT_EQUAL(intg.getErrorTolerance(), intg2->getErrorTolerance());
     ASSERT_EQUAL(intg.getMaximumStepSize(), intg2->getMaximumStepSize());
+    ASSERT_EQUAL(intg.getIntegrationForceGroups(), intg2->getIntegrationForceGroups());
     delete intg2;
 }

 void testSerializeVariableLangevinIntegrator() {
     VariableLangevinIntegrator intg(243.1, 3.234, 0.0021);
     intg.setMaximumStepSize(0.32);
+    intg.setRandomNumberSeed(10);
+    intg.setIntegrationForceGroups(3);
     stringstream ss;
     XmlSerializer::serialize<Integrator>(&intg, "VariableLangevinIntegrator", ss);
     VariableLangevinIntegrator *intg2 = dynamic_cast<VariableLangevinIntegrator*>(XmlSerializer::deserialize<Integrator>(ss));
@@ -129,11 +144,14 @@ void testSerializeVariableLangevinIntegrator() {
     ASSERT_EQUAL(intg.getTemperature(), intg2->getTemperature());
     ASSERT_EQUAL(intg.getRandomNumberSeed(), intg2->getRandomNumberSeed());
     ASSERT_EQUAL(intg.getMaximumStepSize(), intg2->getMaximumStepSize());
+    ASSERT_EQUAL(intg.getIntegrationForceGroups(), intg2->getIntegrationForceGroups());
     delete intg2;
 }

 void testSerializeCustomIntegrator() {
     CustomIntegrator *intg = new CustomIntegrator(0.002234);
+    intg->setRandomNumberSeed(10);
+    intg->setIntegrationForceGroups(3);
     intg->addPerDofVariable("temp",0);
     vector<Vec3> initialValues(123);
     for(int i = 0; i < 123; i++)
@@ -209,6 +227,7 @@ void testSerializeCustomIntegrator() {
     }
     ASSERT_EQUAL(intg->getKineticEnergyExpression(), intg2->getKineticEnergyExpression());
     ASSERT_EQUAL(intg->getRandomNumberSeed(), intg2->getRandomNumberSeed());
+    ASSERT_EQUAL(intg->getIntegrationForceGroups(), intg2->getIntegrationForceGroups());
     ASSERT_EQUAL(intg->getStepSize(), intg2->getStepSize());
     ASSERT_EQUAL(intg->getConstraintTolerance(), intg2->getConstraintTolerance());
     ASSERT_EQUAL(intg->getNumTabulatedFunctions(), intg2->getNumTabulatedFunctions());
@@ -233,10 +252,12 @@ void testSerializeCompoundIntegrator() {
     integ.addIntegrator(new LangevinIntegrator(372.4, 1.234, 0.0018));
     integ.addIntegrator(new VerletIntegrator(0.002));
     integ.setCurrentIntegrator(1);
+    integ.setIntegrationForceGroups(3);
     stringstream ss;
     XmlSerializer::serialize<Integrator>(&integ, "CompoundIntegrator", ss);
     CompoundIntegrator *integ2 = dynamic_cast<CompoundIntegrator*>(XmlSerializer::deserialize<Integrator>(ss));
     ASSERT_EQUAL(integ.getCurrentIntegrator(), integ2->getCurrentIntegrator());
+    ASSERT_EQUAL(integ.getIntegrationForceGroups(), integ2->getIntegrationForceGroups());
     LangevinIntegrator& langevin1 = dynamic_cast<LangevinIntegrator&>(integ.getIntegrator(0));
     LangevinIntegrator& langevin2 = dynamic_cast<LangevinIntegrator&>(integ2->getIntegrator(0));
     ASSERT_EQUAL(langevin1.getConstraintTolerance(), langevin2.getConstraintTolerance());
diff --git a/serialization/tests/TestSerializeNoseHooverIntegrator.cpp b/serialization/tests/TestSerializeNoseHooverIntegrator.cpp
index 4a244b5be0..ef23e036c3 100644
--- a/serialization/tests/TestSerializeNoseHooverIntegrator.cpp
+++ b/serialization/tests/TestSerializeNoseHooverIntegrator.cpp
@@ -45,6 +45,7 @@ void assertIntegratorsEqual(const NoseHooverIntegrator& integrator1, const NoseH
     ASSERT_EQUAL(integrator1.getConstraintTolerance(), integrator2.getConstraintTolerance());
     ASSERT_EQUAL(integrator1.getMaximumPairDistance(), integrator2.getMaximumPairDistance());
     ASSERT_EQUAL(integrator1.getNumThermostats(), integrator2.getNumThermostats());
+    ASSERT_EQUAL(integrator1.getIntegrationForceGroups(), integrator2.getIntegrationForceGroups());
     for (int i = 0; i < integrator1.getNumThermostats(); i++) {
         const auto &thermostat1 = integrator1.getThermostat(i);
         const auto &thermostat2 = integrator2.getThermostat(i);
@@ -82,6 +83,7 @@ void testSerialization() {
     integrator_sub.addSubsystemThermostat(
         {0,1,2,3,4,7}, {{0,7}}, 301.1, 1.1, 1.2, 1.3, 9, 2, 5
     );
+    integrator_sub.setIntegrationForceGroups(3);

     // Serialize and then deserialize it.