Perturbation

As seen in chapter 6, the concept of a “merged molecule” is central to how sire implements the perturbations (morphing) needed for free energy calculations.

A merged molecule is one that has been created with a single set of atoms, but with properties that represent either a “reference” (λ=0) or “perturbed” (λ=1) state.

For example, let’s load a merged molecule that represents neopentane in the reference state, and methane in the perturbed state.

>>> import sire as sr
>>> mols = sr.load_test_files("neo_meth_scratch.bss")
>>> print(mols)
System( name=BioSimSpace_System num_molecules=1 num_residues=1 num_atoms=17 )
>>> print(mols[0])
Molecule( Merged_Molecule:6 num_atoms=17 num_residues=1 )

Note

This molecule was created using BioSimSpace. We will cover how to create merged molecules in a later section in this tutorial.

Examining the perturbation

Normally, a sire molecule will have one set of forcefield parameters. For example, the atomic charges would be stored in the charge property, the Lennard-Jones parameters in the LJ property, the bond parameters in the bond property, etc.

However, a merged molecule has two sets of parameters, one for the reference state (representing λ=0) and one for the perturbed state (representing λ=1). The reference state parameters are stored in the 0 properties, e.g. charge0, LJ0, bond0, etc. The perturbed state parameters are stored in the 1 properties, e.g. charge1, LJ1, bond1, etc.

We can see this for our merged molecule above by printing out all of the property keys.

>>> print(mols[0].property_keys())
['gb_radii0', 'gb_radii1', 'atomtype0', 'element0', 'atomtype1', 'element1',
 'time', 'intrascale0', 'intrascale1', 'improper0', 'mass0', 'improper1',
 'mass1', 'ambertype0', 'ambertype1', 'treechain0', 'treechain1',
 'parameters0', 'parameters1', 'space', 'charge0', 'charge1',
 'connectivity', 'dihedral0', 'forcefield0', 'dihedral1', 'coordinates0',
 'forcefield1', 'coordinates1', 'LJ0', 'name0', 'LJ1', 'name1', 'angle0',
 'angle1', 'bond0', 'bond1', 'molecule0', 'molecule1', 'gb_screening0',
 'gb_screening1', 'is_perturbable']

Notice how there is a 0 and 1 version of nearly every property. These represent the molecule at the reference and perturbed end states, e.g.

>>> print(mols[0].property("charge0"))
SireMol::AtomCharges( size=17
0: -0.0853353 |e|
1: -0.0602353 |e|
2: -0.0853353 |e|
3: -0.0853353 |e|
4: -0.0853353 |e|
...
12: 0.0334647 |e|
13: 0.0334647 |e|
14: 0.0334647 |e|
15: 0.0334647 |e|
16: 0.0334647 |e|
)
>>> print(mols[0].property("charge1"))
SireMol::AtomCharges( size=17
0: 0 |e|
1: 0.0271 |e|
2: 0 |e|
3: -0.1084 |e|
4: 0 |e|
...
12: 0.0271 |e|
13: 0.0271 |e|
14: 0 |e|
15: 0 |e|
16: 0 |e|
)

prints the charges for neopentane, and then methane. Note how many of the atom charges for methane are zero. This is because methane has fewer atoms than neopentane, and so the charges (and other parameters) for the extra atoms are “switched off” for the perturbed state. These extra atoms are often called “dummy atoms”. In sire, we prefer to call them “ghost atoms”. They are “ghosts” because they fade away to nothing as λ increases from 0 to 1.

In addition to the 0 and 1 properties, a perturbable molecule must also contain the is_perturbable property. This is a boolean that signals whether or not a molecule is perturbable. It should be True if the molecule can be perturbed.

>>> print(mols[0].property("is_perturbable"))
True

Perturbation objects

Examining the perturbable properties directly can be a little cumbersome. To make things easier, there are a number of helper classes and functions. These are accessed via the sire.morph module, and the perturbation() function of a Molecule.

Let’s create the Perturbation object for our molecule.

>>> pert = mols[0].perturbation()
>>> print(pert)
Perturbation( Molecule( Merged_Molecule:6 num_atoms=17 num_residues=1 ) )

We can extract a molecule that contains only the reference or perturbed parameters using the extract_reference() and extract_perturbed() functions.

>>> ref = pert.extract_reference()
>>> print(ref.property_keys())
['bond', 'atomtype', 'time', 'intrascale', 'element', 'dihedral',
 'parameters', 'ambertype', 'angle', 'gb_radii', 'treechain', 'space',
 'forcefield', 'name', 'connectivity', 'gb_screening', 'molecule',
 'coordinates', 'LJ', 'charge', 'mass', 'improper']
>>> print(ref.property("charge"))
SireMol::AtomCharges( size=17
0: -0.0853353 |e|
1: -0.0602353 |e|
2: -0.0853353 |e|
3: -0.0853353 |e|
4: -0.0853353 |e|
...
12: 0.0334647 |e|
13: 0.0334647 |e|
14: 0.0334647 |e|
15: 0.0334647 |e|
16: 0.0334647 |e|
)

Note

You can extract the reference and perturbed molecules in a collection using the sire.morph.extract_reference() and sire.morph.extract_perturbed() functions. For example, mols = sire.morph.extract_reference(mols) would extract the reference state of all molecules in mols.

Note

By default, ghost atoms will be removed when you extract an end state. You can retain ghost atoms by passing remove_ghosts=False to the above extract_reference and extract_perturbed functions.

Extracting the reference or perturbed states can be useful if you want to go back to the unmerged molecule, e.g. for visualisation via the view() function. However, normally we would want to keep the properties of the two end states, and then choose one end state as the “current” state. We can do this by creating links from the “standard” property names (e.g. charge, LJ etc.) to the equivalent properties of the chosen end state. You could do this manually, but it is much easier to use the link_to_reference() and link_to_perturbed() functions.

