Skip to content

Commit c29ab66

Browse files
authored
Merge pull request #239 from open-forcefield-group/constraints
Initial support for bond constraints
2 parents c1b89b6 + 0423254 commit c29ab66

File tree

8 files changed

+11030
-19
lines changed

8 files changed

+11030
-19
lines changed

The-SMIRFF-force-field-format.md

+32
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,38 @@ Here is an example not intended for actual use:
172172
```
173173
The charge model specified must be a method understood by the OpenEye toolkits, and the charge correction `increment` (in units of proton charge) will be applied on top of this by subtracting `increment` from the atom tagged as 1 and adding it to the atom tagged as 2.
174174

175+
### CONSTRAINTS
176+
177+
Bond length constraints can be specified through a `<Constraints/>` block, which can constrain bonds to their equilibrium lengths or specify an interatomic constraint distance.
178+
Two atoms must be tagged in the `smirks` attribute of each `<Constraint/>` record.
179+
180+
To constrain two atoms to their equilibrium bond length, it is critical that a `<HarmonicBondForce/>` record be specified for those atoms:
181+
```XML
182+
<Constraints>
183+
<!-- constrain all bonds to hydrogen to their equilibrium bond length -->
184+
<Constraint smirks="[#1:1]-[*:2]" />
185+
</Constraints>
186+
```
187+
However, this constraint distance can be overridden, or two atoms that are not directly bonded constrained, by specifying the `distance` attribute (and optional `distance_unit` attribute for the `<Constraints/>` tag):
188+
```XML
189+
<Constraints distance_unit="angstroms">
190+
<!-- constrain water O-H bond to equilibrium bond length (overrides earlier constraint) -->
191+
<Constraint smirks="[#1:1]-[#8X2H2:2]-[#1]" distance="0.9572"/>
192+
<!-- constrain water H...H, calculating equilibrium length from H-O-H equilibrium angle and H-O equilibrium bond lengths -->
193+
<Constraint smirks="[#1:1]-[#8X2H2]-[#1:2]" distance="1.8532"/>
194+
</Constraints>
195+
```
196+
Typical molecular simulation practice is to constrain all bonds to hydrogen to their equilibrium bond lengths and enforce rigid TIP3P geometry on water molecules:
197+
```XML
198+
<Constraints distance_unit="angstroms">
199+
<!-- constrain all bonds to hydrogen to their equilibrium bond length -->
200+
<Constraint smirks="[#1:1]-[*:2]" />
201+
<!-- TIP3P rigid water -->
202+
<Constraint smirks="[#1:1]-[#8X2H2:2]-[#1]" distance="0.9572"/>
203+
<Constraint smirks="[#1:1]-[#8X2H2]-[#1:2]" distance="1.8532"/>
204+
</Constraints>
205+
```
206+
175207
## Advanced features
176208

177209
Standard usage is expected to rely primarily on the features documented above and potentially new features. However, some advanced features are also currently supported.

smarty/data/systems/README.md

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Solvated systems here were built using SolvationToolkit (github.com/mobleylab/solvationtoolkit) version 0.41 and 0.42 (equivalent except for DOI assignment for the latter), except as noted.
2+
3+
Because of a bug in handling of insertion of CONECT entries in SolvationToolkit/openmoltools for systems containing water, water boxes were generated by taking GROMACS coordinate/topology files for solvated cyclohexane ('mobley_2689721') and ethanol ('mobley_2310185') from FreeSolv 0.5 and converting to PDB format via OpenMM 7.1 (reading via GromacsGroFile and GromacsTopFile and writing via PDBFile).
+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
@<TRIPOS>MOLECULE
2+
ZZU
3+
3 2 1 0 1
4+
SMALL
5+
USER_CHARGES
6+
@<TRIPOS>ATOM
7+
1 O1 -0.2950 -0.2180 0.1540 oh 1 ZZU -0.785000
8+
2 H1 -0.0170 0.6750 0.4080 ho 1 ZZU 0.392500
9+
3 H2 0.3120 -0.4570 -0.5630 ho 1 ZZU 0.392500
10+
@<TRIPOS>BOND
11+
1 1 2 1
12+
2 1 3 1
13+
@<TRIPOS>SUBSTRUCTURE
14+
1 ZZU 1 RESIDUE 0 **** ROOT 0
+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
water
2+
-OEChem-03221709533D
3+
4+
3 2 0 0 0 0 0 0 0999 V2000
5+
-0.2950 -0.2180 0.1540 O 0 0 0 0 0 0 0 0 0 0 0 0
6+
-0.0170 0.6750 0.4080 H 0 0 0 0 0 0 0 0 0 0 0 0
7+
0.3120 -0.4570 -0.5630 H 0 0 0 0 0 0 0 0 0 0 0 0
8+
1 2 1 0 0 0 0
9+
1 3 1 0 0 0 0
10+
M END
11+
> <partial_charges>
12+
-0.7850000262260437
13+
0.39250001311302185
14+
0.39250001311302185
15+
16+
17+
> <partial_bond_orders>
18+
1.0
19+
1.0
20+
21+
22+
> <atom_types>
23+
oh
24+
ho
25+
ho
26+
27+
28+
$$$$

smarty/data/systems/packmol_boxes/cyclohexane_water.pdb

+5,524
Large diffs are not rendered by default.

smarty/data/systems/packmol_boxes/ethanol_water.pdb

+5,174
Large diffs are not rendered by default.

smarty/forcefield.py

+191-7
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,53 @@ def __init__(self, topology, reference_molecules):
232232
# Get/initialize bond orders
233233
self._updateBondOrders()
234234

235+
# Track constraints
236+
self._constrainedAtomPairs = dict()
237+
238+
def constrainAtomPair(self, iatom, jatom, distance=True):
239+
"""
240+
Mark a pair of atoms as constrained.
241+
242+
Parameters
243+
----------
244+
iatom, jatom : int
245+
Indices of atoms to mark as constrained.
246+
distance : simtk.unit.Quantity, optional, default=True
247+
Constraint distance if constraint has been applied,
248+
or True if no constraint has yet been applied
249+
"""
250+
# Check that constraint hasn't already been specified.
251+
if (iatom,jatom) in self._constrainedAtomPairs:
252+
existing_distance = self._constrainedAtomPairs[(iatom,jatom)]
253+
if unit.is_quantity(existing_distance) and (distance is True):
254+
raise Exception('Atoms (%d,%d) already constrained with distance %s but attempting to override with unspecified distance' % (iatom, jatom, existing_distance))
255+
if (existing_distance is True) and (distance is True):
256+
raise Exception('Atoms (%d,%d) already constrained with unspecified distance but attempting to override with unspecified distance' % (iatom, jatom))
257+
258+
self._constrainedAtomPairs[(iatom,jatom)] = distance
259+
self._constrainedAtomPairs[(jatom,iatom)] = distance
260+
261+
def atomPairIsConstrained(self, iatom, jatom):
262+
"""
263+
Check if a pair of atoms are marked as constrained.
264+
265+
Parameters
266+
----------
267+
iatom, jatom : int
268+
Indices of atoms to mark as constrained.
269+
270+
Returns
271+
-------
272+
distance : simtk.unit.Quantity or bool
273+
True if constrained but constraints have not yet been applied
274+
Distance if constraint has already been added to System
275+
276+
"""
277+
if (iatom,jatom) in self._constrainedAtomPairs:
278+
return self._constrainedAtomPairs[(iatom,jatom)]
279+
else:
280+
return False
281+
235282
def angles(self):
236283
"""
237284
Get an iterator over all i-j-k angles.
@@ -618,7 +665,12 @@ def getGenerators(self):
618665

619666
def registerGenerator(self, generator):
620667
"""Register a new generator."""
621-
self._forces.append(generator)
668+
# Special case: ConstraintGenerator has to come before HarmonicBondGenerator and HarmonicAngleGenerator.
669+
# TODO: Figure out a more general way to allow generators to specify enforced orderings.
670+
if isinstance(generator, ConstraintGenerator):
671+
self._forces.insert(0, generator)
672+
else:
673+
self._forces.append(generator)
622674

623675
def getParameter(self, smirks = None, paramID=None, force_type='Implied'):
624676
"""Get info associated with a particular parameter as specified by SMIRKS or parameter ID, and optionally force term.
@@ -1243,6 +1295,117 @@ def __keytransform__(self,key):
12431295
# Force generators
12441296
#=============================================================================================
12451297

1298+
#=============================================================================================
1299+
# Force generators
1300+
#=============================================================================================
1301+
1302+
## @private
1303+
class ConstraintGenerator(object):
1304+
"""A ConstraintGenerator adds constraints.
1305+
1306+
The ConstraintGenerator must be applied before HarmonicBondGenerator and HarmonicAngleGenerator if constrained bonds are to not also have harmonic bond terms added.
1307+
1308+
ConstraintGenerator will mark bonds as being constrained for HarmonicBondGenerator to assign constraints to equilibrium bond lengths,
1309+
while constraints with distances specified will be assigned by ConstraintGenerator.
1310+
1311+
"""
1312+
1313+
class ConstraintType(object):
1314+
"""A SMIRFF constraint type."""
1315+
def __init__(self, node, parent):
1316+
self.smirks = _validateSMIRKS(node.attrib['smirks'], node=node)
1317+
self.pid = _extractQuantity(node, parent, 'id')
1318+
if 'distance' in node.attrib:
1319+
# Constraint distance is specified, will be handled by ConstraintGenerator
1320+
self.distance = _extractQuantity(node, parent, 'distance')
1321+
else:
1322+
# Constraint to equilibrium bond length, handled by HarmonicBondGenerator
1323+
self.distance = True
1324+
1325+
def __init__(self, forcefield):
1326+
self.ff = forcefield
1327+
self._constraint_types = list()
1328+
1329+
def registerConstraint(self, node, parent):
1330+
"""Register a SMIRFF constraint type definition."""
1331+
constraint = ConstraintGenerator.ConstraintType(node, parent)
1332+
self._constraint_types.append(constraint)
1333+
1334+
@staticmethod
1335+
def parseElement(element, ff):
1336+
# Find existing force generator or create new one.
1337+
existing = [f for f in ff._forces if isinstance(f, ConstraintGenerator)]
1338+
if len(existing) == 0:
1339+
generator = ConstraintGenerator(ff)
1340+
ff.registerGenerator(generator)
1341+
else:
1342+
generator = existing[0]
1343+
1344+
# Register all SMIRFF constraint definitions.
1345+
for constraint in element.findall('Constraint'):
1346+
generator.registerConstraint(constraint, element)
1347+
1348+
def createForce(self, system, topology, verbose=False, **kwargs):
1349+
# Iterate over all defined constraint types, allowing later matches to override earlier ones.
1350+
constraints = ValenceDict()
1351+
for constraint in self._constraint_types:
1352+
for atom_indices in topology.unrollSMIRKSMatches(constraint.smirks, aromaticity_model = self.ff._aromaticity_model):
1353+
constraints[atom_indices] = constraint
1354+
1355+
if verbose:
1356+
print('')
1357+
print('ConstraintGenerator:')
1358+
print('')
1359+
for constraint in self._constraint_types:
1360+
print('%64s : %8d matches' % (constraint.smirks, len(topology.unrollSMIRKSMatches(constraint.smirks, aromaticity_model = self.ff._aromaticity_model))))
1361+
print('')
1362+
1363+
for (atom_indices, constraint) in constraints.items():
1364+
# Update constrained atom pairs in topology
1365+
topology.constrainAtomPair(atom_indices[0], atom_indices[1], constraint.distance)
1366+
# If a distance is specified, add the constraint here.
1367+
# Otherwise, the equilibrium bond length will be used to constrain the atoms in HarmonicBondGenerator
1368+
if constraint.distance is not True:
1369+
system.addConstraint(atom_indices[0], atom_indices[1], constraint.distance)
1370+
1371+
if verbose: print('%d constraints added' % (len(constraints)))
1372+
1373+
def labelForce(self, oemol, verbose=False, **kwargs):
1374+
"""Take a provided OEMol and parse Constraint terms for this molecule.
1375+
1376+
Parameters
1377+
----------
1378+
oemol : OEChem OEMol object for molecule to be examined
1379+
1380+
Returns
1381+
---------
1382+
force_terms: list
1383+
Returns a list of tuples, [ ([atom id 1, ... atom id N], parameter id, smirks) , (....), ... ] for all forces of this type which would be applied.
1384+
"""
1385+
1386+
# Iterate over all defined constraint SMIRKS, allowing later matches to override earlier ones.
1387+
constraints = ValenceDict()
1388+
for constraint in self._constraint_types:
1389+
for atom_indices in topology.unrollSMIRKSMatches(constraint.smirks, aromaticity_model = self.ff._aromaticity_model):
1390+
constraints[atom_indices] = constraint
1391+
1392+
if verbose:
1393+
print('')
1394+
print('ConstraintGenerator:')
1395+
print('')
1396+
for constraint in self._constraint_types:
1397+
print('%64s : %8d matches' % (constraint.smirks, len(topology.unrollSMIRKSMatches(constraint.smirks, aromaticity_model = self.ff._aromaticity_model))))
1398+
print('')
1399+
1400+
# Add all bonds to the output list
1401+
force_terms = []
1402+
for (atom_indices, constraint) in constraints.items():
1403+
force_terms.append( ([atom_indices[0], atom_indices[1]], constraint.pid, constraint.smirks) )
1404+
1405+
return force_terms
1406+
1407+
parsers["Constraints"] = ConstraintGenerator.parseElement
1408+
12461409
## @private
12471410
class HarmonicBondGenerator(object):
12481411
"""A HarmonicBondGenerator constructs a HarmonicBondForce."""
@@ -1327,14 +1490,16 @@ def createForce(self, system, topology, verbose=False, **kwargs):
13271490
print('')
13281491

13291492
# Add all bonds to the system.
1493+
skipped_constrained_bonds = 0 # keep track of how many bonds were constrained (and hence skipped)
13301494
for (atom_indices, bond) in bonds.items():
13311495
# Ensure atoms are actually bonded correct pattern in Topology
13321496
assert topology._isBonded(atom_indices[0], atom_indices[1]), 'Atom indices %d and %d are not bonded in topology' % (atom_indices[0], atom_indices[1])
13331497

1498+
# Compute equilibrium bond length and spring constant.
13341499
if bond.fractional_bondorder==None:
1335-
force.addBond(atom_indices[0], atom_indices[1], bond.length, bond.k)
1336-
# If this bond uses partial bond orders
1500+
[k, length] = [bond.k, bond.length]
13371501
else:
1502+
# This bond uses partial bond orders
13381503
# Make sure forcefield asks for fractional bond orders
13391504
if not self.ff._use_fractional_bondorder:
13401505
raise ValueError("Error: your forcefield file does not request to use fractional bond orders in its header, but a harmonic bond attempts to use them.")
@@ -1343,12 +1508,25 @@ def createForce(self, system, topology, verbose=False, **kwargs):
13431508
if bond.fractional_bondorder=='interpolate-linear':
13441509
k = bond.k[0] + (bond.k[1]-bond.k[0])*(order-1.)
13451510
length = bond.length[0] + (bond.length[1]-bond.length[0])*(order-1.)
1346-
force.addBond(atom_indices[0], atom_indices[1], length, k)
1347-
if verbose: print("%64s" % "Added %s bond, order %.2f; length=%.2g; k=%.2g" % (bond.smirks, order, length, k))
13481511
else:
13491512
raise Exception("Partial bondorder treatment %s is not implemented." % bond.fractional_bondorder)
13501513

1351-
if verbose: print('%d bonds added' % (len(bonds)))
1514+
# Handle constraints.
1515+
if topology.atomPairIsConstrained(*atom_indices):
1516+
# Atom pair is constrained; we don't need to add a bond term.
1517+
skipped_constrained_bonds += 1
1518+
# Check if we need to add the constraint here to the equilibrium bond length.
1519+
if topology.atomPairIsConstrained(*atom_indices) is True:
1520+
# Mark that we have now assigned a specific constraint distance to this constraint.
1521+
topology.constrainAtomPair(atom_indices[0], atom_indices[1], length)
1522+
# Add the constraint to the System.
1523+
system.addConstraint(atom_indices[0], atom_indices[1], length)
1524+
continue
1525+
1526+
# Add bond
1527+
force.addBond(atom_indices[0], atom_indices[1], length, k)
1528+
1529+
if verbose: print('%d bonds added (%d skipped due to constraints)' % (len(bonds) - skipped_constrained_bonds, skipped_constrained_bonds))
13521530

13531531
# Check that no topological bonds are missing force parameters
13541532
_check_for_missing_valence_terms('HarmonicBondForce', topology, bonds.keys(), topology.bonds())
@@ -1455,14 +1633,20 @@ def createForce(self, system, topology, verbose=False, **kwargs):
14551633
print('')
14561634

14571635
# Add all angles to the system.
1636+
skipped_constrained_angles = 0 # keep track of how many angles were constrained (and hence skipped)
14581637
for (atom_indices, angle) in angles.items():
14591638
# Ensure atoms are actually bonded correct pattern in Topology
14601639
assert topology._isBonded(atom_indices[0], atom_indices[1]), 'Atom indices %d and %d are not bonded in topology' % (atom_indices[0], atom_indices[1])
14611640
assert topology._isBonded(atom_indices[1], atom_indices[2]), 'Atom indices %d and %d are not bonded in topology' % (atom_indices[1], atom_indices[2])
14621641

1642+
if topology.atomPairIsConstrained(atom_indices[0], atom_indices[1]) and topology.atomPairIsConstrained(atom_indices[1], atom_indices[2]) and topology.atomPairIsConstrained(atom_indices[0], atom_indices[2]):
1643+
# Angle is constrained; we don't need to add an angle term.
1644+
skipped_constrained_angles += 1
1645+
continue
1646+
14631647
force.addAngle(atom_indices[0], atom_indices[1], atom_indices[2], angle.angle, angle.k)
14641648

1465-
if verbose: print('%d angles added' % (len(angles)))
1649+
if verbose: print('%d angles added (%d skipped due to constraints)' % (len(angles) - skipped_constrained_angles, skipped_constrained_angles))
14661650

14671651
# Check that no topological angles are missing force parameters
14681652
_check_for_missing_valence_terms('HarmonicAngleForce', topology, angles.keys(), topology.angles())

0 commit comments

Comments
 (0)