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

org: openmm
repo: openmm
pr_number: 4585
issue_title: addHydrogens() allows specifying exactly what hydrogens to add
issue_body: 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

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