Skip to content

Commit fdf8a27

Browse files
Merge pull request #665 from openforcefield/write-settles
Write `[ settles ]`
2 parents 0114c35 + 9f7d316 commit fdf8a27

File tree

8 files changed

+186
-14
lines changed

8 files changed

+186
-14
lines changed

docs/releasehistory.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Please note that all releases prior to a version 1.0.0 are considered pre-releas
3131
* #649 Removes the use of `pkg_resources`, which is deprecated.
3232
* #660 Moves the contents of `openff.interchange.components.foyer` to `openff.interchange.foyer` while maintaining existing import paths.
3333
* #663 Improves the performance of `Interchange.to_prmtop`.
34+
* #665 Properly write `[ settes ]` directive in GROMACS files.
3435

3536
## 0.3.0 - 2023-04-10
3637

openff/interchange/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import importlib
33
from types import ModuleType
44

5-
from openff.interchange._version import get_versions # type: ignore
5+
from openff.interchange._version import get_versions
66
from openff.interchange.components.interchange import Interchange
77

88
# Handle versioneer

openff/interchange/_tests/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ def acetaldehyde(self):
285285

286286
@pytest.fixture()
287287
def water(self):
288-
return Molecule.from_mapped_smiles("[H:1][O:2][H:3]")
288+
return Molecule.from_mapped_smiles("[H:2][O:1][H:3]")
289289

290290

291291
HAS_GROMACS = _find_gromacs_executable() is not None

openff/interchange/_tests/unit_tests/smirnoff/test_create.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def test_sage_tip3p_charges(self, water, sage):
5555
out = Interchange.from_smirnoff(force_field=sage, topology=[water])
5656
found_charges = [v.m for v in out["Electrostatics"].charges.values()]
5757

58-
assert numpy.allclose(found_charges, [0.417, -0.834, 0.417])
58+
assert numpy.allclose(found_charges, [-0.834, 0.417, 0.417])
5959

6060
def test_infer_positions(self, sage):
6161
from openff.toolkit.tests.create_molecules import create_ethanol

openff/interchange/_tests/unit_tests/smirnoff/test_gromacs.py

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
"""Test SMIRNOFF-GROMACS conversion."""
2+
import pytest
23
from openff.toolkit import Molecule
4+
from openff.units import unit
35

46
from openff.interchange import Interchange
57
from openff.interchange._tests import _BaseTest
6-
from openff.interchange.smirnoff._gromacs import _convert
8+
from openff.interchange.interop.gromacs.models.models import GROMACSMolecule
9+
from openff.interchange.smirnoff._gromacs import (
10+
_convert,
11+
_convert_angles,
12+
_convert_bonds,
13+
_convert_settles,
14+
)
715

816