For example, here we will link to the perturbed state.

>>> mol = pert.link_to_perturbed()
>>> print(mol.get_links())
{'improper': 'improper1', 'gb_screening': 'gb_screening1',
 'mass': 'mass1', 'dihedral': 'dihedral1', 'parameters': 'parameters1',
 'treechain': 'treechain1', 'bond': 'bond1', 'ambertype': 'ambertype1',
 'molecule': 'molecule1', 'atomtype': 'atomtype1', 'charge': 'charge1',
 'angle': 'angle1', 'forcefield': 'forcefield1',
 'coordinates': 'coordinates1', 'intrascale': 'intrascale1',
 'name': 'name1', 'LJ': 'LJ1', 'element': 'element1',
 'gb_radii': 'gb_radii1'}
>>> print(mol.property("charge"))
SireMol::AtomCharges( size=17
0: 0 |e|
1: 0.0271 |e|
2: 0 |e|
3: -0.1084 |e|
4: 0 |e|
...
12: 0.0271 |e|
13: 0.0271 |e|
14: 0 |e|
15: 0 |e|
16: 0 |e|
)

Note

You can link the reference or perturbed states in a collection of molecules using the sire.morph.link_to_reference() and sire.morph.link_to_perturbed() functions. For example, mols = sire.morph.link_to_perturbed(mols) would link all molecules in mols to the perturbed state.

Note

It is a good idea when loading a system containing one or more merged molecules to decide on which end state you want to use as the “current”. After loading, simply call either mols = sire.morph.link_to_reference(mols) or mols = sire.morph.link_to_perturbed(mols) to update the molecules with your chosen state.

Inspecting the changing parameters

Under the hood, the properties of the two end states are converted into parameters for the underlying OpenMM system used for the free energy simulations. You can access these parameters by converting the above perturbation into a PerturbableOpenMMMolecule.

>>> pert_omm = pert.to_openmm()
>>> print(pert_omm)
PerturbableOpenMMMolecule()

This object contains all of the parameters needed to represent both end states of this molecule in the OpenMM forces. For example, get_charges0() returns a list of the charges for the reference state, while get_charges0() returns a list of the charges for the perturbed state.

>>> print(pert_omm.get_charges0())
[-0.08533529411764705, -0.06023529411764705, -0.08533529411764705,
 -0.08533529411764705, -0.08533529411764705, 0.03346470588235294,
  0.03346470588235294, 0.03346470588235294, 0.03346470588235294,
  0.03346470588235294, 0.03346470588235294, 0.03346470588235294,
  0.03346470588235294, 0.03346470588235294, 0.03346470588235294,
  0.03346470588235294, 0.03346470588235294]

At this level, we are most interested in the parameters that change as we morph from the reference to the perturbed state (i.e. as we move from λ=0 to λ=1). You can access these via the changed_atoms(), changed_bonds(), changed_angles(), changed_torsions(), changed_exceptions() and changed_constraints() functions.

>>> print(pert_omm.changed_bonds())
        bond  length0  length1         k0         k1
0  C2:2-C4:4  0.15375  0.10969  251793.12  276646.08
>>> print(pert_omm.changed_angles())
              angle     size0     size1        k0        k1
0  C2:2-C4:4-H12:12  1.916372  1.877626  387.4384  329.6992
1  C2:2-C4:4-H14:14  1.916372  1.877626  387.4384  329.6992
2  C2:2-C4:4-H13:13  1.916372  1.877626  387.4384  329.6992

Note

The parameters are directly as would be used in an OpenMM force, i.e. in OpenMM default units.

Here we see that the perturbation involves the C2-C4 bond changing from 0.15375 nm to 0.10969 nm, with an associated change in the force constant from 251793.12 kJ mol^-1 nm^-2 to 276646.08 kJ mol^-1 nm^-2.

Similarly, the C2-C4-H12 angle changes from 1.916372 radians to 1.877626 radians, with an associated change in the force constant from 387.4384 kJ mol^-1 rad^-2 to 329.6992 kJ mol^-1 rad^-2.

These functions return their output as pandas dataframes. You can get a raw output by passing in to_pandas=False.

>>> print(pert_omm.changed_bonds(to_pandas=False))
[(Bond( C2:2 => C4:4 ), 0.15375000000000003, 0.10969000000000001, 251793.11999999997, 276646.08)]

As well as forcefield parameters, you can also access any changes in constraint parameters caused by constraining perturbable bonds. For example, here we can create the PerturbableOpenMMMolecule used when the bonds constraint algorithm is used.

>>> pert_omm = pert.to_openmm(constraint="bonds")

Now, we can see how the constraint parameters will change across λ using the changed_constraints() function.

>>> print(pert_omm.changed_constraints())
    atompair  length0  length1
0  C2:2-C4:4  0.15375  0.10969

In this case, we see that the perturbing C2-C4 bond is constrained, with a constraint length of 0.15375 nm in the reference state, and 0.10969 nm in the perturbed state.

Visualising the perturbation

Perturbations can involve changes in bond lengths, or angle / torsion sizes. These can be difficult to visualise from the raw numbers. To help with this, the view_reference() and view_perturbed() functions can be used to view a 3D movie of the perturbation from either the reference or perturbed states.

>>> pert.view_reference()
View of the movie showing the perturbation from neopentane to methane

Note

The movie loops from λ=0 to λ=1, and then back to λ=0. You can pass in any of the visualisation options as used in the standard view() function. The viewer may show some bonds as broken - this is just because they are too long to be shown when calculated at λ=0.