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

org: openmm
repo: openmm
pr_number: 4528
issue_title: Fixed errors with residueTemplates arguments to Modeller methods
issue_body: Fixes #4524.
resolved_issues: number: 4524
title: modeller.addSolvent error with residue templates and non-standard residues
body: 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

Reference solution:
fix_patch: 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):