Skip to content

Commit 80b6c98

Browse files
committed
Add support for 4- and 5-point water models with OpenMM.
1 parent 7ed107e commit 80b6c98

5 files changed

Lines changed: 422 additions & 0 deletions

File tree

doc/source/changelog.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ organisation on `GitHub <https://github.com/openbiosim/sire>`__.
2929

3030
* Fix hang in ``sire.load`` function when shared GROMACS topology path is missing.
3131

32+
* Add support for 4- and 5-point water models in the OpenMM conversion layer.
33+
3234
`2025.4.0 <https://github.com/openbiosim/sire/compare/2025.3.0...2025.4.0>`__ - February 2026
3335
---------------------------------------------------------------------------------------------
3436

tests/convert/test_openmm_water.py

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import pytest
2+
import sire as sr
3+
4+
_skip_no_openmm = pytest.mark.skipif(
5+
"openmm" not in sr.convert.supported_formats(),
6+
reason="openmm support is not available",
7+
)
8+
9+
10+
@pytest.fixture(scope="module")
11+
def tip3p_mols():
12+
return sr.load_test_files("tip3p.s3")
13+
14+
15+
@pytest.fixture(scope="module")
16+
def tip4p_mols():
17+
return sr.load_test_files("tip4p.s3")
18+
19+
20+
@pytest.fixture(scope="module")
21+
def tip5p_mols():
22+
return sr.load_test_files("tip5p.s3")
23+
24+
25+
@pytest.fixture(scope="module")
26+
def opc_mols():
27+
return sr.load_test_files("opc.s3")
28+
29+
30+
def _get_vsite_info(omm_context):
31+
"""Return (n_vsites, n_zero_mass, n_bad_constraints) for an OpenMM context."""
32+
import openmm
33+
34+
sys = omm_context.getSystem()
35+
natoms = sys.getNumParticles()
36+
37+
zero_mass = {
38+
i
39+
for i in range(natoms)
40+
if sys.getParticleMass(i).value_in_unit(openmm.unit.dalton) == 0.0
41+
}
42+
n_vsites = sum(1 for i in zero_mass if sys.isVirtualSite(i))
43+
44+
bad_constraints = sum(
45+
1
46+
for k in range(sys.getNumConstraints())
47+
if sys.getConstraintParameters(k)[0] in zero_mass
48+
or sys.getConstraintParameters(k)[1] in zero_mass
49+
)
50+
51+
return n_vsites, len(zero_mass), bad_constraints
52+
53+
54+
def _potential_energy(omm_context):
55+
import openmm
56+
57+
return (
58+
omm_context.getState(getEnergy=True)
59+
.getPotentialEnergy()
60+
.value_in_unit(openmm.unit.kilojoules_per_mole)
61+
)
62+
63+
64+
@_skip_no_openmm
65+
def test_tip3p_no_virtual_sites(tip3p_mols, openmm_platform):
66+
"""TIP3P has no EP atoms — no virtual sites should be registered."""
67+
omm_map = {
68+
"constraint": "h-bonds",
69+
"cutoff_type": "none",
70+
"cutoff": "none",
71+
"platform": openmm_platform or "CPU",
72+
}
73+
74+
omm = sr.convert.to(tip3p_mols, "openmm", map=omm_map)
75+
n_vsites, n_zero_mass, bad_constraints = _get_vsite_info(omm)
76+
77+
assert (
78+
n_zero_mass == 0
79+
), f"TIP3P should have no zero-mass particles, got {n_zero_mass}"
80+
assert n_vsites == 0, f"TIP3P should have no virtual sites, got {n_vsites}"
81+
assert bad_constraints == 0
82+
83+
e = _potential_energy(omm)
84+
assert e == e, "Potential energy is NaN"
85+
86+
87+
@_skip_no_openmm
88+
def test_tip4p_virtual_sites(tip4p_mols, openmm_platform):
89+
"""TIP4P: one ThreeParticleAverageSite per water, no bad constraints, finite energy."""
90+
import openmm
91+
92+
omm_map = {
93+
"constraint": "h-bonds",
94+
"cutoff_type": "none",
95+
"cutoff": "none",
96+
"platform": openmm_platform or "CPU",
97+
}
98+
99+
omm = sr.convert.to(tip4p_mols, "openmm", map=omm_map)
100+
n_vsites, n_zero_mass, bad_constraints = _get_vsite_info(omm)
101+
102+
n_waters = tip4p_mols.num_molecules()
103+
104+
assert (
105+
n_zero_mass == n_waters
106+
), f"Expected {n_waters} zero-mass EP atoms, got {n_zero_mass}"
107+
assert n_vsites == n_waters, f"Expected {n_waters} virtual sites, got {n_vsites}"
108+
assert bad_constraints == 0, f"Constraints on virtual sites: {bad_constraints}"
109+
110+
# All virtual sites should be ThreeParticleAverageSite
111+
sys = omm.getSystem()
112+
natoms = sys.getNumParticles()
113+
for i in range(natoms):
114+
if sys.isVirtualSite(i):
115+
vs = sys.getVirtualSite(i)
116+
assert isinstance(
117+
vs, openmm.ThreeParticleAverageSite
118+
), f"Particle {i}: expected ThreeParticleAverageSite, got {type(vs).__name__}"
119+
120+
e = _potential_energy(omm)
121+
assert e == e, "Potential energy is NaN"
122+
123+
124+
@_skip_no_openmm
125+
def test_opc_virtual_sites(opc_mols, openmm_platform):
126+
"""OPC: one ThreeParticleAverageSite per water, no bad constraints, finite energy."""
127+
import openmm
128+
129+
omm_map = {
130+
"constraint": "h-bonds",
131+
"cutoff_type": "none",
132+
"cutoff": "none",
133+
"platform": openmm_platform or "CPU",
134+
}
135+
136+
omm = sr.convert.to(opc_mols, "openmm", map=omm_map)
137+
n_vsites, n_zero_mass, bad_constraints = _get_vsite_info(omm)
138+
139+
n_waters = opc_mols.num_molecules()
140+
141+
assert (
142+
n_zero_mass == n_waters
143+
), f"Expected {n_waters} zero-mass EP atoms, got {n_zero_mass}"
144+
assert n_vsites == n_waters, f"Expected {n_waters} virtual sites, got {n_vsites}"
145+
assert bad_constraints == 0, f"Constraints on virtual sites: {bad_constraints}"
146+
147+
sys = omm.getSystem()
148+
natoms = sys.getNumParticles()
149+
for i in range(natoms):
150+
if sys.isVirtualSite(i):
151+
vs = sys.getVirtualSite(i)
152+
assert isinstance(
153+
vs, openmm.ThreeParticleAverageSite
154+
), f"Particle {i}: expected ThreeParticleAverageSite, got {type(vs).__name__}"
155+
156+
e = _potential_energy(omm)
157+
assert e == e, "Potential energy is NaN"
158+
159+
160+
@_skip_no_openmm
161+
def test_tip5p_virtual_sites(tip5p_mols, openmm_platform):
162+
"""TIP5P: two OutOfPlaneSite virtual sites per water, no bad constraints, finite energy."""
163+
import openmm
164+
165+
omm_map = {
166+
"constraint": "h-bonds",
167+
"cutoff_type": "none",
168+
"cutoff": "none",
169+
"platform": openmm_platform or "CPU",
170+
}
171+
172+
omm = sr.convert.to(tip5p_mols, "openmm", map=omm_map)
173+
n_vsites, n_zero_mass, bad_constraints = _get_vsite_info(omm)
174+
175+
n_waters = tip5p_mols.num_molecules()
176+
177+
assert (
178+
n_zero_mass == 2 * n_waters
179+
), f"Expected {2 * n_waters} zero-mass EP atoms, got {n_zero_mass}"
180+
assert (
181+
n_vsites == 2 * n_waters
182+
), f"Expected {2 * n_waters} virtual sites, got {n_vsites}"
183+
assert bad_constraints == 0, f"Constraints on virtual sites: {bad_constraints}"
184+
185+
sys = omm.getSystem()
186+
natoms = sys.getNumParticles()
187+
for i in range(natoms):
188+
if sys.isVirtualSite(i):
189+
vs = sys.getVirtualSite(i)
190+
assert isinstance(
191+
vs, openmm.OutOfPlaneSite
192+
), f"Particle {i}: expected OutOfPlaneSite, got {type(vs).__name__}"
193+
194+
e = _potential_energy(omm)
195+
assert e == e, "Potential energy is NaN"

0 commit comments

Comments
 (0)