917
class TestConvert(_BaseTest):
@@ -25,3 +33,86 @@ def test_residue_names(self, sage):
2533
for molecule_type in system.molecule_types.values():
2634
for atom in molecule_type.atoms:
2735
assert atom.residue_name == "LIG"
36+
37+
38+
class TestSettles(_BaseTest):
39+
@pytest.fixture()
40+
def tip3p_interchange(self, tip3p, water):
41+
return tip3p.create_interchange(water.to_topology())
42+
43+
def test_catch_other_water_ordering(self, tip3p):
44+
molecule = Molecule.from_mapped_smiles("[H:1][O:2][H:3]")
45+
interchange = tip3p.create_interchange(molecule.to_topology())
46+
47+
with pytest.raises(Exception, match="OHH"):
48+
_convert_settles(
49+
GROMACSMolecule(name="foo"),
50+
interchange.topology.molecule(0),
51+
interchange,
52+
)
53+
54+
def test_convert_settles(self, tip3p_interchange):
55+
molecule = GROMACSMolecule(name="foo")
56+
57+
_convert_settles(
58+
molecule,
59+
tip3p_interchange.topology.molecule(0),
60+
tip3p_interchange,
61+
)
62+
63+
assert len(molecule.settles) == 1
64+
65+
settle = molecule.settles[0]
66+
67+
assert settle.first_atom == 1
68+
assert settle.hydrogen_hydrogen_distance.m_as(unit.angstrom) == pytest.approx(
69+
1.5139006545247014,
70+
)
71+
assert settle.oxygen_hydrogen_distance.m_as(unit.angstrom) == pytest.approx(
72+
0.9572,
73+
)
74+
75+
assert molecule.exclusions[0].first_atom == 1
76+
assert molecule.exclusions[0].other_atoms == [2, 3]
77+
assert molecule.exclusions[1].first_atom == 2
78+
assert molecule.exclusions[1].other_atoms == [3]
79+
80+
def test_convert_no_settles_unconstrained_water(self, tip3p_interchange):
81+
tip3p_interchange.collections["Constraints"].key_map = dict()
82+
83+
molecule = GROMACSMolecule(name="foo")
84+
85+
_convert_settles(
86+
molecule,
87+
tip3p_interchange.topology.molecule(0),
88+
tip3p_interchange,
89+
)
90+
91+
assert len(molecule.settles) == 0
92+
93+
def test_convert_no_settles_no_constraints(self, tip3p_interchange):
94+
tip3p_interchange.collections.pop("Constraints")
95+
96+
molecule = GROMACSMolecule(name="foo")
97+
98+
_convert_settles(
99+
molecule,
100+
tip3p_interchange.topology.molecule(0),
101+
tip3p_interchange,
102+
)
103+
104+
assert len(molecule.settles) == 0
105+
106+
def test_no_bonds_or_angles_if_settle(self, tip3p_interchange):
107+
molecule = GROMACSMolecule(name="foo")
108+
109+
for function in [_convert_settles, _convert_bonds, _convert_angles]:
110+
function(
111+
molecule,
112+
tip3p_interchange.topology.molecule(0),
113+
tip3p_interchange,
114+
)
115+
116+
assert len(molecule.settles) == 1
117+
assert len(molecule.angles) == 0
118+
assert len(molecule.bonds) == 0

openff/interchange/_version.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
# type: ignore # noqa
2-
31
# This file helps to compute a version number in source trees obtained from
42
# git-archive tarball (such as those provided by githubs download-from-tag
53
# feature). Distribution tarballs (built by setup.py sdist) and build
@@ -16,6 +14,7 @@
1614
import re
1715
import subprocess
1816
import sys
17+
from typing import Any
1918

2019

2120
def get_keywords():
@@ -53,8 +52,8 @@ class NotThisMethod(Exception):
5352
"""Exception raised if a method is not valid for the current scenario."""
5453

5554

56-
LONG_VERSION_PY = {}
57-
HANDLERS = {}
55+
LONG_VERSION_PY: dict = dict()
56+
HANDLERS: dict = {}
5857

5958

6059
def register_vcs_handler(vcs, method): # decorator
@@ -506,7 +505,7 @@ def render(pieces, style):
506505
}
507506

508507

509-
def get_versions():
508+
def get_versions() -> dict[str, Any]:
510509
"""Get version information or return default if unable to do so."""
511510
# I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have
512511
# __file__, we can work backwards from there to the root. Some

openff/interchange/smirnoff/_gromacs.py

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import itertools
12
from typing import Optional
23

34
from openff.toolkit.topology.molecule import Atom, Molecule
@@ -16,15 +17,17 @@
1617
GROMACSAngle,
1718
GROMACSAtom,
1819
GROMACSBond,
20+
GROMACSExclusion,
1921
GROMACSMolecule,
2022
GROMACSPair,
23+
GROMACSSettles,
2124
GROMACSSystem,
2225
LennardJonesAtomType,
2326
PeriodicImproperDihedral,
2427
PeriodicProperDihedral,
2528
RyckaertBellemansDihedral,
2629
)
27-
from openff.interchange.models import TopologyKey
30+
from openff.interchange.models import BondKey, TopologyKey
2831

2932

3033
def _convert(interchange: Interchange) -> GROMACSSystem:
@@ -171,12 +174,12 @@ def _convert(interchange: Interchange) -> GROMACSSystem:
171174
else:
172175
raise NotImplementedError()
173176

177+
_convert_settles(molecule, unique_molecule, interchange)
174178
_convert_bonds(molecule, unique_molecule, interchange)
175179
_convert_angles(molecule, unique_molecule, interchange)
176180
# pairs
177181
_convert_dihedrals(molecule, unique_molecule, interchange)
178-
# settles?
179-
# constraints?
182+
# other constraints?
180183

