Skip to content

Commit 6d16a80

Browse files
authored
Support for generic and potential-specific customizable embeddings (#152)
* Initial implementation of customizable embedding framework * Add tests for mechanical embedding, make minor corrections * Change handling of mlLongRange option and implement for potentials * Correction * Specify mlLongRange in nequip test * Add documentation on mechanical embedding to user guide * Renaming MLEmbeddingImpl to Embedding * Changes from suggestions and docs update
1 parent a0b60ad commit 6d16a80

17 files changed

Lines changed: 860 additions & 190 deletions

doc/api.rst.jinja2

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,27 @@ Developer API
1616

1717
openmmml.mlpotential.MLPotentialImplFactory
1818
openmmml.mlpotential.MLPotentialImpl
19+
openmmml.mlpotential.EmbeddingFactory
20+
openmmml.mlpotential.Embedding
1921

2022

2123
Models
2224
~~~~~~
2325
.. autosummary::
2426
:toctree: generated/
2527
:nosignatures:
26-
28+
2729
{% for model in models %}
2830
{{ model }}
2931
{% endfor %}
32+
33+
34+
Embeddings
35+
~~~~~~~~~~
36+
.. autosummary::
37+
:toctree: generated/
38+
:nosignatures:
39+
40+
{% for embedding in embeddings %}
41+
{{ embedding }}
42+
{% endfor %}

doc/render.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,24 @@ def models_template_variables():
2020
"""Create the data structure available to the Jinja2 renderer when
2121
filling in the templates.
2222
23-
This function extracts all of classes in ``openmmml.models`` and returns
24-
a list of them
23+
This function extracts all of classes in ``openmmml.models`` and
24+
``openmmml.embeddings`` and returns a list of them
2525
"""
2626
data = {
2727
'models': [],
28+
'embeddings': [],
2829
}
2930

3031
for _, module in inspect.getmembers(openmmml.models, predicate=inspect.ismodule):
3132
for name, obj in inspect.getmembers(module, predicate=inspect.isclass):
3233
if issubclass(obj, openmmml.mlpotential.MLPotentialImpl) and obj != openmmml.mlpotential.MLPotentialImpl:
3334
data['models'].append(fullname(obj))
3435

36+
for _, module in inspect.getmembers(openmmml.embeddings, predicate=inspect.ismodule):
37+
for name, obj in inspect.getmembers(module, predicate=inspect.isclass):
38+
if issubclass(obj, openmmml.mlpotential.Embedding) and obj != openmmml.mlpotential.Embedding:
39+
data['embeddings'].append(fullname(obj))
40+
3541
return data
3642

3743

@@ -51,4 +57,4 @@ def main():
5157

5258

5359
if __name__ == '__main__':
54-
main()
60+
main()

doc/userguide.md

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -330,10 +330,42 @@ When using ASE models, the following extra keyword arguments to `createSystem()`
330330
| `aseAtoms` | An Atoms object to use for computations. |
331331
| `info` | Values that should be added to the `info` dict of the Atoms object. |
332332

333-
### Other Packages
333+
## Embeddings
334+
335+
For mixed ML/MM systems created with `createMixedSystem()`, the interactions within the ML subset will be computed by
336+
the selected MLIP, and the interactions within the MM subset will be computed by the MM force field. However, OpenMM-ML
337+
offers various *embedding methods* that controls how the interactions between the ML and MM atoms are computed.
338+
339+
By default, OpenMM-ML uses mechanical embedding, but this can be selected with the `embedding` argument to
340+
`createMixedSystem()`. Embedding methods can either be generic methods available for use along with any MLIP (described
341+
below), or they can be specific to certain MLIPs. You can retrieve the names of all acceptable embedding methods (both
342+
generic and specific) for a given MLIP by calling `MLPotential.getSupportedEmbeddings()`. Consult the API documentation
343+
for more information about how the embedding API works internally and how custom embedding methods can be implemented.
344+
345+
### Mechanical Embedding
346+
347+
This default embedding method can also be explicitly selected with the embedding name `mechanical`. Mechanical
348+
embedding uses the MM force field to compute the interactions between the ML and MM atoms.
349+
350+
For periodic ML/MM systems, some MLIPs may compute the interactions between ML atoms including all periodic images;
351+
OpenMM-ML calls these models "long-range". Other MLIPs may compute only the interactions between ML atoms in a single
352+
periodic image. In this case, the interaction between the ML subset and all of its other periodic images is computed
353+
using the MM force field when using mechanical embedding.
354+
355+
For the pretrained models supported by OpenMM-ML, this behavior is selected automatically. However, for custom models
356+
(*e.g.*, the ASE, DeePMD, and NequIP interfaces, and non-pretrained FeNNix, MACE, and TorchMDNet models) it is necessary
357+
to specify which behavior your model uses when doing mechanical embedding in a periodic system. To do so, pass
358+
`mlLongRange=False` to `createMixedSystem()` if your model is not long-range, and `mlLongRange=True` if it is. An error
359+
will be raised to inform you if this information is needed and not provided; OpenMM-ML will not assume either choice
360+
automatically.
361+
362+
## Other Packages
334363

335364
OpenMM-ML is based on a plugin architecture, allowing other packages to provide their own interfaces to it. The
336-
packages listed above are the ones for which OpenMM-ML has built in support. Other packages can interface to it by
365+
packages listed above are the ones for which OpenMM-ML has built in support. Other packages can provide potentials by
337366
defining two classes that subclass `MLPotentialImpl` and `MLPotentialImplFactory`, then registering them by specifying
338367
an [entry point](https://packaging.python.org/en/latest/specifications/entry-points/) in the group `openmmml.potentials`.
339-
Consult the documentation for other packages to see whether they provide interfaces for OpenMM-ML.
368+
They can also provide generic embedding methods (that can be used with any potential from OpenMM-ML or any other package)
369+
by similarly defining subclasses of `Embedding` and `EmbeddingFactory`, and registering them with an entry point in the
370+
group `openmmml.embeddings`. Consult the documentation for other packages to see whether they provide interfaces for
371+
OpenMM-ML.

openmmml/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
from .mlpotential import MLPotential
2-
from . import models
2+
from . import embeddings, models

openmmml/embeddings/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import mechanicalembedding
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
"""
2+
mechanicalembedding.py: Implements mechanical embedding.
3+
4+
This is part of the OpenMM molecular simulation toolkit originating from
5+
Simbios, the NIH National Center for Physics-Based Simulation of
6+
Biological Structures at Stanford, funded under the NIH Roadmap for
7+
Medical Research, grant U54 GM072970. See https://simtk.org.
8+
9+
Portions copyright (c) 2026 Stanford University and the Authors.
10+
Authors: Peter Eastman, Evan Pretti
11+
Contributors:
12+
13+
Permission is hereby granted, free of charge, to any person obtaining a
14+
copy of this software and associated documentation files (the "Software"),
15+
to deal in the Software without restriction, including without limitation
16+
the rights to use, copy, modify, merge, publish, distribute, sublicense,
17+
and/or sell copies of the Software, and to permit persons to whom the
18+
Software is furnished to do so, subject to the following conditions:
19+
20+
The above copyright notice and this permission notice shall be included in
21+
all copies or substantial portions of the Software.
22+
23+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
26+
THE AUTHORS, CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
27+
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
28+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
29+
USE OR OTHER DEALINGS IN THE SOFTWARE.
30+
"""
31+
32+
from openmmml.mlpotential import MLPotential, MLPotentialImpl, Embedding, EmbeddingFactory
33+
import openmm
34+
import openmm.app
35+
import openmm.unit as unit
36+
from copy import deepcopy
37+
38+
class MechanicalEmbeddingFactory(EmbeddingFactory):
39+
"""This is the factory that creates MechanicalEmbedding objects."""
40+
41+
def createEmbedding(self, name: str, **args) -> Embedding:
42+
43+
# name should always be "mechanical" for this plugin.
44+
45+
return MechanicalEmbedding()
46+
47+
48+
class MechanicalEmbedding(Embedding):
49+
"""Mechanical embedding. This is the default embedding method, and uses the
50+
conventional force field to compute the interactions between the atoms
51+
within the ML subset and those outside of it.
52+
53+
If long-range electrostatics are in use by the conventional force field but
54+
not supported by the ML model, the conventional force field charges will be
55+
used to approximate the interactions between atoms within the ML subset in
56+
different periodic images. For some models, this cannot be determined based
57+
on the information provided to OpenMM-ML, and the mlLongRange option must be
58+
provided explicitly to control whether or not to include these interactions.
59+
60+
This implementation assumes that if a model reports using long-range
61+
interactions, or mlLongRange is True, then the long-range interactions that
62+
the model computes are purely electrostatic (Ewald/PME), not LJPME.
63+
"""
64+
65+
def __init__(self):
66+
pass
67+
68+
def createMixedSystem(self,
69+
potential: MLPotentialImpl,
70+
topology: openmm.app.Topology,
71+
system: openmm.System,
72+
atoms: list[int],
73+
forceGroup: int,
74+
interpolate: bool,
75+
**args) -> openmm.System:
76+
77+
periodic = system.usesPeriodicBoundaryConditions()
78+
79+
# See if the ML potential uses long-range interactions. mlLongRange may
80+
# end up being None, in which case we were unable to determine this.
81+
82+
potentialMLLongRange = potential.getMLLongRange()
83+
userMLLongRange = args.get("mlLongRange", None)
84+
if potentialMLLongRange is None:
85+
mlLongRange = userMLLongRange
86+
else:
87+
if userMLLongRange is not None:
88+
raise ValueError("This ML model does not support the mlLongRange option.")
89+
mlLongRange = potentialMLLongRange
90+
91+
# See if the MM force field uses long-range interactions.
92+
93+
mmLongRange = False
94+
mmLongRangeForce = None
95+
for force in system.getForces():
96+
if isinstance(force, openmm.NonbondedForce):
97+
if force.getNonbondedMethod() in (openmm.NonbondedForce.Ewald, openmm.NonbondedForce.PME, openmm.NonbondedForce.LJPME):
98+
mmLongRange = True
99+
if mmLongRangeForce is not None:
100+
raise ValueError("Multiple long-range NonbondedForce objects encountered.")
101+
mmLongRangeForce = force
102+
break
103+
104+
excludeLongRange = False
105+
106+
if periodic:
107+
108+
# For a non-periodic system, we will always use exceptions for the
109+
# ML subset. Otherwise, we need to know whether or not the ML
110+
# potential is long-range.
111+
112+
if mlLongRange is None:
113+
raise ValueError("The system is periodic and it is unknown if the ML model uses long-range interactions; provide the mlLongRange option to specify.")
114+
115+
# We don't support the case where the MM force field is not
116+
# long-range but the ML potential is.
117+
118+
if mlLongRange:
119+
if not mmLongRange:
120+
raise ValueError("The system is periodic and the ML model uses long-range interactions while the MM force field does not.")
121+
122+
excludeLongRange = True
123+
124+
# Create the new system with ML-ML interactions to be computed by the ML
125+
# potential removed.
126+
127+
newSystem = MLPotential._removeBonds(system, atoms, True)
128+
129+
for force in newSystem.getForces():
130+
if isinstance(force, openmm.NonbondedForce):
131+
# All of the LJ interactions in the ML region should be zeroed.
132+
charges = [force.getParticleParameters(atom)[0] for atom in atoms]
133+
for iAtom1 in range(len(atoms)):
134+
for iAtom2 in range(iAtom1):
135+
136+
# If the ML region electrostatics should be excluded in
137+
# long-range, keep this exception's charge product set
138+
# to the true product of the charges since the energy
139+
# for this pair will be subtracted later. If not, set
140+
# it to zero here.
141+
142+
if excludeLongRange:
143+
chargeProd = charges[iAtom1] * charges[iAtom2]
144+
else:
145+
chargeProd = 0
146+
force.addException(atoms[iAtom1], atoms[iAtom2], chargeProd, 1, 0, True)
147+
148+
# This may cause exceptions in the MM region to use PBCs, but
149+
# this should not ordinarily have any significant effects.
150+
151+
force.setExceptionsUsePeriodicBoundaryConditions(periodic)
152+
153+
elif isinstance(force, openmm.CustomNonbondedForce):
154+
existing = set(tuple(force.getExclusionParticles(i)) for i in range(force.getNumExclusions()))
155+
for iAtom1 in range(len(atoms)):
156+
atom1 = atoms[iAtom1]
157+
for iAtom2 in range(iAtom1):
158+
atom2 = atoms[iAtom2]
159+
if (atom1, atom2) not in existing and (atom2, atom1) not in existing:
160+
force.addExclusion(atom1, atom2)
161+
162+
if excludeLongRange:
163+
# Prepare a force to calculate the PME energy of the ML-ML region.
164+
165+
excludeForce = openmm.NonbondedForce()
166+
excludeForce.setCutoffDistance(mmLongRangeForce.getCutoffDistance())
167+
excludeForce.setEwaldErrorTolerance(mmLongRangeForce.getEwaldErrorTolerance())
168+
excludeForce.setNonbondedMethod(openmm.NonbondedForce.PME)
169+
excludeForce.setPMEParameters(*mmLongRangeForce.getPMEParameters())
170+
171+
atomSet = set(atoms)
172+
for atom in range(newSystem.getNumParticles()):
173+
excludeForce.addParticle(mmLongRangeForce.getParticleParameters(atom)[0] if atom in atomSet else 0, 1, 0)
174+
175+
if interpolate:
176+
# Set up a CV force to do the interpolation.
177+
178+
cvForce = openmm.CustomCVForce("")
179+
cvForce.addGlobalParameter("lambda_interpolate", 1)
180+
181+
mlTermNames = []
182+
def addMLTerm(force):
183+
name = f"mlTerm{len(mlTermNames)}"
184+
cvForce.addCollectiveVariable(name, force)
185+
mlTermNames.append(name)
186+
187+
mmTermNames = []
188+
def addMMTerm(force):
189+
name = f"mmTerm{len(mmTermNames)}"
190+
cvForce.addCollectiveVariable(name, force)
191+
mmTermNames.append(name)
192+
193+
# Add the ML potential.
194+
195+
tempSystem = openmm.System()
196+
potential.addForces(topology, tempSystem, atoms, forceGroup, **args)
197+
for force in tempSystem.getForces():
198+
addMLTerm(deepcopy(force))
199+
200+
# Add back the bonds that were removed.
201+
202+
bondedSystem = MLPotential._removeBonds(system, atoms, False)
203+
for force in bondedSystem.getForces():
204+
if hasattr(force, "addBond") or hasattr(force, "addAngle") or hasattr(force, "addTorsion"):
205+
addMMTerm(deepcopy(force))
206+
207+
# Add in terms corresponding to the exceptions set earlier.
208+
209+
for force in system.getForces():
210+
if isinstance(force, openmm.NonbondedForce):
211+
replacementForce = openmm.CustomBondForce("138.935456*chargeProd/r + 4*epsilon*((sigma/r)^12-(sigma/r)^6)")
212+
replacementForce.setUsesPeriodicBoundaryConditions(periodic)
213+
replacementForce.addPerBondParameter("chargeProd")
214+
replacementForce.addPerBondParameter("sigma")
215+
replacementForce.addPerBondParameter("epsilon")
216+
217+
atomCharge, atomSigma, atomEpsilon = zip(*(force.getParticleParameters(atom) for atom in atoms))
218+
exceptions = {}
219+
for i in range(force.getNumExceptions()):
220+
atom1, atom2, chargeProd, sigma, epsilon = force.getExceptionParameters(i)
221+
exceptions[atom1, atom2] = (chargeProd, sigma, epsilon)
222+
def getParameters(iAtom1, iAtom2):
223+
atom1 = atoms[iAtom1]
224+
atom2 = atoms[iAtom2]
225+
if (atom1, atom2) in exceptions:
226+
return exceptions[atom1, atom2]
227+
if (atom2, atom1) in exceptions:
228+
return exceptions[atom2, atom1]
229+
return (
230+
atomCharge[iAtom1] * atomCharge[iAtom2],
231+
0.5 * (atomSigma[iAtom1] + atomSigma[iAtom2]),
232+
unit.sqrt(atomEpsilon[iAtom1] * atomEpsilon[iAtom2]),
233+
)
234+
235+
for iAtom1 in range(len(atoms)):
236+
for iAtom2 in range(iAtom1):
237+
chargeProd, sigma, epsilon = getParameters(iAtom1, iAtom2)
238+
if excludeLongRange:
239+
chargeProd -= atomCharge[iAtom1] * atomCharge[iAtom2]
240+
if chargeProd._value != 0 or epsilon._value != 0:
241+
replacementForce.addBond(atoms[iAtom1], atoms[iAtom2], [chargeProd, sigma, epsilon])
242+
243+
addMMTerm(replacementForce)
244+
245+
elif isinstance(force, openmm.CustomNonbondedForce):
246+
# TODO: this case has never been supported (previously it
247+
# would be silently ignored).
248+
raise NotImplementedError("Interpolation is currently unsupported when a CustomNonbondedForce is present")
249+
250+
# Subtract the ML-ML PME energy if needed.
251+
252+
if excludeLongRange:
253+
mlTermNames.append("(-excludeForce)")
254+
cvForce.addCollectiveVariable("excludeForce", excludeForce)
255+
256+
# Build the expression to do the interpolation.
257+
258+
cvTerms = []
259+
if mlTermNames:
260+
mlSum = "+".join(mlTermNames)
261+
cvTerms.append(f"lambda_interpolate*({mlSum})")
262+
if mmTermNames:
263+
mmSum = "+".join(mmTermNames)
264+
cvTerms.append(f"(1-lambda_interpolate)*({mmSum})")
265+
cvForce.setEnergyFunction("+".join(cvTerms) if cvTerms else "0")
266+
newSystem.addForce(cvForce)
267+
268+
else:
269+
# Add the ML potential and subtract the ML-ML PME energy if needed.
270+
271+
if excludeLongRange:
272+
cvForce = openmm.CustomCVForce("-excludeForce")
273+
cvForce.addCollectiveVariable("excludeForce", excludeForce)
274+
newSystem.addForce(cvForce)
275+
276+
potential.addForces(topology, newSystem, atoms, forceGroup, **args)
277+
278+
return newSystem

0 commit comments

Comments
 (0)