Skip to content

Commit b55022c

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

5 files changed

Lines changed: 412 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"

wrapper/Convert/SireOpenMM/openmmmolecule.cpp

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,6 +742,28 @@ void OpenMMMolecule::constructFromAmber(const Molecule &mol,
742742
this->unbonded_atoms.insert(i);
743743
}
744744

745+
// Detect virtual site (extra point) atoms by name.
746+
// Only 4- and 5-atom molecules can have EP atoms (TIP4P, OPC, TIP5P).
747+
// Supported names: AMBER EPW / EP1 / EP2, GROMACS MW / LP1 / LP2.
748+
// We remove them from unbonded_atoms so that buildExceptions does
749+
// not try to add an erroneous constraint for them.
750+
static const QSet<QString> vsite_names = {"EPW", "EP1", "EP2", "MW", "LP1", "LP2"};
751+
752+
QSet<int> vsite_idxs;
753+
754+
if (nats >= 4 and nats <= 5)
755+
{
756+
for (int i = 0; i < nats; ++i)
757+
{
758+
if (masses_data[i] < 0.05 and
759+
vsite_names.contains(mol.atom(SireMol::AtomIdx(i)).name().value()))
760+
{
761+
vsite_idxs.insert(i);
762+
unbonded_atoms.remove(i);
763+
}
764+
}
765+
}
766+
745767
// now the bonds
746768
const double bond_k_to_openmm = 2.0 * (SireUnits::kcal_per_mol / (SireUnits::angstrom * SireUnits::angstrom)).to(SireUnits::kJ_per_mol / (SireUnits::nanometer * SireUnits::nanometer));
747769
const double bond_r0_to_openmm = SireUnits::angstrom.to(SireUnits::nanometer);
@@ -825,6 +847,11 @@ void OpenMMMolecule::constructFromAmber(const Molecule &mol,
825847
if (atom0 > atom1)
826848
std::swap(atom0, atom1);
827849

850+
// Skip bonds involving virtual site atoms: their positions are
851+
// determined by OpenMM's VirtualSite machinery, not a HarmonicBondForce.
852+
if (vsite_idxs.contains(atom0) or vsite_idxs.contains(atom1))
853+
continue;
854+
828855
const double k = bondparam.k() * bond_k_to_openmm;
829856
const double r0 = bondparam.r0() * bond_r0_to_openmm;
830857
double r0_1 = r0;
@@ -972,6 +999,10 @@ void OpenMMMolecule::constructFromAmber(const Molecule &mol,
972999
if (atom0 > atom2)
9731000
std::swap(atom0, atom2);
9741001

1002+
// Skip angles involving virtual site atoms.
1003+
if (vsite_idxs.contains(atom0) or vsite_idxs.contains(atom1) or vsite_idxs.contains(atom2))
1004+
continue;
1005+
9751006
const double k = angparam.k() * angle_k_to_openmm;
9761007
const double theta0 = angparam.theta0(); // already in radians
9771008

@@ -1225,6 +1256,141 @@ void OpenMMMolecule::constructFromAmber(const Molecule &mol,
12251256
}
12261257
}
12271258

1259+
// Build virtual site definitions for any extra-point atoms detected above.
1260+
// Weights are derived from the actual atomic coordinates so that any 4- or
1261+
// 5-point water model (OPC, TIP4P, TIP5P, …) is handled automatically.
1262+
virtual_sites.clear();
1263+
1264+
if (not vsite_idxs.isEmpty())
1265+
{
1266+
// Map atom name → molecule-local index (first match wins).
1267+
QHash<QString, int> name_to_idx;
1268+
1269+
for (int i = 0; i < nats; ++i)
1270+
{
1271+
const QString name = mol.atom(SireMol::AtomIdx(i)).name().value();
1272+
1273+
if (not name_to_idx.contains(name))
1274+
name_to_idx.insert(name, i);
1275+
}
1276+
1277+
auto find_idx = [&](std::initializer_list<const char *> candidates) -> int
1278+
{
1279+
for (const char *n : candidates)
1280+
{
1281+
auto it = name_to_idx.find(QString(n));
1282+
1283+
if (it != name_to_idx.end())
1284+
return it.value();
1285+
}
1286+
1287+
return -1;
1288+
};
1289+
1290+
const int o_idx = find_idx({"O", "OW"});
1291+
const int h1_idx = find_idx({"H1", "HW1"});
1292+
const int h2_idx = find_idx({"H2", "HW2"});
1293+
1294+
if (o_idx >= 0 and h1_idx >= 0 and h2_idx >= 0)
1295+
{
1296+
const auto &O = coords[o_idx];
1297+
const auto &H1 = coords[h1_idx];
1298+
const auto &H2 = coords[h2_idx];
1299+
1300+
// Bisector vector in the molecular plane: (H1-O) + (H2-O)
1301+
const double bx = (H1[0] - O[0]) + (H2[0] - O[0]);
1302+
const double by = (H1[1] - O[1]) + (H2[1] - O[1]);
1303+
const double bz = (H1[2] - O[2]) + (H2[2] - O[2]);
1304+
const double bisect_sq = bx * bx + by * by + bz * bz;
1305+
1306+
// Cross product (H1-O) x (H2-O) — needed for out-of-plane sites (TIP5P)
1307+
const double d1x = H1[0] - O[0], d1y = H1[1] - O[1], d1z = H1[2] - O[2];
1308+
const double d2x = H2[0] - O[0], d2y = H2[1] - O[1], d2z = H2[2] - O[2];
1309+
const double cx = d1y * d2z - d1z * d2y;
1310+
const double cy = d1z * d2x - d1x * d2z;
1311+
const double cz = d1x * d2y - d1y * d2x;
1312+
const double cross_sq = cx * cx + cy * cy + cz * cz;
1313+
1314+
// ── 4-point water (OPC, TIP4P): single EP on the bisector ──────
1315+
const int ep_idx = find_idx({"EPW", "MW"});
1316+
1317+
if (ep_idx >= 0 and vsite_idxs.contains(ep_idx) and bisect_sq > 1e-20)
1318+
{
1319+
const auto &EP = coords[ep_idx];
1320+
const double ex = EP[0] - O[0];
1321+
const double ey = EP[1] - O[1];
1322+
const double ez = EP[2] - O[2];
1323+
const double a = (ex * bx + ey * by + ez * bz) / bisect_sq;
1324+
1325+
VirtualSiteInfo vs;
1326+
vs.type = VirtualSiteInfo::ThreeParticleAverage;
1327+
vs.vsite_idx = ep_idx;
1328+
vs.p1_idx = o_idx;
1329+
vs.p2_idx = h1_idx;
1330+
vs.p3_idx = h2_idx;
1331+
vs.w1 = 1.0 - 2.0 * a;
1332+
vs.w2 = a;
1333+
vs.w3 = a;
1334+
vs.w12 = vs.w13 = vs.wCross = 0.0;
1335+
virtual_sites.append(vs);
1336+
}
1337+
1338+
// ── 5-point water (TIP5P): two out-of-plane EPs ─────────────────
1339+
const int ep1_idx = find_idx({"EP1", "LP1"});
1340+
const int ep2_idx = find_idx({"EP2", "LP2"});
1341+
1342+
if (ep1_idx >= 0 and ep2_idx >= 0 and
1343+
vsite_idxs.contains(ep1_idx) and vsite_idxs.contains(ep2_idx) and
1344+
bisect_sq > 1e-20 and cross_sq > 1e-20)
1345+
{
1346+
const auto &EP1 = coords[ep1_idx];
1347+
const auto &EP2 = coords[ep2_idx];
1348+
1349+
// a = dot((EP1+EP2)/2 - O, bisect) / bisect_sq
1350+
const double mx = 0.5 * (EP1[0] + EP2[0]) - O[0];
1351+
const double my = 0.5 * (EP1[1] + EP2[1]) - O[1];
1352+
const double mz = 0.5 * (EP1[2] + EP2[2]) - O[2];
1353+
const double a = (mx * bx + my * by + mz * bz) / bisect_sq;
1354+
1355+
// b = dot(EP1-EP2, cross) / (2 * cross_sq)
1356+
const double dx = EP1[0] - EP2[0];
1357+
const double dy = EP1[1] - EP2[1];
1358+
const double dz = EP1[2] - EP2[2];
1359+
const double b = (dx * cx + dy * cy + dz * cz) / (2.0 * cross_sq);
1360+
1361+
// EP1: wCross = +b
1362+
{
1363+
VirtualSiteInfo vs;
1364+
vs.type = VirtualSiteInfo::OutOfPlane;
1365+
vs.vsite_idx = ep1_idx;
1366+
vs.p1_idx = o_idx;
1367+
vs.p2_idx = h1_idx;
1368+
vs.p3_idx = h2_idx;
1369+
vs.w1 = vs.w2 = vs.w3 = 0.0;
1370+
vs.w12 = a;
1371+
vs.w13 = a;
1372+
vs.wCross = b;
1373+
virtual_sites.append(vs);
1374+
}
1375+
1376+
// EP2: wCross = -b
1377+
{
1378+
VirtualSiteInfo vs;
1379+
vs.type = VirtualSiteInfo::OutOfPlane;
1380+
vs.vsite_idx = ep2_idx;
1381+
vs.p1_idx = o_idx;
1382+
vs.p2_idx = h1_idx;
1383+
vs.p3_idx = h2_idx;
1384+
vs.w1 = vs.w2 = vs.w3 = 0.0;
1385+
vs.w12 = a;
1386+
vs.w13 = a;
1387+
vs.wCross = -b;
1388+
virtual_sites.append(vs);
1389+
}
1390+
}
1391+
}
1392+
}
1393+
12281394
this->buildExceptions(mol, constrained_pairs, map);
12291395
}
12301396

0 commit comments

Comments
 (0)