Restraints

It can be useful to add restraints to the system to hold things in place. For example, you might want to hold the protein fixed while allowing the ligand to move around. Or you may want to hold a ligand in place while it is being decoupled from the simulation. Or you may want to use restraints to control the distances between atoms or pull a molecule into a particular conformation.

You can specify the restraints you want to add to a system via the functions in the sire.restraints module. These functions return Restraints objects that contain all of the information that needs to be passed to OpenMM to add the restraints to the system.

Positional Restraints

The simplest type of restraint is a positional restraint, which holds a set of atoms at a fixed position. You can create a positional restraint using the positional() function. This function takes the set of molecules or system you want to simulate, together with a search string, list of atom indexes, or molecule views holding the atoms that you want to restrain. For example,

>>> import sire as sr
>>> mols = sr.load(sr.expand(sr.tutorial_url, "ala.top", "ala.crd"))
>>> restraints = sr.restraints.positional(mols, atoms="resname ALA and element C")
>>> print(restraints)
PositionalRestraints( size=3
0: PositionalRestraint( 8 => ( 16.5371, 5.02707, 15.812 ), k=150 kcal mol-1 Å-2 : r0=0 Å )
1: PositionalRestraint( 10 => ( 16.0464, 6.38937, 15.2588 ), k=150 kcal mol-1 Å-2 : r0=0 Å )
2: PositionalRestraint( 14 => ( 15.3698, 4.19397, 16.434 ), k=150 kcal mol-1 Å-2 : r0=0 Å )
)

will create positional restraints for the three carbon atoms in the alanine residue. These carbon atoms are at indexes 8, 10 and 14 in mols.atoms(), e.g.

>>> print(mols.atoms([8, 10, 14]))
SireMol::SelectorM<SireMol::Atom>( size=3
0: MolNum(6) Atom( CA:9    [  16.54,    5.03,   15.81] )
1: MolNum(6) Atom( CB:11   [  16.05,    6.39,   15.26] )
2: MolNum(6) Atom( C:15    [  15.37,    4.19,   16.43] )
)

You could have specified these indicies directly, e.g.

>>> restraints = sr.restraints.positional(mols, atoms=[8, 10, 14])
>>> print(restraints)
PositionalRestraints( size=3
0: PositionalRestraint( 8 => ( 16.5371, 5.02707, 15.812 ), k=150 kcal mol-1 Å-2 : r0=0 Å )
1: PositionalRestraint( 10 => ( 16.0464, 6.38937, 15.2588 ), k=150 kcal mol-1 Å-2 : r0=0 Å )
2: PositionalRestraint( 14 => ( 15.3698, 4.19397, 16.434 ), k=150 kcal mol-1 Å-2 : r0=0 Å )
)

or just passed in the atoms directly, e.g.

>>> atoms = mols.atoms([8, 10, 14])
>>> restraints = sr.restraints.positional(mols, atoms=atoms)
>>> print(restraints)
PositionalRestraints( size=3
0: PositionalRestraint( 8 => ( 16.5371, 5.02707, 15.812 ), k=150 kcal mol-1 Å-2 : r0=0 Å )
1: PositionalRestraint( 10 => ( 16.0464, 6.38937, 15.2588 ), k=150 kcal mol-1 Å-2 : r0=0 Å )
2: PositionalRestraint( 14 => ( 15.3698, 4.19397, 16.434 ), k=150 kcal mol-1 Å-2 : r0=0 Å )
)

The default force constant is 150 kcal mol-1 Å-2. By default, the atoms are restrained to their current positions, and are held exactly in those positions (hence why the r0=0 Å in the output above).

You can set the force constant and width of the half-harmonic restraint used to hold the atoms in position using the k and r0 arguments, e.g.

>>> restraints = sr.restraints.positional(mols, atoms="resname ALA and element C",
...                                       k="100 kcal mol-1 A-2", r0="1.5 A")
>>> print(restraints)
PositionalRestraints( size=3
0: PositionalRestraint( 8 => ( 16.5371, 5.02707, 15.812 ), k=100 kcal mol-1 Å-2 : r0=1.5 Å )
1: PositionalRestraint( 10 => ( 16.0464, 6.38937, 15.2588 ), k=100 kcal mol-1 Å-2 : r0=1.5 Å )
2: PositionalRestraint( 14 => ( 15.3698, 4.19397, 16.434 ), k=100 kcal mol-1 Å-2 : r0=1.5 Å )
)

