Creating custom interactions via plugins
Custom interactions (i.e., interactions other than 12-6 Lennard-Jones, harmonic bonds, or harmonic angles) can be introduced into the OpenFF stack via plugin interfaces. To create a plugin, create a subclass(s) of both ParameterHandler
and SMIRNOFFCollection
for each new type of physical interaction in the model, then expose them to Interchange via the openff.toolkit.plugins.handlers
and openff.interchange.plugins.collections
SetupTools entry points, respectively.
Parameter handlers are responsible for the system- and engine-independent aspects of parametrization; in other words, they define the force field itself. By contrast, a SMIRNOFF collection handles the parametrization of a particular system and the preparation of that system for simulation with an external MD engine. The base classes for parameter handlers are defined in the OpenFF Toolkit, while collections are defined in Interchange. Most use cases involve one parameter handler and one collection, though a collection can process multiple parameter handlers.
Currently, systems using custom interactions can only be exported to OpenMM. There are two routes for this export: one using existing Interchange machinery and another that allows for completely custom behavior.
Creating a custom ParameterHandler
There is no formal specification for a ParameterHandler
as processed by the OpenFF Toolkit, but there are some guidelines that are expected to be followed. A parameter handler requires both a ParameterHandler
class, which performs parametrization, and a ParameterType
class, which stores the corresponding data. For more information, see the Toolkit documentation.
The high-level objectives of a parameter handler are to:
store force field parameters
assign parameters via SMIRKS
enable construction and modification of parameters via the Python API
enable serialization and deserialization of parameters
The handler class should
inherit from
ParameterHandler
be available in the
openff.toolkit.plugins.handlers
entry point groupcontain a class that inherits from
ParameterType
(a “type class”)define a class attribute
_TAGNAME: str
that is used for serialization and handy lookupsdefine a class attribute
_INFOTYPE
that specifies the handler’s “type class”
The handler class may
define a method
check_handler_compatibility
that is called when combining instances of this classdefine other attributes for specific behavior, i.e.
scale14
for non-bonded interactionsoverride methods like
__init__
for custom behavior if required (this should be avoided if possible)
The “type class” should
inherit from
ParameterType
define a class attribute
_ELEMENT_NAME: str
used for serializationinclude attributes defining each numerical value associated with a single parameter
Each of these attributes should be tagged with units and have default values (which can be
None
)
The “type class” may
override methods like
__init__
for custom behavior if required (this should be avoided if possible)define its own properties, class methods, etc. as needed
define optional attributes such as
id
Creating a custom SMIRNOFFCollection
There is similarly no specification for these plugins yet, but some guidelines that should be followed.
A SMIRNOFFCollection
is a subclass of Collection
specific to SMIRNOFF force fields. The objectives of a collection are to:
store a system’s parameters, as assigned by SMIRKS-based typing of chemical topologies
provide an interface for exporting these parameters to MD engines (i.e. creating an
openmm.Force
)The class should
inherit from
SMIRNOFFCollection
and therefore be written in the style of a Pydantic model.define a field
is_plugin: bool = True
be available in the
openff.interchange.plugins.collections
entry point groupdefine a field
type: str
, which we suggest match the correspondingParameterHandler._TAGNAME
define a field
expression: str
, a mathematical formula defining how it contributes to the overall potential energydefine a class method
allowed_parameter_handlers
that returns an iterable of theParameterHandler
subclasses that it can processdefine a class method
supported_parameters
that returns an iterable of parameters that it expects to store (i.e."smirks"
,"k"
,"length"
, etc.)override other methods of
SMIRNOFFCollection
andCollection
(store_matches
,create
) as neededThe argument
parameter_handler
tocreate
can either be of typeParameterHandler
orList[ParameterHandler]
. If the collection can take multiple handlers (a case probably associated withallowed_parameter_handlers
returning an iterable longer than 1) thencreate
should assume the argument is a list containing multiple handlers. Otherwise, it will be passed a single handler.
The class may
define other optional fields, similar to optional attributes on its corresponding parameter handler
define other methods and fields as needed
Examples
Bootstrapping existing Interchange machinery
For example, here are two custom handlers. One defines a Buckingham potential which can be used in place of a 12-6 Lennard-Jones potential.
from openff.toolkit.typing.engines.smirnoff.parameters import (
ParameterHandler,
ParameterType,
ParameterAttribute,
_allow_only,
unit,
)
class BuckinghamHandler(ParameterHandler):
class BuckinghamType(ParameterType):
_VALENCE_TYPE = "Atom"
_ELEMENT_NAME = "Atom"
a = ParameterAttribute(default=None, unit=unit.kilojoule_per_mole)
b = ParameterAttribute(default=None, unit=unit.nanometer**-1)
c = ParameterAttribute(
default=None,
unit=unit.kilojoule_per_mole * unit.nanometer**6,
)
_TAGNAME = "Buckingham"
_INFOTYPE = BuckinghamType
scale12 = ParameterAttribute(default=0.0, converter=float)
scale13 = ParameterAttribute(default=0.0, converter=float)
scale14 = ParameterAttribute(default=0.5, converter=float)
scale15 = ParameterAttribute(default=1.0, converter=float)
cutoff = ParameterAttribute(default=9.0 * unit.angstroms, unit=unit.angstrom)
switch_width = ParameterAttribute(default=1.0 * unit.angstroms, unit=unit.angstrom)
method = ParameterAttribute(
default="cutoff",
converter=_allow_only(["cutoff", "PME"]),
)
combining_rules = ParameterAttribute(
default="Lorentz-Berthelot",
converter=_allow_only(["Lorentz-Berthelot"]),
)
Notice that
BuckinghamHandler
(the “handler class”) is a subclass ofParameterHandler
BuckinghamType
(the “type class”)is a subclass of
ParameterType
defines
"Atom"
as its_VALENCE_TYPE
, or chemical environmentdefines
"Atom"
as its_ELEMENT_TYPE
, which defines how it is serializedhas unit-tagged attributes
a
,b
, andc
, corresponding to particular values for each parameter
the handler class also
defines a
_TAGNAME
for lookup and serializationlinkts itself with
BuckinghamType
via its_INFOTYPE
includes several optional attributes that are used in non-bonded interactions
scale12
throughscale15
the cutoff distance
the switch width
the method used to compute these interactions
the combining rules (which will not be used with water)
From here we can instantiate this handler inside of a ForceField
object, add some parameters (here, dummy values for water), and serialize it out to disk:
from openff.toolkit import ForceField, Molecule
handler = BuckinghamHandler(version="0.3")
handler.add_parameter(
{
"smirks": "[#1:1]-[#8X2H2+0]-[#1]",
"a": 1.0 * unit.kilojoule_per_mole,
"b": 2.0 / unit.nanometer,
"c": 3.0 * unit.kilojoule_per_mole * unit.nanometer**6,
}
)
handler.add_parameter(
{
"smirks": "[#1]-[#8X2H2+0:1]-[#1]",
"a": 4.0 * unit.kilojoule_per_mole,
"b": 4.0 / unit.nanometer,
"c": 4.0 * unit.kilojoule_per_mole * unit.nanometer**6,
}
)
force_field = ForceField(load_plugins=True)
force_field.register_parameter_handler(handler)
topology = Molecule.from_mapped_smiles("[H:2][O:1][H:3]").to_topology()
matches = force_field.label_molecules(topology)
# the matches of the 0th molecule in the handler tagged "Buckingham"
# printed as key-val pairs of atom indices and parameter types
print(*matches[0]["Buckingham"].items())
Which should output something like:
[((0,),
<BuckinghamType with smirks: [#1]-[#8X2H2+0:1]-[#1] a: 4.0 kilojoule_per_mole b: 4.0 / nanometer c: 4.0 kilojoule_per_mole * nanometer ** 6 >),
((1,),
<BuckinghamType with smirks: [#1:1]-[#8X2H2+0]-[#1] a: 1.0 kilojoule_per_mole b: 2.0 / nanometer c: 3.0 kilojoule_per_mole * nanometer ** 6 >),
((2,),
<BuckinghamType with smirks: [#1:1]-[#8X2H2+0]-[#1] a: 1.0 kilojoule_per_mole b: 2.0 / nanometer c: 3.0 kilojoule_per_mole * nanometer ** 6 >)]
This class should be registered as a plugin via the entry_points
system by adding something like this to your setup.py
or analogous setup file.
entry_points = {
"openff.toolkit.plugins.handlers": [
"BuckinghamHandler = full.path.to.module:BuckinghamHandler",
],
}
At this point, we have created a class that can parse sections of a custom OFFXML file (or create this handler from the Python API). We next need to create a custom SMIRNOFFCollection
to process these parameters. In this case, we are using a shortcut by inheriting from _SMIRNOFFNonbondedCollection
, which itself inherits from SMIRNOFFCollection
and adds in some default fields for non-bonded interactions.
from openff.toolkit import Topology
from typing import Literal, Type
from openff.models.types import FloatQuantity
from openff.interchange.smirnoff._nonbonded import _SMIRNOFFNonbondedCollection
from openff.interchange.components.potentials import Potential
class SMIRNOFFBuckinghamCollection(_SMIRNOFFNonbondedCollection):
type: Literal["Buckingham"] = "Buckingham"
expression: str = "a*exp(-b*r)-c/r**6"
method: str = "cutoff"
mixing_rule: str = "Buckingham"
switch_width: FloatQuantity["angstrom"] = unit.Quantity(1.0, unit.angstrom)
@classmethod
def allowed_parameter_handlers(cls):
return [BuckinghamHandler]
@classmethod
def supported_parameters(cls):
return ["smirks", "id", "a", "b", "c"]
def store_potentials(self, parameter_handler: BuckinghamHandler) -> None:
self.method = parameter_handler.method.lower()
self.cutoff = parameter_handler.cutoff
for potential_key in self.key_map.values():
smirks = potential_key.id
parameter = parameter_handler.parameters[smirks]
self.potentials[potential_key] = Potential(
parameters={
"a": parameter.a,
"b": parameter.b,
"c": parameter.c,
},
)
@classmethod
def create(
cls,
parameter_handler: BuckinghamHandler,
topology: Topology,
):
handler = cls(
scale_13=parameter_handler.scale13,
scale_14=parameter_handler.scale14,
scale_15=parameter_handler.scale15,
cutoff=parameter_handler.cutoff,
mixing_rule=parameter_handler.combining_rules.lower(),
method=parameter_handler.method.lower(),
switch_width=parameter_handler.switch_width,
)
handler.store_matches(parameter_handler=parameter_handler, topology=topology)
handler.store_potentials(parameter_handler=parameter_handler)
return handler
One last piece of housekeeping: because we are defining these plugins as one-off classes in an interactive session, some steps in the plugin loading machinery were skipped over. To get this to work without creating a separate module, we need to monkey-patch two private classes; one that tracks which SMIRNOFF sections are supported, and another that maps handlers to collection classes:
from openff.interchange.smirnoff._create import (
_SUPPORTED_PARAMETER_HANDLERS,
_PLUGIN_CLASS_MAPPING,
)
_SUPPORTED_PARAMETER_HANDLERS.add("Buckingham")
_PLUGIN_CLASS_MAPPING[BuckinghamHandler] = SMIRNOFFBuckinghamCollection
With everything registered and still in memory, we can create an Interchange
using it and a simple water Topology
:
from openff.interchange import Interchange
Interchange.from_smirnoff(force_field, topology)
Creating completely custom behavior
For cases in which custom behavior has no analog in existing Interchange functionality, another route is available. Define on the collection the modify_openmm_forces
method as follows:
class MySMIRNOFFCollection(SMIRNOFFCollection):
def modify_openmm_forces(
self,
interchange: Interchange,
system: openmm.System,
add_constrained_forces: bool,
constrained_pairs: Set[Tuple[int, ...]],
particle_map: Dict[Union[int, "VirtualSiteKey"], int],
): ...
This provides complete access to the contents of the Interchange
object and the flexibility to modify the openmm.System
as desired - modifying, adding, deleting, etc. existing particles, forces, exclusions, etc. This method is internally called on each plugin collection when calling Interchange.to_openmm()
via a code block like:
def to_openmm(
interchange,
combine_nonbonded_forces: bool = False,
add_constrained_forces: bool = False,
) -> openmm.System:
...
# Process in-spec SMIRNOFF sections
...
for collection in interchange.collections.values():
if collection.is_plugin:
try:
collection.modify_openmm_forces(
interchange,
system,
add_constrained_forces=add_constrained_forces,
constrained_pairs=constrained_pairs,
particle_map=particle_map,
)
except NotImplementedError:
continue
return system