181184
system.molecule_types[unique_molecule.name] = molecule
182185

@@ -199,6 +202,9 @@ def _convert_bonds(
199202
unique_molecule: "Molecule",
200203
interchange: Interchange,
201204
):
205+
if len(molecule.settles) > 0:
206+
return
207+
202208
collection = interchange["Bonds"]
203209

204210
for bond in unique_molecule.bonds:
@@ -246,6 +252,9 @@ def _convert_angles(
246252
unique_molecule: "Molecule",
247253
interchange: Interchange,
248254
):
255+
if len(molecule.settles) > 0:
256+
return
257+
249258
collection = interchange["Angles"]
250259

251260
for angle in unique_molecule.angles:
@@ -407,3 +416,75 @@ def _convert_dihedrals(
407416
multiplicity=int(params["periodicity"]),
408417
),
409418
)
419+
420+
421+
def _convert_settles(
422+
molecule: GROMACSMolecule,
423+
unique_molecule: "Molecule",
424+
interchange: Interchange,
425+
):
426+
if "Constraints" not in interchange.collections:
427+
return
428+
429+
if not unique_molecule.is_isomorphic_with(Molecule.from_smiles("O")):
430+
return
431+
432+
if unique_molecule.atom(0).atomic_number != 8:
433+
raise Exception(
434+
"Writing `[ settles ]` assumes water is ordered as OHH. Please raise an issue "
435+
"if you would benefit from this assumption changing.",
436+
)
437+
438+
topology_atom_indices = [
439+
interchange.topology.atom_index(atom) for atom in unique_molecule.atoms
440+
]
441+
442+
constraint_lengths = set()
443+
444+
for atom_pair in itertools.combinations(topology_atom_indices, 2):
445+
key = BondKey(atom_indices=atom_pair)
446+
447+
if key not in interchange["Constraints"].key_map:
448+
return
449+
450+
try:
451+
constraint_lengths.add(
452+
interchange["Bonds"]
453+
.potentials[interchange["Bonds"].key_map[key]]
454+
.parameters["length"],
455+
)
456+
# KeyError (subclass of LookupErrorR) when this BondKey is not found in the bond collection
457+
# LookupError for the Interchange not having a bond collection
458+
# in either case, look to the constraint collection for this distance
459+
except LookupError:
460+
constraint_lengths.add(
461+
interchange["Constraints"]
462+
.constraints[interchange["Constraints"].key_map[key]]
463+
.parameters["distance"],
464+
)
465+
466+
if len(constraint_lengths) != 2:
467+
raise RuntimeError(
468+
"Found three unique constraint lengths in constrained water.",
469+
)
470+
471+
molecule.settles.append(
472+
GROMACSSettles(
473+
first_atom=1, # TODO: documentation unclear on if this is first or oxygen
474+
oxygen_hydrogen_distance=min(constraint_lengths),
475+
hydrogen_hydrogen_distance=max(constraint_lengths),
476+
),
477+
)
478+
479+
molecule.exclusions.append(
480+
GROMACSExclusion(
481+
first_atom=1,
482+
other_atoms=[2, 3],
483+
),
484+
)
485+
molecule.exclusions.append(
486+
GROMACSExclusion(
487+
first_atom=2,
488+
other_atoms=[3],
489+
),
490+
)

openff/interchange/smirnoff/_valence.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ class SMIRNOFFConstraintCollection(SMIRNOFFCollection):
287287
expression: Literal[""] = ""
288288
constraints: dict[
289289
PotentialKey,
290-
bool,
290+
Potential,
291291
] = dict() # should this be named potentials for consistency?
292292

293293
@classmethod
@@ -390,7 +390,7 @@ def store_constraints(
390390
"distance": distance,
391391
},
392392
)
393-
self.constraints[potential_key] = potential # type: ignore[assignment]
393+
self.constraints[potential_key] = potential
394394

395395

396396
class SMIRNOFFAngleCollection(SMIRNOFFCollection, AngleCollection):

0 commit comments

Comments
 (0)