Repository: openmm/openmm — pull request #4585 (cpp)
Title: addHydrogens() allows specifying exactly what hydrogens to add

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.

Resolved issues:
title: addHydrogens() allows specifying exactly what hydrogens to add
body: 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

Hints:

Fix patch:
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)
 


Test patch:
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. """