Preserve bond type and order in all `Modeller` operations (openmm/openmm#4323)

This is not linked to an open issue (as far as I can tell), but it falls in the same category as #4112 (issue), #4113 (PR), completing the work of the latter.

----

When generating a new topology for a `Modeller` as a result of any operation, bonds that had `type` and `order` attributes in the original topology should retain them, instead of implicitly setting them to `None`.

This PR

- ensures that  `delete()` (therefore implicitly `deleteWater()`), `addSolvent()`, `addHydrogens()` and `addExtraParticles()` behave in the desirable way, while #4112 did it for `add()` (and implicitly for `addMembrane()`).

- adds regression tests either as new tests method, or as assertions in existing test methods

### Issue Description

#### Problem:
When performing operations in the `Modeller` class, such as adding solvent, deleting water, adding hydrogens, or adding extra particles, bond attributes like `type` and `order` are not preserved in the resulting topology. This leads to the loss of critical bond information that may be essential for downstream processes, such as simulations or analyses that rely on accurate chemical representations.

#### Expected Behavior:
All operations in the `Modeller` class should preserve bond attributes, including `type` and `order`, from the original topology. When a new topology is generated as a result of these operations, bonds should retain their original attributes to ensure consistent and accurate molecular representations.

## IMPORTANT INSTRUCTIONS

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

diff --git a/wrappers/python/openmm/app/modeller.py b/wrappers/python/openmm/app/modeller.py
index 997e0f53b1..1cc5299fbe 100644
--- a/wrappers/python/openmm/app/modeller.py
+++ b/wrappers/python/openmm/app/modeller.py
@@ -178,7 +178,7 @@ def delete(self, toDelete):
         for bond in self.topology.bonds():
             if bond[0] in newAtoms and bond[1] in newAtoms:
                 if bond not in deleteSet and (bond[1], bond[0]) not in deleteSet:
-                    newTopology.addBond(newAtoms[bond[0]], newAtoms[bond[1]])
+                    newTopology.addBond(newAtoms[bond[0]], newAtoms[bond[1]], bond.type, bond.order)
         self.topology = newTopology
         self.positions = newPositions

@@ -253,7 +253,7 @@ def convertWater(self, model='tip3p'):
                         newPositions.append(deepcopy(self.positions[atom.index]))
         for bond in self.topology.bonds():
             if bond[0] in newAtoms and bond[1] in newAtoms:
-                newTopology.addBond(newAtoms[bond[0]], newAtoms[bond[1]])
+                newTopology.addBond(newAtoms[bond[0]], newAtoms[bond[1]], bond.type, bond.order)
         self.topology = newTopology
         self.positions = newPositions

@@ -549,7 +549,7 @@ def addSolvent(self, forcefield, model='tip3p', boxSize=None, boxVectors=None, p
                     newAtoms[atom] = newAtom
                     newPositions.append(deepcopy(self.positions[atom.index]))
         for bond in self.topology.bonds():
-            newTopology.addBond(newAtoms[bond[0]], newAtoms[bond[1]])
+            newTopology.addBond(newAtoms[bond[0]], newAtoms[bond[1]], bond.type, bond.order)

         # Sort the solute atoms into cells for fast lookup.

@@ -1000,7 +1000,7 @@ def isHbond(d, h, a):
                         newPositions.append(deepcopy(self.positions[atom.index]))
         for bond in self.topology.bonds():
             if bond[0] in newAtoms and bond[1] in newAtoms:
-                newTopology.addBond(newAtoms[bond[0]], newAtoms[bond[1]])
+                newTopology.addBond(newAtoms[bond[0]], newAtoms[bond[1]], bond.type, bond.order)

         # The hydrogens were added at random positions.  Now perform an energy minimization to fix them up.

@@ -1224,7 +1224,7 @@ def addExtraParticles(self, forcefield, ignoreExternalBonds=False, residueTempla

         for bond in self.topology.bonds():
             if bond[0] in newAtoms and bond[1] in newAtoms:
-                newTopology.addBond(newAtoms[bond[0]], newAtoms[bond[1]])
+                newTopology.addBond(newAtoms[bond[0]], newAtoms[bond[1]], bond.type, bond.order)

         if len(missingPositions) > 0:
             # There were particles whose position we couldn't identify before, since they were neither virtual sites nor Drude particles.

diff --git a/wrappers/python/tests/TestModeller.py b/wrappers/python/tests/TestModeller.py
index 1f29bc9951..16e5837418 100644
--- a/wrappers/python/tests/TestModeller.py
+++ b/wrappers/python/tests/TestModeller.py
@@ -1200,6 +1200,75 @@ def test_addMembrane(self):
         for i in range(3):
             self.assertTrue(newSize[i] >= originalSize[i]+1.1*nanometers)

+    def test_bondTypeAndOrderPreserved(self):
+        """ Check that bond type and order are preserved across multiple operations.
+
+        Regression test for issue #4112 and similar behaviors.
+        """
+
+        # Given: an isolated molecule
+        pdb = PDBFile("systems/alanine-dipeptide-implicit.pdb")
+        topology, positions = pdb.topology, pdb.positions
+        topology.setUnitCellDimensions(Vec3(3.5, 3.5, 3.5) * nanometers)
+        # with some bonds carrying type and order information
+        for bond in topology.bonds():
+            if ((bond.atom1.element, bond.atom2.element) in [
+                (element.carbon, element.oxygen), (element.oxygen, element.carbon)
+            ]):
+                bond.type = Double
+                bond.order = 2.0
+        modeller = Modeller(topology, positions)
+
+        # When (1): add solvent
+        forcefield = ForceField("amber10.xml", "tip3p.xml")
+        modeller.addSolvent(forcefield, "tip3p")
+        # sanity check: water was added
+        self.assertTrue(any(r.name == "HOH" for r in modeller.topology.residues()))
+
+        # When (2): convert water (no sites added)
+        modeller.convertWater("spce")
+
+        # When (3): convert water with addExtraParticles
+        new_forcefield = ForceField('amber10.xml', 'tip4pew.xml')
+        modeller.addExtraParticles(new_forcefield)
+        # sanity check: extra sites were added
+        self.assertEqual(
+            set([len(list(res.atoms())) for res in modeller.topology.residues() if res.name == "HOH"]),
+            {4}
+        )
+
+        # When (4): delete water (with deleteWater) and hydrogens (with delete)
+        modeller.deleteWater()
+        hydrogens = [a for a in modeller.topology.atoms() if a.element == element.hydrogen]
+        modeller.delete(hydrogens)
+        # sanity check: all gone
+        self.assertFalse(any(a.element == element.hydrogen for a in modeller.topology.atoms()))
+        self.assertFalse(any(r.name == "HOH" for r in modeller.topology.residues()))
+
+        # When (5): add back hydrogens
+        modeller.addHydrogens()
+        # sanity check: hydrogens are back
+        self.assertTrue(any(a.element == element.hydrogen for a in modeller.topology.atoms()))
+
+        # Then (intermediate): bond info have been retained throughout the workflow
+        self.assertIn((Double, 2.0), [(b.type, b.order) for b in modeller.topology.bonds()])
+
+        # When (6): add a modeller (which also bears some bond info)
+        to_add = PDBFile('systems/methanol-box.pdb')
+        topology_to_add = to_add.topology
+        positions_to_add = to_add.positions
+        # add a dummy bond to the "to_add" system to check that it also is preserved
+        atom0, atom1 = (atom for i, atom in enumerate(topology_to_add.atoms()) if i < 2)
+        topology_to_add.addBond(atom0, atom1, Single, 1.0)
+        modeller.add(topology_to_add, positions_to_add)
+
+        # Then: bond info are retained for both the old and the new system
+        all_bond_extra_data = [(b.type, b.order) for b in modeller.topology.bonds()]
+        self.assertEqual(
+            set(all_bond_extra_data),
+            # None and Double from topology; other Nones and Single from topology_to_add
+            {(None, None), (Single, 1.0), (Double, 2.0)}
+        )

     def assertVecAlmostEqual(self, p1, p2, tol=1e-7):
         scale = max(1.0, norm(p1),)


DrudeForce supports periodic boundary conditions (openmm/openmm#4523)

Periodic boundary conditions with DrudeForce

Hi, I have a question regarding periodic boundary conditions and the DrudeForce. From the documentation for `DrudeForce` I understand that `drudeforce.usesPeriodicBoundaryConditions()` will return `True` or `False` depending on if the `NonbondedForce` uses PBC. However in my case `drudeforce.usesPeriodicBoundaryConditions()` returns `False` although `nonbondedforce.usesPeriodicBoundaryConditions()` returns `True`.

Since there is no `drudeforce.setUsesPeriodicBoundaryConditions(True)` how can I set `drudeforce.usesPeriodicBoundaryConditions()` to `True`?

I attach my code:

```
# Import openmm
from openmm.app import *
from openmm import *
from openmm.unit import *
from sys import stdout

# Read force field from XML file

forcefield = ForceField("forcefield.xml")

# Read positions and topology from Gromacs files, create a modeller object and add extra particles

gro = GromacsGroFile("slab.gro")

top = GromacsTopFile("topol.top", periodicBoxVectors=gro.getPeriodicBoxVectors(), includeDir='/usr/local/gromacs/share/gromacs/top') #gromacs top file

modeller = Modeller(top.topology,gro.positions)

modeller.addExtraParticles(forcefield)

# Create a system

system = forcefield.createSystem(modeller.topology, nonbondedMethod=PME, nonbondedCutoff=1.0*nanometer, constraints=HBonds)

# Activate PBC for bonds and angles

forces = system.getForces()

for force in forces:

    force_name = force.__class__.__name__

    if force_name == "HarmonicBondForce":

        force.setUsesPeriodicBoundaryConditions(True)

    elif force_name == "HarmonicAngleForce":

        force.setUsesPeriodicBoundaryConditions(True)

# Check options for periodic boundary conditions

for force in forces:

    force_name = force.__class__.__name__

    if force.usesPeriodicBoundaryConditions():

        print(f"{force_name} uses PBC")

    else:
        print(f"{force_name} does not use PBC")
```
The output is

```
HarmonicBondForce uses PBC
NonbondedForce uses PBC
DrudeForce does not use PBC
CMMotionRemover does not use PBC
HarmonicAngleForce uses PBC
```

### 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 `plugins/drude/openmmapi/src/DrudeForce.cpp`: `def void DrudeForce::setUsesPeriodicBoundaryConditions(periodic: bool) -> void: ...`
  **Inputs:** periodic: bool
  **Output:** void

```python
def void DrudeForce::setUsesPeriodicBoundaryConditions(periodic) -> 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/plugins/drude/openmmapi/include/openmm/DrudeForce.h b/plugins/drude/openmmapi/include/openmm/DrudeForce.h
index 0ae8980b95..c4483c4331 100644
--- a/plugins/drude/openmmapi/include/openmm/DrudeForce.h
+++ b/plugins/drude/openmmapi/include/openmm/DrudeForce.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) 2013 Stanford University and the Authors.           *
+ * Portions copyright (c) 2013-2024 Stanford University and the Authors.      *
  * Authors: Peter Eastman                                                     *
  * Contributors:                                                              *
  *                                                                            *
@@ -134,7 +134,7 @@ class OPENMM_EXPORT_DRUDE DrudeForce : public Force {
      */
     int addScreenedPair(int particle1, int particle2, double thole);
     /**
-     * Get the force field parameters for screened pair.
+     * Get the force field parameters for a screened pair.
      *
      * @param index           the index of the pair for which to get parameters
      * @param[out] particle1  the index within this Force of the first particle involved in the interaction
@@ -143,7 +143,7 @@ class OPENMM_EXPORT_DRUDE DrudeForce : public Force {
      */
     void getScreenedPairParameters(int index, int& particle1, int& particle2, double& thole) const;
     /**
-     * Set the force field parameters for screened pair.
+     * Set the force field parameters for a screened pair.
      *
      * @param index      the index of the pair for which to get parameters
      * @param particle1  the index within this Force of the first particle involved in the interaction
@@ -162,15 +162,19 @@ class OPENMM_EXPORT_DRUDE DrudeForce : public Force {
      * be used to add new particles or screenedPairs, only to change the parameters of existing ones.
      */
     void updateParametersInContext(Context& context);
+    /**
+     * Set whether this force should apply periodic boundary conditions when calculating displacements.
+     * Usually this is not appropriate for bonded forces, but there are situations when it can be useful.
+     *
+     * Periodic boundary conditions are only applied to screened pairs.  They are never used for the
+     * force between a Drude particle and its parent particle, regardless of this setting.
+     */
+    void setUsesPeriodicBoundaryConditions(bool periodic);
     /**
      * Returns whether or not this force makes use of periodic boundary
      * conditions.
-     *
-     * @returns true if nonbondedMethod uses PBC and false otherwise
      */
-    bool usesPeriodicBoundaryConditions() const {
-        return false;
-    }
+    bool usesPeriodicBoundaryConditions() const;
 protected:
     ForceImpl* createImpl() const;
 private:
@@ -178,6 +182,7 @@ class OPENMM_EXPORT_DRUDE DrudeForce : public Force {
     class ScreenedPairInfo;
     std::vector<ParticleInfo> particles;
     std::vector<ScreenedPairInfo> screenedPairs;
+    bool usePeriodic;
 };

 /**
diff --git a/plugins/drude/openmmapi/src/DrudeForce.cpp b/plugins/drude/openmmapi/src/DrudeForce.cpp
index b46b0a8f32..b07f23844a 100644
--- a/plugins/drude/openmmapi/src/DrudeForce.cpp
+++ b/plugins/drude/openmmapi/src/DrudeForce.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-2021 Stanford University and the Authors.      *
+ * Portions copyright (c) 2013-2024 Stanford University and the Authors.      *
  * Authors: Peter Eastman                                                     *
  * Contributors:                                                              *
  *                                                                            *
@@ -38,7 +38,7 @@
 using namespace OpenMM;
 using namespace std;

-DrudeForce::DrudeForce() {
+DrudeForce::DrudeForce() : usePeriodic(false) {
 }

 int DrudeForce::addParticle(int particle, int particle1, int particle2, int particle3, int particle4, double charge, double polarizability, double aniso12, double aniso34) {
@@ -105,3 +105,11 @@ ForceImpl* DrudeForce::createImpl() const {
 void DrudeForce::updateParametersInContext(Context& context) {
     dynamic_cast<DrudeForceImpl&>(getImplInContext(context)).updateParametersInContext(getContextImpl(context));
 }
+
+void DrudeForce::setUsesPeriodicBoundaryConditions(bool periodic) {
+    usePeriodic = periodic;
+}
+
+bool DrudeForce::usesPeriodicBoundaryConditions() const {
+    return usePeriodic;
+}
diff --git a/plugins/drude/platforms/common/src/CommonDrudeKernels.cpp b/plugins/drude/platforms/common/src/CommonDrudeKernels.cpp
index 77c9aaa026..2da3aeba1e 100644
--- a/plugins/drude/platforms/common/src/CommonDrudeKernels.cpp
+++ b/plugins/drude/platforms/common/src/CommonDrudeKernels.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-2023 Stanford University and the Authors.      *
+ * Portions copyright (c) 2013-2024 Stanford University and the Authors.      *
  * Authors: Peter Eastman                                                     *
  * Contributors:                                                              *
  *                                                                            *
@@ -158,6 +158,7 @@ void CommonCalcDrudeForceKernel::initialize(const System& system, const DrudeFor
         }
         pairParams.upload(paramVector);
         map<string, string> replacements;
+        replacements["APPLY_PERIODIC"] = (force.usesPeriodicBoundaryConditions() ? "1" : "0");
         replacements["PARAMS"] = cc.getBondedUtilities().addArgument(pairParams, "float2");
         cc.getBondedUtilities().addInteraction(atoms, cc.replaceStrings(CommonDrudeKernelSources::drudePairForce, replacements), force.getForceGroup());
     }
diff --git a/plugins/drude/platforms/common/src/kernels/drudePairForce.cc b/plugins/drude/platforms/common/src/kernels/drudePairForce.cc
index 3cf79f07fa..ced2638ee1 100644
--- a/plugins/drude/platforms/common/src/kernels/drudePairForce.cc
+++ b/plugins/drude/platforms/common/src/kernels/drudePairForce.cc
@@ -7,6 +7,9 @@ real3 force4 = make_real3(0);
 // First pair.

 real3 delta = make_real3(pos1.x-pos3.x, pos1.y-pos3.y, pos1.z-pos3.z);
+#if APPLY_PERIODIC
+APPLY_PERIODIC_TO_DELTA(delta)
+#endif
 real rInv = RSQRT(dot(delta, delta));
 real r = RECIP(rInv);
 real u = drudeParams.x*r;
@@ -20,6 +23,9 @@ force3 -= f;
 // Second pair.

 delta = make_real3(pos1.x-pos4.x, pos1.y-pos4.y, pos1.z-pos4.z);
+#if APPLY_PERIODIC
+APPLY_PERIODIC_TO_DELTA(delta)
+#endif
 rInv = RSQRT(dot(delta, delta));
 r = RECIP(rInv);
 u = drudeParams.x*r;
@@ -33,6 +39,9 @@ force4 -= f;
 // Third pair.

 delta = make_real3(pos2.x-pos3.x, pos2.y-pos3.y, pos2.z-pos3.z);
+#if APPLY_PERIODIC
+APPLY_PERIODIC_TO_DELTA(delta)
+#endif
 rInv = RSQRT(dot(delta, delta));
 r = RECIP(rInv);
 u = drudeParams.x*r;
@@ -46,6 +55,9 @@ force3 -= f;
 // Fourth pair.

 delta = make_real3(pos2.x-pos4.x, pos2.y-pos4.y, pos2.z-pos4.z);
+#if APPLY_PERIODIC
+APPLY_PERIODIC_TO_DELTA(delta)
+#endif
 rInv = RSQRT(dot(delta, delta));
 r = RECIP(rInv);
 u = drudeParams.x*r;
diff --git a/plugins/drude/platforms/reference/src/ReferenceDrudeKernels.cpp b/plugins/drude/platforms/reference/src/ReferenceDrudeKernels.cpp
index b97e836d31..087719f145 100644
--- a/plugins/drude/platforms/reference/src/ReferenceDrudeKernels.cpp
+++ b/plugins/drude/platforms/reference/src/ReferenceDrudeKernels.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) 2011-2023 Stanford University and the Authors.      *
+ * Portions copyright (c) 2011-2024 Stanford University and the Authors.      *
  * Authors: Peter Eastman                                                     *
  * Contributors:                                                              *
  *                                                                            *
@@ -36,6 +36,7 @@
 #include "SimTKOpenMMUtilities.h"
 #include "ReferenceConstraints.h"
 #include "ReferenceVirtualSites.h"
+#include "ReferenceForce.h"
 #include <set>

 using namespace OpenMM;
@@ -56,6 +57,11 @@ static vector<Vec3>& extractForces(ContextImpl& context) {
     return *data->forces;
 }

+static Vec3* extractBoxVectors(ContextImpl& context) {
+    ReferencePlatform::PlatformData* data = reinterpret_cast<ReferencePlatform::PlatformData*>(context.getPlatformData());
+    return data->periodicBoxVectors;
+}
+
 static ReferenceConstraints& extractConstraints(ContextImpl& context) {
     ReferencePlatform::PlatformData* data = reinterpret_cast<ReferencePlatform::PlatformData*>(context.getPlatformData());
     return *data->constraints;
@@ -103,6 +109,7 @@ void ReferenceCalcDrudeForceKernel::initialize(const System& system, const Drude
     pairThole.resize(numPairs);
     for (int i = 0; i < numPairs; i++)
         force.getScreenedPairParameters(i, pair1[i], pair2[i], pairThole[i]);
+    periodic = force.usesPeriodicBoundaryConditions();
 }

 double ReferenceCalcDrudeForceKernel::execute(ContextImpl& context, bool includeForces, bool includeEnergy) {
@@ -170,6 +177,7 @@ double ReferenceCalcDrudeForceKernel::execute(ContextImpl& context, bool include
     // Compute the screened interaction between bonded dipoles.

     int numPairs = pair1.size();
+    Vec3* boxVectors = extractBoxVectors(context);
     for (int i = 0; i < numPairs; i++) {
         int dipole1 = pair1[i];
         int dipole2 = pair2[i];
@@ -181,8 +189,13 @@ double ReferenceCalcDrudeForceKernel::execute(ContextImpl& context, bool include
                 int p1 = dipole1Particles[j];
                 int p2 = dipole2Particles[k];
                 double chargeProduct = charge[dipole1]*charge[dipole2]*(j == k ? 1 : -1);
-                Vec3 delta = pos[p1]-pos[p2];
-                double r = sqrt(delta.dot(delta));
+                double deltaR[ReferenceForce::LastDeltaRIndex];
+                if (periodic)
+                    ReferenceForce::getDeltaRPeriodic(pos[p2], pos[p1], boxVectors, deltaR);
+                else
+                    ReferenceForce::getDeltaR(pos[p2], pos[p1], deltaR);
+                Vec3 delta(deltaR[ReferenceForce::XIndex], deltaR[ReferenceForce::YIndex], deltaR[ReferenceForce::ZIndex]);
+                double r = deltaR[ReferenceForce::RIndex];
                 double u = r*uscale;
                 double screening = 1.0 - (1.0+0.5*u)*exp(-u);
                 energy += ONE_4PI_EPS0*chargeProduct*screening/r;
diff --git a/plugins/drude/platforms/reference/src/ReferenceDrudeKernels.h b/plugins/drude/platforms/reference/src/ReferenceDrudeKernels.h
index 2f462c948c..05f0f22924 100644
--- a/plugins/drude/platforms/reference/src/ReferenceDrudeKernels.h
+++ b/plugins/drude/platforms/reference/src/ReferenceDrudeKernels.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) 2013-2023 Stanford University and the Authors.      *
+ * Portions copyright (c) 2013-2024 Stanford University and the Authors.      *
  * Authors: Peter Eastman                                                     *
  * Contributors:                                                              *
  *                                                                            *
@@ -77,6 +77,7 @@ class ReferenceCalcDrudeForceKernel : public CalcDrudeForceKernel {
     std::vector<double> charge, polarizability, aniso12, aniso34;
     std::vector<int> pair1, pair2;
     std::vector<double> pairThole;
+    bool periodic;
 };

 /**

diff --git a/plugins/drude/tests/TestDrudeForce.h b/plugins/drude/tests/TestDrudeForce.h
index 67e47d06fc..67eef4ffa3 100644
--- a/plugins/drude/tests/TestDrudeForce.h
+++ b/plugins/drude/tests/TestDrudeForce.h
@@ -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:                                                              *
  *                                                                            *
@@ -127,26 +127,29 @@ double computeScreening(double r, double thole, double alpha1, double alpha2) {
     return 1.0-(1.0+u/2)*exp(-u);
 }

-void testThole() {
+void testThole(bool periodic) {
     const double k = ONE_4PI_EPS0*1.5;
     const double charge = 0.1;
     const double alpha = ONE_4PI_EPS0*charge*charge/k;
     const double thole = 2.5;
+    const double box = 2.0;
     System system;
     system.addParticle(1.0);
     system.addParticle(1.0);
     system.addParticle(1.0);
     system.addParticle(1.0);
+    system.setDefaultPeriodicBoxVectors(Vec3(box, 0, 0), Vec3(0, box, 0), Vec3(0, 0, box));
     DrudeForce* drude = new DrudeForce();
     drude->addParticle(1, 0, -1, -1, -1, charge, alpha, 1, 1);
     drude->addParticle(3, 2, -1, -1, -1, charge, alpha, 1, 1);
     drude->addScreenedPair(0, 1, thole);
+    drude->setUsesPeriodicBoundaryConditions(periodic);
     system.addForce(drude);
     vector<Vec3> positions(4);
     positions[0] = Vec3(0, 0, 0);
     positions[1] = Vec3(0, -0.5, 0);
-    positions[2] = Vec3(1, 0, 0);
-    positions[3] = Vec3(1, 0, 0.3);
+    positions[2] = Vec3(1.1, 0, 0);
+    positions[3] = Vec3(1.1, 0, 0.3);
     double energySpring1 = 0.5*k*0.5*0.5;
     double energySpring2 = 0.5*k*0.3*0.3;
     double energyDipole = 0.0;
@@ -154,6 +157,11 @@ void testThole() {
     for (int i = 0; i < 2; i++)
         for (int j = 2; j < 4; j++) {
             Vec3 delta = positions[i]-positions[j];
+            if (periodic) {
+                delta[0] -= box*floor(delta[0]/box+0.5);
+                delta[1] -= box*floor(delta[1]/box+0.5);
+                delta[2] -= box*floor(delta[2]/box+0.5);
+            }
             double r = sqrt(delta.dot(delta));
             energyDipole += ONE_4PI_EPS0*q[i]*q[j]*computeScreening(r, thole, alpha, alpha)/r;
         }
@@ -204,7 +212,8 @@ int main(int argc, char* argv[]) {
         setupKernels(argc, argv);
         testSingleParticle();
         testAnisotropicParticle();
-        testThole();
+        testThole(false);
+        testThole(true);
         testChangingParameters();
         runPlatformTests();
     }


Fixed errors with residueTemplates arguments to Modeller methods (openmm/openmm#4528)

modeller.addSolvent error with residue templates and non-standard residues

I'm working on a system with a non-canonical residue that binds to a cystein residue. I computed a set of parameters for these residues (including the cystein, which has different partial charges compared to the standard CYX residue, but has the same atoms).

Then, I tried to solvate my system. I provided the `Modeller.addSolvent` method a dictionary (where the keys are `Residue`s from my system's `Topology`, and the values are the residue names) using the `residueTemplates` keyword, but it throws an error, saying that there is an ambiguity between my cystein-like residue andthe standard cystein residue (despite the residue templates are provided). The trace points to the `Modeller._addIons` method.

Looking at the code, the `Modeller.addSolvent` method replaces the system's initial `Topology` by a new one before adding ions, which breaks the references to the `Residue` objects stored in the residueTemplates dictionary (as they are pointing to `Residue`s of the `Topology` that were replaced).

Creating a new residueTemplates dictionary while creating the new `Topology` solved the issue and allowed me to solvate and ionize the system. I may submit a PR with this fix if you wish.

## IMPORTANT INSTRUCTIONS

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

diff --git a/wrappers/python/openmm/app/modeller.py b/wrappers/python/openmm/app/modeller.py
index fc30c0dbe5..0e9917bb26 100644
--- a/wrappers/python/openmm/app/modeller.py
+++ b/wrappers/python/openmm/app/modeller.py
@@ -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) 2012-2023 Stanford University and the Authors.
+Portions copyright (c) 2012-2024 Stanford University and the Authors.
 Authors: Peter Eastman
 Contributors:

@@ -856,12 +856,15 @@ def isHbond(d, h, a):
         newTopology.setPeriodicBoxVectors(self.topology.getPeriodicBoxVectors())
         newAtoms = {}
         newPositions = []*nanometer
+        newResidueTemplates = {}
         newIndices = []
         acceptors = [atom for atom in self.topology.atoms() if atom.element in (elem.oxygen, elem.nitrogen)]
         for chain in self.topology.chains():
             newChain = newTopology.addChain(chain.id)
             for residue in chain.residues():
                 newResidue = newTopology.addResidue(residue.name, newChain, residue.id, residue.insertionCode)
+                if residue in residueTemplates:
+                    newResidueTemplates[newResidue] = residueTemplates[residue]
                 isNTerminal = (residue == chain._residues[0])
                 isCTerminal = (residue == chain._residues[-1])
                 if residue.name in Modeller._residueHydrogens:
@@ -1012,8 +1015,7 @@ def isHbond(d, h, a):
         if forcefield is not None:
             # Use the ForceField the user specified.

-            system = forcefield.createSystem(newTopology, rigidWater=False, nonbondedMethod=CutoffNonPeriodic, residueTemplates=residueTemplates)
-            atoms = list(newTopology.atoms())
+            system = forcefield.createSystem(newTopology, rigidWater=False, nonbondedMethod=CutoffNonPeriodic, residueTemplates=newResidueTemplates)
             for i in range(system.getNumParticles()):
                 if i not in addedH:
                     # Existing atom, make it immobile.
@@ -1128,11 +1130,14 @@ def addExtraParticles(self, forcefield, ignoreExternalBonds=False, residueTempla
         newTopology.setPeriodicBoxVectors(self.topology.getPeriodicBoxVectors())
         newAtoms = {}
         newPositions = []*nanometer
+        newResidueTemplates = {}
         missingPositions = set()
         for chain in self.topology.chains():
             newChain = newTopology.addChain(chain.id)
             for residue in chain.residues():
                 newResidue = newTopology.addResidue(residue.name, newChain, residue.id, residue.insertionCode)
+                if residue in residueTemplates:
+                    newResidueTemplates[newResidue] = residueTemplates[residue]
                 template = templates[residue.index]
                 if len(template.atoms) == len(list(residue.atoms())):
                     # Just copy the residue over.
@@ -1233,7 +1238,7 @@ def addExtraParticles(self, forcefield, ignoreExternalBonds=False, residueTempla
             # There were particles whose position we couldn't identify before, since they were neither virtual sites nor Drude particles.
             # Try to figure them out based on bonds.  First, use the ForceField to create a list of every bond involving one of them.

-            system = forcefield.createSystem(newTopology, constraints=AllBonds, residueTemplates=residueTemplates)
+            system = forcefield.createSystem(newTopology, constraints=AllBonds, residueTemplates=newResidueTemplates)
             bonds = []
             for i in range(system.getNumConstraints()):
                 bond = system.getConstraintParameters(i)
@@ -1580,6 +1585,10 @@ def addMembrane(self, forcefield, lipidType='POPC', membraneCenterZ=0*nanometer,
             if len(toDelete) > 0:
                 modeller.delete(toDelete)

+        newResidueTemplates = {}
+        for r1, r2 in zip(self.topology.residues(), modeller.topology.residues()):
+            if r1 in residueTemplates:
+                newResidueTemplates[r2] = residueTemplates[r1]
         self.topology = modeller.topology
         self.positions = modeller.positions

@@ -1620,7 +1629,7 @@ def addMembrane(self, forcefield, lipidType='POPC', membraneCenterZ=0*nanometer,
             if lowerZBoundary < waterZ.value_in_unit(nanometer) < upperZBoundary:
                 del waterPos[wRes]

-        self._addIons(forcefield, numTotalWaters, waterPos, positiveIon=positiveIon, negativeIon=negativeIon, ionicStrength=ionicStrength, neutralize=neutralize, residueTemplates=residueTemplates)
+        self._addIons(forcefield, numTotalWaters, waterPos, positiveIon=positiveIon, negativeIon=negativeIon, ionicStrength=ionicStrength, neutralize=neutralize, residueTemplates=newResidueTemplates)

 class _CellList(object):

diff --git a/wrappers/python/tests/TestModeller.py b/wrappers/python/tests/TestModeller.py
index 16e5837418..a695da8965 100644
--- a/wrappers/python/tests/TestModeller.py
+++ b/wrappers/python/tests/TestModeller.py
@@ -1270,6 +1270,49 @@ def test_bondTypeAndOrderPreserved(self):
             {(None, None), (Single, 1.0), (Double, 2.0)}
         )

+    def test_residueTemplates(self):
+        """Test the residueTemplates argument to Modeller methods"""
+
+        # Create a Topology and ForceField involving residues that match multiple templates.
+
+        topology = Topology()
+        chain = topology.addChain()
+        residue1 = topology.addResidue('Fe', chain)
+        topology.addAtom('Fe', element.iron, residue1)
+        residue2 = topology.addResidue('Fe', chain)
+        topology.addAtom('Fe', element.iron, residue2)
+        positions = [Vec3(0, 0, 0), Vec3(1, 0, 0)]*nanometers
+        ff = ForceField('amber14/tip3pfb.xml', 'amber14/lipid17.xml')
+        residueTemplates = {residue1: 'FE2', residue2: 'FE'}
+
+        # Test addSolvent().
+
+        modeller = Modeller(topology, positions)
+        with self.assertRaises(Exception):
+            modeller.addSolvent(ff, padding=1*nanometer)
+        modeller.addSolvent(ff, padding=1*nanometer, residueTemplates=residueTemplates)
+
+        # Test addHydrogens().
+
+        modeller = Modeller(topology, positions)
+        with self.assertRaises(Exception):
+            modeller.addHydrogens(ff)
+        modeller.addHydrogens(ff, residueTemplates=residueTemplates)
+
+        # Test addExtraParticles().
+
+        modeller = Modeller(topology, positions)
+        with self.assertRaises(Exception):
+            modeller.addExtraParticles(ff)
+        modeller.addExtraParticles(ff, residueTemplates=residueTemplates)
+
+        # Test addMembrane().
+
+        modeller = Modeller(topology, positions)
+        with self.assertRaises(Exception):
+            modeller.addMembrane(ff)
+        modeller.addMembrane(ff, residueTemplates=residueTemplates)
+
     def assertVecAlmostEqual(self, p1, p2, tol=1e-7):
         scale = max(1.0, norm(p1),)
         for i in range(3):


addHydrogens() allows specifying exactly what hydrogens to add (openmm/openmm#4585)

When calling `addHydrogens()`, there is a new option to specify exactly what hydrogens should be present for a residue.  This is needed for some new features I'll be adding to PDBFixer.

The current functionality for adding hydrogens to molecular models does not allow users to specify exactly which hydrogens should be added to a residue. This limitation can be problematic in scenarios where precise control over hydrogen placement is required, such as for specialized modeling tasks or when working with non-standard residues.

The expected behavior is that users should have the option to explicitly define the specific hydrogens to be added to each residue. This would provide greater flexibility and enable more accurate modeling of molecular structures, particularly in cases where default hydrogen placement rules are insufficient or inappropriate.

## 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/devtools/ci/gh-actions/conda-envs/build-M1-arm64.yml b/devtools/ci/gh-actions/conda-envs/build-M1-arm64.yml
index 73cb8a381c..de06102613 100644
--- a/devtools/ci/gh-actions/conda-envs/build-M1-arm64.yml
+++ b/devtools/ci/gh-actions/conda-envs/build-M1-arm64.yml
@@ -10,7 +10,7 @@ dependencies:
 - python
 - cython
 - swig
-- numpy
+- numpy <2.0
 - doxygen 1.9.1
 # test
 - pytest
diff --git a/devtools/ci/gh-actions/conda-envs/build-ubuntu-pypy.yml b/devtools/ci/gh-actions/conda-envs/build-ubuntu-pypy.yml
index b1cbf43e40..5e699075ab 100644
--- a/devtools/ci/gh-actions/conda-envs/build-ubuntu-pypy.yml
+++ b/devtools/ci/gh-actions/conda-envs/build-ubuntu-pypy.yml
@@ -12,7 +12,7 @@ dependencies:
 - pypy
 - cython
 - swig
-- numpy
+- numpy <2.0
 - ocl-icd-system
 - doxygen 1.8.14
 # test
diff --git a/devtools/ci/gh-actions/conda-envs/docs.yml b/devtools/ci/gh-actions/conda-envs/docs.yml
index b36728fc6f..3541ed8e0e 100644
--- a/devtools/ci/gh-actions/conda-envs/docs.yml
+++ b/devtools/ci/gh-actions/conda-envs/docs.yml
@@ -8,7 +8,7 @@ dependencies:
 # host
 - python
 - pip
-- numpy
+- numpy <2.0
 - cython
 - swig
 - doxygen 1.8.14
diff --git a/wrappers/python/openmm/app/modeller.py b/wrappers/python/openmm/app/modeller.py
index 0e9917bb26..7c491cd7c3 100644
--- a/wrappers/python/openmm/app/modeller.py
+++ b/wrappers/python/openmm/app/modeller.py
@@ -736,6 +736,15 @@ def loadHydrogenDefinitions(file):
                     terminal = hydrogen.attrib['terminal']
                 data.hydrogens.append(Modeller._Hydrogen(hydrogen.attrib['name'], hydrogen.attrib['parent'], maxph, atomVariants, terminal))

+    @staticmethod
+    def _loadStandardHydrogenDefinitions():
+        """Load the definitions of hydrogens for standard residues.  Normally there is no need to call this directly.
+        It is automatically called by addHydrogens().  If the definitions have already been loaded, this returns without
+        doing anything."""
+        if not Modeller._hasLoadedStandardHydrogens:
+            Modeller.loadHydrogenDefinitions(os.path.join(os.path.dirname(__file__), 'data', 'hydrogens.xml'))
+            Modeller._hasLoadedStandardHydrogens = True
+
     def addHydrogens(self, forcefield=None, pH=7.0, variants=None, platform=None, residueTemplates=dict()):
         """Add missing hydrogens to the model.

@@ -798,7 +807,11 @@ def addHydrogens(self, forcefield=None, pH=7.0, variants=None, platform=None, re
             length must equal the number of residues in the model.  variants[i]
             is the name of the variant to use for residue i (indexed starting at
             0). If an element is None, the standard rules will be followed to
-            select a variant for that residue.
+            select a variant for that residue.  Alternatively, an element may specify
+            exactly which hydrogens to add.  In that case, variants[i] should be
+            a list of tuples [(name1, parent1), (name2, parent2), ...].  Each
+            tuple specifies the name of a hydrogen and the name of the parent atom
+            it should be bonded to.
         platform : Platform=None
             the Platform to use when computing the hydrogen atom positions.  If
             this is None, the default Platform will be used.
@@ -827,8 +840,7 @@ def addHydrogens(self, forcefield=None, pH=7.0, variants=None, platform=None, re

         # Load the residue specifications.

-        if not Modeller._hasLoadedStandardHydrogens:
-            Modeller.loadHydrogenDefinitions(os.path.join(os.path.dirname(__file__), 'data', 'hydrogens.xml'))
+        Modeller._loadStandardHydrogenDefinitions()

         # Make a list of atoms bonded to each atom.

@@ -867,10 +879,9 @@ def isHbond(d, h, a):
                     newResidueTemplates[newResidue] = residueTemplates[residue]
                 isNTerminal = (residue == chain._residues[0])
                 isCTerminal = (residue == chain._residues[-1])
-                if residue.name in Modeller._residueHydrogens:
+                if residue.name in Modeller._residueHydrogens or isinstance(variants[residue.index], list):
                     # Add hydrogens.  First select which variant to use.

-                    spec = Modeller._residueHydrogens[residue.name]
                     variant = variants[residue.index]
                     if variant is None:
                         if residue.name == 'CYS':
@@ -930,8 +941,14 @@ def isHbond(d, h, a):
                                     variant = 'HID'
                         elif residue.name == 'HIS':
                             variant = 'HIP'
-                    if variant is not None and variant not in spec.variants:
-                        raise ValueError('Illegal variant for %s residue: %s' % (residue.name, variant))
+                    if isinstance(variant, list):
+                        spec = Modeller._ResidueData(residue.name)
+                        infinity = float('Inf')
+                        spec.hydrogens = [Modeller._Hydrogen(name, parent, infinity, None, None) for name, parent in variant]
+                    else:
+                        spec = Modeller._residueHydrogens[residue.name]
+                        if variant is not None and variant not in spec.variants:
+                            raise ValueError('Illegal variant for %s residue: %s' % (residue.name, variant))
                     actualVariants[residue.index] = variant
                     removeExtraHydrogens = (variants[residue.index] is not None)

diff --git a/devtools/ci/gh-actions/conda-envs/build-macos-latest.yml b/devtools/ci/gh-actions/conda-envs/build-macos-latest.yml
index ce3ca8767b..b474f295a9 100644
--- a/devtools/ci/gh-actions/conda-envs/build-macos-latest.yml
+++ b/devtools/ci/gh-actions/conda-envs/build-macos-latest.yml
@@ -10,7 +10,7 @@ dependencies:
 - python
 - cython
 - swig
-- numpy
+- numpy <2.0
 - doxygen 1.8.14
 # test
 - pytest
diff --git a/devtools/ci/gh-actions/conda-envs/build-ubuntu-latest.yml b/devtools/ci/gh-actions/conda-envs/build-ubuntu-latest.yml
index 683875e577..ff7b3a053b 100644
--- a/devtools/ci/gh-actions/conda-envs/build-ubuntu-latest.yml
+++ b/devtools/ci/gh-actions/conda-envs/build-ubuntu-latest.yml
@@ -12,7 +12,7 @@ dependencies:
 - python
 - cython
 - swig
-- numpy
+- numpy <2.0
 - ocl-icd-system
 - doxygen 1.8.14
 # test
diff --git a/devtools/ci/gh-actions/conda-envs/build-windows-latest.yml b/devtools/ci/gh-actions/conda-envs/build-windows-latest.yml
index 2201dc3c6a..b3e477d45c 100644
--- a/devtools/ci/gh-actions/conda-envs/build-windows-latest.yml
+++ b/devtools/ci/gh-actions/conda-envs/build-windows-latest.yml
@@ -12,7 +12,7 @@ dependencies:
 - python
 - cython
 - swig
-- numpy
+- numpy <2.0
 - doxygen 1.8.14
 - khronos-opencl-icd-loader
 # test
diff --git a/wrappers/python/tests/TestModeller.py b/wrappers/python/tests/TestModeller.py
index a695da8965..33d085a715 100644
--- a/wrappers/python/tests/TestModeller.py
+++ b/wrappers/python/tests/TestModeller.py
@@ -1,5 +1,6 @@
 from collections import defaultdict
 import unittest
+import random

 from validateModeller import *
 from openmm.app import *
@@ -980,6 +981,33 @@ def test_addHydrogensGlycam(self):
             names1 = sorted([a.name for a in res1.atoms()])
             names2 = sorted([a.name for a in res2.atoms()])
             self.assertEqual(names1, names2)
+        # Reset the loaded definitions so we don't affect other tests.
+        Modeller._residueHydrogens = {}
+        Modeller._hasLoadedStandardHydrogens = False
+
+    def test_addSpecificHydrogens(self):
+        """Test specifying exactly which hydrogens to add."""
+        pdb = PDBFile('systems/glycopeptide.pdb')
+        variants = [None]*pdb.topology.getNumResidues()
+        for residue in pdb.topology.residues():
+            if residue.name != 'ALA':
+                var = []
+                for atom1, atom2 in residue.bonds():
+                    if atom1.element == element.hydrogen:
+                        var.append((atom1.name, atom2.name))
+                    elif atom2.element == element.hydrogen:
+                        var.append((atom2.name, atom1.name))
+                variants[residue.index] = var
+        modeller = Modeller(pdb.topology, pdb.positions)
+        hydrogens = [a for a in modeller.topology.atoms() if a.element == element.hydrogen and random.random() < 0.7]
+        modeller.delete(hydrogens)
+        self.assertTrue(modeller.topology.getNumAtoms() < pdb.topology.getNumAtoms())
+        modeller.addHydrogens(variants=variants)
+        self.assertEqual(modeller.topology.getNumAtoms(), pdb.topology.getNumAtoms())
+        for res1, res2 in zip(pdb.topology.residues(), modeller.topology.residues()):
+            names1 = sorted([a.name for a in res1.atoms()])
+            names2 = sorted([a.name for a in res2.atoms()])
+            self.assertEqual(names1, names2)

     def test_removeExtraHydrogens(self):
         """Test that addHydrogens() can remove hydrogens that shouldn't be there. """


Fix formal charge reading from pdb files; store formal charges in topology (openmm/openmm#4630)

This pull request fixes some bugs with formal charge handling in pdb files, and also stores the charges in the resulting topologies.

How charges are stored, from the PDB format spec https://www.wwpdb.org/documentation/file-format-content/format33/sect9.html#ATOM

> Columns 79 - 80 indicate any charge on the atom, e.g., 2+, 1-. In most cases, these are blank

The charges are not stored as +1 or -1, rather the sign follows the number.  I have confirmed this using some example files saved from popular software.

The existing code before this patch tries to parse this with `try: self.formal_charge = int(pdb_line[78:80])` which always fails, both for missing charges and for present, correctly formatted charges, because `int('1+')` fails.  The correspnding catch statement sets the charge to None.

The current fix reverses the string before trying to parse it as int.

The second part of this pull request makes it possible to store formal charges in the topology, as follows:
- add an optional formal_charge argument to the Atom constructor
- add an optional formal_charge argument to topology.newAtom()
- pass charges from the PdbStructure to the Topology

Test case, using the attached file:

```
pdb_file = 'formal_charge.pdb'
# check that PdbStructure reads the charges correctly - this always prints None before this patch
from openmm.app.internal.pdbstructure import PdbStructure
fh = open(pdb_file, 'r')
pdb = PdbStructure(fh, load_all_models=True, extraParticleIdentifier='EP')
for chain in pdb.iter_chains():
    for residue in chain.iter_residues():
        for atom in residue.atoms_by_name.values():
            print(atom.get_name(), atom.formal_charge)

# check that Topology gets the charges - this would raise an exception before this patch, because a.formal_charge doesn't exist
import openmm as mm
import openmm.app as app
pdb = app.PDBFile(pdb_file)
for a in topology.atoms():
    print(a, a.formal_charge)
```

### Issue Description

**Problem:**
Formal charges on atoms in PDB files are not being correctly parsed and stored in the topology. According to the PDB format specification, formal charges are represented in columns 79-80 as a number followed by a sign (e.g., "2+", "1-"). The current implementation fails to parse these charges correctly, resulting in formal charges being set to `None` even when they are present and correctly formatted. Additionally, the topology does not store formal charges, which prevents users from accessing this information when working with molecular structures.

**Expected Behavior:**
1. Formal charges in PDB files should be correctly parsed, regardless of their format (e.g., "2+", "1-", "+2", "-1").
2. Parsed formal charges should be stored in the topology, allowing users to access and utilize this information when working with atoms in molecular simulations.
3. The software should handle missing charges gracefully, setting them to `None` without causing errors.

## IMPORTANT INSTRUCTIONS

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

diff --git a/wrappers/python/openmm/app/internal/pdbstructure.py b/wrappers/python/openmm/app/internal/pdbstructure.py
index 3711ee2bfb..aa81838fa4 100644
--- a/wrappers/python/openmm/app/internal/pdbstructure.py
+++ b/wrappers/python/openmm/app/internal/pdbstructure.py
@@ -785,8 +785,15 @@ def __init__(self, pdb_line, pdbstructure=None, extraParticleIdentifier='EP'):
         # segment id, element_symbol, and formal_charge are not always present
         self.segment_id = pdb_line[72:76].strip()
         self.element_symbol = pdb_line[76:78].strip()
-        try: self.formal_charge = int(pdb_line[78:80])
-        except ValueError: self.formal_charge = None
+        try:
+            # formal_charge should always be a one digit followed by a sign, eg "2-", "3+"
+            # this is a bit more permissive, it will also read "-2", "+3", or "3 "
+            formal_charge = pdb_line[78:80]
+            if formal_charge.endswith('+') or formal_charge.endswith('-'):
+                formal_charge = formal_charge[::-1]
+            self.formal_charge = int(formal_charge)
+        except ValueError:
+            self.formal_charge = None
         # figure out atom element
         if self.element_symbol == extraParticleIdentifier:
             self.element = 'EP'
@@ -904,7 +911,7 @@ def _pdb_string(self, serial_number=None, alternate_location_indicator=None):
         end =  "%-4s%2s" % (\
             self.segment_id, self.element_symbol)
         formal_charge = "  "
-        if (self.formal_charge != None): formal_charge = "%+2d" % self.formal_charge
+        if (self.formal_charge is not None): formal_charge = ("%+2d" % self.formal_charge)[::-1]
         return names+numbers+end+formal_charge

     def __str__(self):
diff --git a/wrappers/python/openmm/app/pdbfile.py b/wrappers/python/openmm/app/pdbfile.py
index 626f0ac8dc..b909f52790 100644
--- a/wrappers/python/openmm/app/pdbfile.py
+++ b/wrappers/python/openmm/app/pdbfile.py
@@ -152,7 +152,7 @@ def __init__(self, file, extraParticleIdentifier='EP'):
                                 element = elem.get_by_symbol(upper[0])
                             except KeyError:
                                 pass
-                    newAtom = top.addAtom(atomName, element, r, str(atom.serial_number))
+                    newAtom = top.addAtom(atomName, element, r, str(atom.serial_number), formalCharge=atom.formal_charge)
                     atomByNumber[atom.serial_number] = newAtom
         self._positions = []
         for model in pdb.iter_models(True):
@@ -389,9 +389,13 @@ def writeModel(topology, positions, file=sys.stdout, modelIndex=None, keepIds=Fa
                     else:
                         atomName = atom.name
                     coords = positions[posIndex]
-                    line = "%s%5s %-4s %3s %s%4s%1s   %s%s%s  1.00  0.00          %2s  " % (
+                    if atom.formalCharge is not None:
+                        formalCharge = ("%+2d" % atom.formalCharge)[::-1]
+                    else:
+                        formalCharge = '  '
+                    line = "%s%5s %-4s %3s %s%4s%1s   %s%s%s  1.00  0.00          %2s%2s" % (
                         recordName, _formatIndex(atomIndex, 5), atomName, resName, chainName, resId, resIC, _format_83(coords[0]),
-                        _format_83(coords[1]), _format_83(coords[2]), symbol)
+                        _format_83(coords[1]), _format_83(coords[2]), symbol, formalCharge)
                     if len(line) != 80:
                         raise ValueError('Fixed width overflow detected')
                     print(line, file=file)
diff --git a/wrappers/python/openmm/app/topology.py b/wrappers/python/openmm/app/topology.py
index 46ff9f997f..63d8a2ecde 100644
--- a/wrappers/python/openmm/app/topology.py
+++ b/wrappers/python/openmm/app/topology.py
@@ -166,7 +166,7 @@ def addResidue(self, name, chain, id=None, insertionCode=''):
         chain._residues.append(residue)
         return residue

-    def addAtom(self, name, element, residue, id=None):
+    def addAtom(self, name, element, residue, id=None, formalCharge=None):
         """Create a new Atom and add it to the Topology.

         Parameters
@@ -180,7 +180,8 @@ def addAtom(self, name, element, residue, id=None):
         id : string=None
             An optional identifier for the atom.  If this is omitted, an id is
             generated based on the atom index.
-
+        formalCharge : int=None
+            An optional formal charge for the atom.
         Returns
         -------
         Atom
@@ -190,7 +191,7 @@ def addAtom(self, name, element, residue, id=None):
             raise ValueError('All atoms within a residue must be contiguous')
         if id is None:
             id = str(self._numAtoms+1)
-        atom = Atom(name, element, self._numAtoms, residue, id)
+        atom = Atom(name, element, self._numAtoms, residue, id, formalCharge=formalCharge)
         self._numAtoms += 1
         residue._atoms.append(atom)
         return atom
@@ -452,7 +453,7 @@ def __repr__(self):
 class Atom(object):
     """An Atom object represents an atom within a Topology."""

-    def __init__(self, name, element, index, residue, id):
+    def __init__(self, name, element, index, residue, id, formalCharge=None):
         """Construct a new Atom.  You should call addAtom() on the Topology instead of calling this directly."""
         ## The name of the Atom
         self.name = name
@@ -464,6 +465,8 @@ def __init__(self, name, element, index, residue, id):
         self.residue = residue
         ## A user defined identifier for this Atom
         self.id = id
+        ## An optional formal charge for this Atom
+        self.formalCharge = formalCharge

     def __repr__(self):
         return "<Atom %d (%s) of chain %d residue %d (%s)>" % (self.index, self.name, self.residue.chain.index, self.residue.index, self.residue.name)

diff --git a/wrappers/python/tests/TestPdbFile.py b/wrappers/python/tests/TestPdbFile.py
index 69bdbd901f..430ce3b048 100644
--- a/wrappers/python/tests/TestPdbFile.py
+++ b/wrappers/python/tests/TestPdbFile.py
@@ -105,6 +105,28 @@ def test_AltLocs(self):
             self.assertEqual(19, len(pdb.positions))
             self.assertEqual('ILE', list(pdb.topology.residues())[0].name)

+    def test_FormalCharges(self):
+        """Test reading, and writing and re-reading of a file containing formal charges."""
+        pdb = PDBFile('systems/formal-charges.pdb')
+        for atom in pdb.topology.atoms():
+            if atom.index == 8:
+                self.assertEqual(+1, atom.formalCharge)
+            elif atom.index == 29:
+                self.assertEqual(-1, atom.formalCharge)
+            else:
+                self.assertEqual(None, atom.formalCharge)
+        output = StringIO()
+        PDBFile.writeFile(pdb.topology, pdb.positions, output)
+        input = StringIO(output.getvalue())
+        pdb = PDBFile(input)
+        for atom in pdb.topology.atoms():
+            if atom.index == 8:
+                self.assertEqual(+1, atom.formalCharge)
+            elif atom.index == 29:
+                self.assertEqual(-1, atom.formalCharge)
+            else:
+                self.assertEqual(None, atom.formalCharge)
+
     def test_LargeFile(self):
         """Write and read a file with more than 100,000 atoms"""
         topology = Topology()
diff --git a/wrappers/python/tests/systems/formal-charges.pdb b/wrappers/python/tests/systems/formal-charges.pdb
new file mode 100644
index 0000000000..c78e78e8a8
--- /dev/null
+++ b/wrappers/python/tests/systems/formal-charges.pdb
@@ -0,0 +1,34 @@
+ATOM    302  N   LYS A  39     -21.317   5.001  -3.640  1.00 11.03           N
+ATOM    303  CA  LYS A  39     -22.118   5.903  -2.826  1.00 11.13           C
+ATOM    304  C   LYS A  39     -21.425   6.300  -1.528  1.00 11.27           C
+ATOM    305  O   LYS A  39     -21.979   7.114  -0.782  1.00 12.94           O
+ATOM    306  CB  LYS A  39     -23.466   5.249  -2.510  1.00 11.64           C
+ATOM    307  CG  LYS A  39     -24.271   4.857  -3.735  1.00 13.20           C
+ATOM    308  CD  LYS A  39     -25.586   4.211  -3.325  1.00 14.57           C
+ATOM    309  CE  LYS A  39     -26.389   3.755  -4.533  1.00 17.30           C
+ATOM    310  NZ  LYS A  39     -26.668   4.891  -5.456  1.00 19.28           N1+
+ATOM    311  H   LYS A  39     -21.485   4.006  -3.565  1.00 11.03           H
+ATOM    312  HA  LYS A  39     -22.322   6.821  -3.381  1.00 11.13           H
+ATOM    313  HB3 LYS A  39     -24.066   5.953  -1.933  1.00 11.64           H
+ATOM    314  HB2 LYS A  39     -23.325   4.382  -1.864  1.00 11.64           H
+ATOM    315  HG3 LYS A  39     -23.706   4.154  -4.346  1.00 13.20           H
+ATOM    316  HG2 LYS A  39     -24.439   5.744  -4.347  1.00 13.20           H
+ATOM    317  HD3 LYS A  39     -26.177   4.910  -2.731  1.00 14.57           H
+ATOM    318  HD2 LYS A  39     -25.393   3.350  -2.682  1.00 14.57           H
+ATOM    319  HE3 LYS A  39     -27.335   3.320  -4.210  1.00 17.30           H
+ATOM    320  HE2 LYS A  39     -25.852   2.977  -5.078  1.00 17.30           H
+ATOM    321  HZ1 LYS A  39     -25.793   5.240  -5.824  1.00 19.28           H
+ATOM    322  HZ2 LYS A  39     -27.244   4.571  -6.222  1.00 19.28           H
+ATOM    323  HZ3 LYS A  39     -27.147   5.629  -4.959  1.00 19.28           H
+ATOM    324  N   ASP A  40     -20.235   5.756  -1.257  1.00 10.36           N
+ATOM    325  CA  ASP A  40     -19.478   5.992  -0.027  1.00 10.11           C
+ATOM    326  C   ASP A  40     -18.075   6.451  -0.422  1.00 10.88           C
+ATOM    327  O   ASP A  40     -17.085   5.743  -0.201  1.00 11.09           O
+ATOM    328  CB  ASP A  40     -19.441   4.709   0.809  1.00  9.45           C
+ATOM    329  CG  ASP A  40     -18.542   4.809   2.035  1.00  9.80           C
+ATOM    330  OD1 ASP A  40     -18.434   5.917   2.620  1.00 10.02           O
+ATOM    331  OD2 ASP A  40     -17.966   3.752   2.401  1.00  9.81           O1-
+ATOM    332  H   ASP A  40     -19.836   5.109  -1.925  1.00 10.36           H
+ATOM    333  HA  ASP A  40     -19.938   6.774   0.577  1.00 10.11           H
+ATOM    334  HB3 ASP A  40     -19.104   3.871   0.196  1.00  9.45           H
+ATOM    335  HB2 ASP A  40     -20.447   4.458   1.147  1.00  9.45           H


Serializing CustomIntegrator preserves order of global variables (openmm/openmm#4641)

Fixes #4637.

When serializing a CustomIntegrator, it recorded the global variables as attributes on a single XML tag.  That doesn't preserve order, so when you reloaded it, the global variables would come out in alphabetical order.  It now uses separate tags so the order is preserved.

Deepcopying `AMDIntegrator` swaps values of global parameters

Making a deepcopy of an `AMDIntegrator` swaps the values of the global parameters (alpha and E).
```
from openmm import AMDIntegrator
amd = AMDIntegrator(0.001, 0.0, 1.0)
amd.getAlpha()
amd.getE()
```
prints 0.0, 1.0.
```
from copy import deepcopy
amd2 = deepcopy(amd)
amd2.getAlpha()
amd2.getE()
```
prints 1.0, 0.0.

I have tried reproducing the behavior with another `CustomIntegrator`, but with no luck. Could the issue be related to the names of the global parameters?

## IMPORTANT INSTRUCTIONS

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

diff --git a/serialization/src/CustomIntegratorProxy.cpp b/serialization/src/CustomIntegratorProxy.cpp
index b1ecf26fd2..86ab647835 100644
--- a/serialization/src/CustomIntegratorProxy.cpp
+++ b/serialization/src/CustomIntegratorProxy.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:                                                              *
  *                                                                            *
@@ -36,23 +36,20 @@ using namespace std;
 using namespace OpenMM;

 CustomIntegratorProxy::CustomIntegratorProxy() : SerializationProxy("CustomIntegrator") {
-
 }

 void CustomIntegratorProxy::serialize(const void* object, SerializationNode& node) const {
-    node.setIntProperty("version", 2);
+    node.setIntProperty("version", 3);
     const CustomIntegrator& integrator = *reinterpret_cast<const CustomIntegrator*>(object);
     SerializationNode& globalVariablesNode = node.createChildNode("GlobalVariables");
-    for (int i = 0; i < integrator.getNumGlobalVariables(); i++) {
-        globalVariablesNode.setDoubleProperty(integrator.getGlobalVariableName(i), integrator.getGlobalVariable(i));
-    }
+    for (int i = 0; i < integrator.getNumGlobalVariables(); i++)
+        globalVariablesNode.createChildNode("Variable").setStringProperty("name", integrator.getGlobalVariableName(i)).setDoubleProperty("value", integrator.getGlobalVariable(i));
     SerializationNode& perDofVariablesNode = node.createChildNode("PerDofVariables");
     for (int i = 0; i < integrator.getNumPerDofVariables(); i++) {
         SerializationNode& perDofValuesNode = perDofVariablesNode.createChildNode(integrator.getPerDofVariableName(i));
         vector<Vec3> perDofValues; integrator.getPerDofVariable(i, perDofValues);
-        for (int j = 0; j < perDofValues.size(); j++) {
-            perDofValuesNode.createChildNode("Value").setDoubleProperty("x",perDofValues[j][0]).setDoubleProperty("y",perDofValues[j][1]).setDoubleProperty("z",perDofValues[j][2]);
-        }
+        for (int j = 0; j < perDofValues.size(); j++)
+            perDofValuesNode.createChildNode("Value").setDoubleProperty("x", perDofValues[j][0]).setDoubleProperty("y", perDofValues[j][1]).setDoubleProperty("z", perDofValues[j][2]);
     }
     SerializationNode& computationsNode = node.createChildNode("Computations");
     for (int i = 0; i < integrator.getNumComputations(); i++) {
@@ -60,26 +57,30 @@ void CustomIntegratorProxy::serialize(const void* object, SerializationNode& nod
         string computationVariable;
         string computationExpression;
         integrator.getComputationStep(i, computationType, computationVariable, computationExpression);
-        computationsNode.createChildNode("Computation").setIntProperty("computationType",static_cast<int>(computationType))
-            .setStringProperty("computationVariable",computationVariable).setStringProperty("computationExpression",computationExpression);
+        computationsNode.createChildNode("Computation").setIntProperty("computationType", static_cast<int>(computationType))
+            .setStringProperty("computationVariable", computationVariable).setStringProperty("computationExpression", computationExpression);
     }
     SerializationNode& functions = node.createChildNode("Functions");
     for (int i = 0; i < integrator.getNumTabulatedFunctions(); i++)
         functions.createChildNode("Function", &integrator.getTabulatedFunction(i)).setStringProperty("name", integrator.getTabulatedFunctionName(i));
-    node.setStringProperty("kineticEnergyExpression",integrator.getKineticEnergyExpression());
-    node.setIntProperty("randomSeed",integrator.getRandomNumberSeed());
-    node.setDoubleProperty("stepSize",integrator.getStepSize());
-    node.setDoubleProperty("constraintTolerance",integrator.getConstraintTolerance());
+    node.setStringProperty("kineticEnergyExpression", integrator.getKineticEnergyExpression());
+    node.setIntProperty("randomSeed", integrator.getRandomNumberSeed());
+    node.setDoubleProperty("stepSize", integrator.getStepSize());
+    node.setDoubleProperty("constraintTolerance", integrator.getConstraintTolerance());
 }

 void* CustomIntegratorProxy::deserialize(const SerializationNode& node) const {
     int version = node.getIntProperty("version");
-    if (version < 1 || version > 2)
+    if (version < 1 || version > 3)
         throw OpenMMException("Unsupported version number");
     CustomIntegrator* integrator = new CustomIntegrator(node.getDoubleProperty("stepSize"));
     const SerializationNode& globalVariablesNode = node.getChildNode("GlobalVariables");
-    for (auto& prop : globalVariablesNode.getProperties())
-        integrator->addGlobalVariable(prop.first, globalVariablesNode.getDoubleProperty(prop.first));
+    if (version < 3)
+        for (auto& prop : globalVariablesNode.getProperties())
+            integrator->addGlobalVariable(prop.first, globalVariablesNode.getDoubleProperty(prop.first));
+    else
+        for (auto& var : globalVariablesNode.getChildren())
+            integrator->addGlobalVariable(var.getStringProperty("name"), var.getDoubleProperty("value"));
     const SerializationNode& perDofVariablesNode = node.getChildNode("PerDofVariables");
     int count = 0;
     for (auto& var : perDofVariablesNode.getChildren()) {

diff --git a/serialization/tests/TestSerializeIntegrator.cpp b/serialization/tests/TestSerializeIntegrator.cpp
index d24fe4f74d..2663de097a 100644
--- a/serialization/tests/TestSerializeIntegrator.cpp
+++ b/serialization/tests/TestSerializeIntegrator.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:                                                              *
  *                                                                            *
@@ -154,8 +154,8 @@ void testSerializeCustomIntegrator() {
     intg->addGlobalVariable("oute", 0);
     intg->addGlobalVariable("oute1", 0);
     intg->addGlobalVariable("oute2", 0);
-    intg->addGlobalVariable("oute3_conditional_v1", 0);// HACK: need addGlobals to be alphabetical to work around bug
     intg->addGlobalVariable("oute3_conditional_v2", 0);
+    intg->addGlobalVariable("oute3_conditional_v1", 0);
     intg->addComputePerDof("outf", "f");
     intg->addComputePerDof("outf1", "f1");
     intg->addComputePerDof("outf2", "f2");