By default, the atoms are restrained to their current positions. You can specify a different position using the position argument, e.g.

>>> restraints = sr.restraints.positional(mols, atoms="resname ALA and element C",
...                                       position=[(0,0,0), (1,1,1), (2,2,2)])
>>> print(restraints)
PositionalRestraints( size=3
0: PositionalRestraint( 8 => ( 0, 0, 0 ), k=150 kcal mol-1 Å-2 : r0=0 Å )
1: PositionalRestraint( 10 => ( 1, 1, 1 ), k=150 kcal mol-1 Å-2 : r0=0 Å )
2: PositionalRestraint( 14 => ( 2, 2, 2 ), k=150 kcal mol-1 Å-2 : r0=0 Å )
)

Note

The number of positions must equal the number of atoms to be restrained, or equal to 1. If you only pass in a single position then this will be used for all of the restraints.

Sometimes it can be useful to use the same position for all of the restraints, e.g. in the case of a spherical half-harmonic restraint that holds molecules within a spherical bubble, e.g.

>>> center = mols[0].coordinates()
>>> restraints = sr.restraints.positional(mols,
...                                       atoms=mols[f"atoms within 10 of {center}"],
...                                       position=center,
...                                       r0="10 A")
>>> print(restraints)
PositionalRestraints( size=358
0: PositionalRestraint( 0 => ( 16.5471, 4.50102, 15.6589 ), k=150 kcal mol-1 Å-2 : r0=10 Å )
1: PositionalRestraint( 1 => ( 16.5471, 4.50102, 15.6589 ), k=150 kcal mol-1 Å-2 : r0=10 Å )
2: PositionalRestraint( 2 => ( 16.5471, 4.50102, 15.6589 ), k=150 kcal mol-1 Å-2 : r0=10 Å )
3: PositionalRestraint( 3 => ( 16.5471, 4.50102, 15.6589 ), k=150 kcal mol-1 Å-2 : r0=10 Å )
4: PositionalRestraint( 4 => ( 16.5471, 4.50102, 15.6589 ), k=150 kcal mol-1 Å-2 : r0=10 Å )
...
353: PositionalRestraint( 1892 => ( 16.5471, 4.50102, 15.6589 ), k=150 kcal mol-1 Å-2 : r0=10 Å )
354: PositionalRestraint( 1893 => ( 16.5471, 4.50102, 15.6589 ), k=150 kcal mol-1 Å-2 : r0=10 Å )
355: PositionalRestraint( 1906 => ( 16.5471, 4.50102, 15.6589 ), k=150 kcal mol-1 Å-2 : r0=10 Å )
356: PositionalRestraint( 1907 => ( 16.5471, 4.50102, 15.6589 ), k=150 kcal mol-1 Å-2 : r0=10 Å )
357: PositionalRestraint( 1908 => ( 16.5471, 4.50102, 15.6589 ), k=150 kcal mol-1 Å-2 : r0=10 Å )
)

has created half-harmonic restraints that affect all atoms within 10 Å of the center of the first molecule, and that restrain those atoms to be within a sphere of radius 10 Å centered on that molecule.

Note that the restraints only contain the indicies of the atoms that are restrained, not the atoms themselves. You can get the atoms by looking up those indicies from the mols molecular container from which the atoms were selected. For example, here we get all of the atoms that are subject to that spherical half-harmonic restraint;

>>> print(mols.atoms()[[restraint.atom() for restraint in restraints]])
SireMol::SelectorM<SireMol::Atom>( size=358
0: MolNum(6) Atom( HH31:1  [  18.45,    3.49,   12.44] )
1: MolNum(6) Atom( CH3:2   [  18.98,    3.45,   13.39] )
2: MolNum(6) Atom( HH32:3  [  20.05,    3.63,   13.29] )
3: MolNum(6) Atom( HH33:4  [  18.80,    2.43,   13.73] )
4: MolNum(6) Atom( C:5     [  18.48,    4.55,   14.35] )
...
353: MolNum(630) Atom( H1:1893 [  14.92,    1.28,   17.05] )
354: MolNum(630) Atom( H2:1894 [  15.19,   -0.21,   17.07] )
355: MolNum(623) Atom( O:1907  [  21.65,    7.88,    9.79] )
356: MolNum(623) Atom( H1:1908 [  22.33,    8.56,    9.83] )
357: MolNum(623) Atom( H2:1909 [  21.07,    8.08,   10.53] )
)

