|
| 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