Distance or Bond Restraints

The sire.restraints.distance() or sire.restraints.bond() functions are used to create bond or distance restraints. These are identical restraints, so the functions are just synonyms of each other (they are the same function with different names).

These functions take the set of molecules or system you want to simulate, plus two search strings, lists of atom indexes, or molecule views holding the pairs of atoms that you want to restrain, e.g.

>>> restraints = sr.restraints.distance(mols, atoms0=0, atoms1=1)
>>> print(restraints)
BondRestraints( size=1
0: BondRestraint( 0 <=> 1, k=150 kcal mol-1 Å-2 : r0=1.09 Å )
)

or

>>> restraints = sr.restraints.bond(mols, atoms0=0, atoms1=1)
>>> print(restraints)
BondRestraints( size=1
0: BondRestraint( 0 <=> 1, k=150 kcal mol-1 Å-2 : r0=1.09 Å )
)

creates a single harmonic distance (or bond) restraint that acts between atoms 0 and 1. By default, the equilibrium distance (r0) is the current distance between the atoms (1.09 Å), and the force constant (k) is 150 kcal mol-1 Å-2.

You can set these via the k and r0 arguments, e.g.

>>> restraints = sr.restraints.bond(mols, atoms0=0, atoms1=1,
...                                 k="100 kcal mol-1 A-2", r0="1.5 A")
>>> print(restraints)
BondRestraints( size=1
0: BondRestraint( 0 <=> 1, k=100 kcal mol-1 Å-2 : r0=1.5 Å )
)

You can specify as many atoms pairs as you like, e.g.

>>> restraints = sr.restraints.bond(mols, atoms0=[0, 1, 2], atoms1=[3, 4, 5])
>>> print(restraints)
BondRestraints( size=3
0: BondRestraint( 0 <=> 3, k=150 kcal mol-1 Å-2 : r0=1.71245 Å )
1: BondRestraint( 1 <=> 4, k=150 kcal mol-1 Å-2 : r0=1.54643 Å )
2: BondRestraint( 2 <=> 5, k=150 kcal mol-1 Å-2 : r0=2.48642 Å )
)

You can specify the atoms using a search string, passing the atoms themselves, or using the atom index, just as you could for the positional restraints.

>>> restraints = sr.restraints.bond(mols,
...                                 atoms0=mols[0][0],
...                                 atoms1=mols["water and element O"])
>>> print(restraints)
BondRestraints( size=630
0: BondRestraint( 0 <=> 22, k=150 kcal mol-1 Å-2 : r0=13.2847 Å )
1: BondRestraint( 0 <=> 25, k=150 kcal mol-1 Å-2 : r0=10.8445 Å )
2: BondRestraint( 0 <=> 28, k=150 kcal mol-1 Å-2 : r0=15.9183 Å )
3: BondRestraint( 0 <=> 31, k=150 kcal mol-1 Å-2 : r0=13.5108 Å )
4: BondRestraint( 0 <=> 34, k=150 kcal mol-1 Å-2 : r0=9.18423 Å )
...
625: BondRestraint( 0 <=> 1897, k=150 kcal mol-1 Å-2 : r0=9.52934 Å )
626: BondRestraint( 0 <=> 1900, k=150 kcal mol-1 Å-2 : r0=12.7207 Å )
627: BondRestraint( 0 <=> 1903, k=150 kcal mol-1 Å-2 : r0=10.8053 Å )
628: BondRestraint( 0 <=> 1906, k=150 kcal mol-1 Å-2 : r0=6.04142 Å )
629: BondRestraint( 0 <=> 1909, k=150 kcal mol-1 Å-2 : r0=17.1035 Å )
)

Note

If the number of atoms in atoms0 and atoms1 are not equal, then the smaller list will be extended to match by appending multiple copies of the last atom. In this case, this duplicates the single atom in atoms0, meaning that this has created distance restraints between the first atom in the first molecule and the oxygen atoms in all of the water molecules.

Boresch Restraints

The sire.restraints.boresch() function is used to create Boresch restraints, which are commonly used for restraining the relative positions and orientations of a receptor and ligand during alchemical absolute binding free energy calculations. They restrain the six relative external degrees of freedom of the receptor and ligand by restraining one distance, two angles, and three dihedrals angles which are defined according to three anchor points in the receptor and three anchor points in the ligand. For more detail, please see J. Phys. Chem. B 2003, 107, 35, 9535–9551.

To create a Boresch restraint, you need to specify the receptor and ligand anchor atoms (note that the order of the atoms is important). Like the distance restraints, the atoms can be specified using a search string, passing lists of atom indexes, or molecule views holding the atoms. You can also specify the force constants and equilibrium values for the restraints. If not supplied, default force constants of 10 kcal mol-1 Å-2 and 100 kcal mol-1 rad-2 are used for the distance and angle restraints, respectively, and the equilibrium values are set to the current values of the distances and angles in the system supplied. For example,

>>> boresch_restraints = sr.restraints.boresch(mols, receptor=[1574, 1554, 1576], ligand=[4,3,5])
>>> boresch_restraint = boresch_restraints[0]
>>> print(boresch_restraint)
BoreschRestraint( [1574, 1554, 1576] => [4, 3, 5],
                  k=[10 kcal mol-1 Å-2, 0.0304617 kcal mol-1 °-2, 0.0304617 kcal mol-1 °-2,
                   0.0304617 kcal mol-1 °-2, 0.0304617 kcal mol-1 °-2, 0.0304617 kcal mol-1 °-2]
                  r0=15.1197 Å, theta0=[80.5212°, 59.818°],
                  phi0=[170.562°Ⱐ128.435°Ⱐ192.21°] )

creates a Boresch restraint where the receptor anchor atoms are r1 = 1574, r2 = 1554, and r3 = 1576, and the ligand anchor atoms are l1 = 4, l2 = 3, and l3 = 5. The default force constants have been set and the equilibrium values have been set to the current values of the distances and angles in the system supplied.

Note

Boresch restraints can be subject to instabilities if any three contiguous anchor points approach collinearity (J. Chem. Theory Comput. 2023, 19, 12, 3686–3704). It is important to prevent this by ensuring the associated angles are sufficiently far from 0 or 180 degrees, and that the ktheta force constants are high enough. Sire will raise a warning if the theta0 values are too close to 0 or 180 degrees for the given temperature and force constants.

Alternatively, we could have explicitly set the force constants and equilibrium values, e.g.

>>> boresch_restraints = sr.restraints.boresch(mols, receptor=[1574, 1554, 1576], ligand=[4,3,5],
                                               kr = "6.2012 kcal mol-1 A-2",
                                               ktheta = ["28.7685 kcal mol-1 rad-2", "24.8204 kcal mol-1 rad-2"],
                                               kphi = ["59.8626 kcal mol-1 rad-2", "0.7923 kcal mol-1 rad-2",
                                                       "55.1775 kcal mol-1 rad-2"],
                                               r0 = "16 A",
                                               theta0 = ["1.2 rad", "1.3 rad"],
                                               phi0 = ["2.2 rad", "2.5 rad", "1.5 rad"],
                                               name = "boresch_restraint_1")
>>> boresch_restraint = boresch_restraints[0]
>>> print(boresch_restraint)
BoreschRestraint( [1574, 1554, 1576] => [4, 3, 5],
                  k=[6.2012 kcal mol-1 Å-2, 0.00876339 kcal mol-1 °-2, 0.00756073 kcal mol-1 °-2, 0.0182352 kcal mol-1 °-2, 0.000241348 kcal mol-1 °-2, 0.016808 kcal mol-1 °-2]
                  r0=16 Å, theta0=[68.7549°, 74.4845°],
                  phi0=[126.051°Ⱐ143.239°Ⱐ85.9437°] )

Note

sire.restraints.boresch() returns a list of Boresch restraints. If you are only interested in a single Boresch restraint, you can extract it with the index, e.g. boresch_restraint = boresch_restraints[0].

When performing an alchemical absolute binding free energy calculation, it is necessary to calculate the free energy of releasing the decoupled ligand to the standard state volume. The analytical Boresch correction is almost always accurate if stable restraints have been selected (see 10.26434/chemrxiv-2023-8s9dz-v2). This can be calculated with sire.restraints.standard_state_correction():

>>> boresch_restraint = boresch_restraints[0]
>>> from sire import u
>>> correction = sr.restraints.standard_state_correction(boresch_restraint, temperature=u("298 K"))
>>> print(correction)
-6.2399 kcal mol-1

Using restraints in minimisation or dynamics

You can use restraints in a minimisation or dynamics simulation by passing them in via the restraints argument, e.g.

>>> restraints = sr.restraints.positional(mols, atoms="resname ALA and element C")
>>> d = mols.dynamics(timestep="4fs", temperature="25oC",
...                   restraints=restraints)
>>> d.run("10ps")
>>> mols = d.commit()

runs a dynamics simulation using positional restraints on the carbon atoms of the ALA residue, while

>>> restraints = sr.restraints.distance(mols, atoms0=mols[0][0],
...                                     atoms1=mols[1][0], r0="5 A")
>>> mols = mols.minimisation(restraints=restraints).run().commit()

performs a minimisation where a distance restraint is applied between the first atoms of the first two molecules, pulling them to be 5 Å apart.

You can pass in a list of restraints, meaning that you can use as many as you wish during a simulation.

>>> pos_rests = sr.restraints.positional(mols, atoms="resname ALA and element C")
>>> dst_rests = sr.restraints.distance(mols, atoms0=mols[0][0], atoms1=mols[1][0])
>>> mols = mols.minimisation(restraints=[pos_rests, dst_rests]).run().commit()
>>> d = mols.dynamics(timestep="4fs", temperature="25oC",
...                   restraints=[pos_rests, dst_rests])
>>> d.run("10ps")
>>> mols = d.commit()

Note

It is a good idea to run minimisation before dynamics whenever you add restraints to a system. This is because the restraints could put a lot of energy into the system, causing blow-ups or NaN errors.

Using restraints in the low-level API

You can use restraints in the low-level API by passing them in as a map parameter using the key restraints, e.g.

>>> omm = sr.convert.to(mols, "openmm",
...                     map={"restraints": [pos_rests, dst_rests]})
>>> print(omm)
openmm::Context( num_atoms=1915 integrator=VerletIntegrator timestep=1.0 fs platform=HIP )

More information about the mappable options for the low-level API can be found in the detailed guide.

Fixing atoms in place

As well as restraining atoms, you also have the option of fixing atoms in space during the simulation. Atoms which are fixed are not moved by minimisation or the dynamics integrator. This can be useful if you, e.g. want to freeze atoms outside a radius of a ligand binding site, or if you want to minimise the solvent while keeping the solute fixed.

You can fix atoms by passing in the set of atoms to fix as the fixed argument to the minimisation() or dynamics() functions, e.g.

>>> mols = sr.load(sr.expand(sr.tutorial_url, "ala.top", "ala.crd"))
>>> d = mols.dynamics(timestep="4fs", temperature="25oC",
...                   fixed=mols[0])
>>> d.run("10ps")
>>> mols = d.commit()

will run dynamics where all of the atoms in mols[0] (the solute) are fixed. You can pass in a molecular container containing all of the atoms that should be fixed, or a search string, or the atom indexes of the atoms. Here,

>>> d = mols.dynamics(timestep="1fs", temperature="25oC",
...                   fixed="element C")
>>> d.run("10ps")
>>> mols = d.commit()

we have run dynamics where all of the carbon atoms are fixed, while

>>> d = mols.dynamics(timestep="1fs", temperature="25oC",
...                   fixed=[0, 2, 4, 6, 8])
>>> d.run("10ps")
>>> mols = d.commit()

would run dynamics where atoms at indicies 0, 2, 4, 6 and 8 are fixed.

You could even use search strings to fix atoms by distances. If you do this, it is best to also add half-harmonic positional restraints that hold nearby molecules within the sphere of mobile atoms, e.g.

>>> center = mols[0].coordinates()
>>> radius = "10 A"
>>> restraints = sr.restraints.positional(mols,
...                                       atoms=mols[f"molecules within 10 of {center}"],
...                                       position=center,
...                                       r0=radius)
>>> d = mols.dynamics(timestep="1fs", temperature="25oC",
...                   fixed=f"not molecules within {radius} of {center}")
>>> d.run("10ps")
>>> mols = d.commit()

This implements the restraints in OpenMM by setting the masses of the fixed atoms to zero. This signals the OpenMM integrator to skip over these atoms and not move them.

Note

Be careful using constraints with fixed atoms. If a constraint involves a fixed atom and a mobile atom, then there is a high risk that the constraint won’t be able to be satisfied during dynamics, and Particle coordinate is NaN error or similar will occur. It it safest to use a small timestep (e.g. 1 fs) when constraining only parts of molecules.

Note

While the fixed atoms are not moved by the integrator, they are still included in the energy calculation. This means that the computational cost of the simulation will still be high, and also that energies will not be conserved. Make sure you use an integrator that provides a heat bath (e.g. NVT or NPT integrators).