Skip to content

Commit 3d54be2

Browse files
authored
Merge pull request #409 from OpenBioSim/feature_openmm_cmap
Add OpenMM CMAP support
2 parents 5de3c7c + 7105611 commit 3d54be2

10 files changed

Lines changed: 688 additions & 6 deletions

File tree

corelib/src/libs/SireSystem/merge.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ namespace SireSystem
131131
"atomtype",
132132
"bond",
133133
"charge",
134+
"cmap",
134135
"connectivity",
135136
"coordinates",
136137
"dihedral",

doc/source/changelog.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ organisation on `GitHub <https://github.com/openbiosim/sire>`__.
2525

2626
* Fixed parsing of AMBER and GROMACS GLYCAM force field topologies.
2727

28+
* Add support for CMAP terms in the OpenMM conversion layer.
29+
30+
* Fix hang in ``sire.load`` function when shared GROMACS topology path is missing.
31+
2832
`2025.4.0 <https://github.com/openbiosim/sire/compare/2025.3.0...2025.4.0>`__ - February 2026
2933
---------------------------------------------------------------------------------------------
3034

src/sire/_load.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,16 @@ def _get_gromacs_dir():
7777

7878
if not os.path.exists(gromacs_tbz2):
7979
try:
80+
import socket
8081
import urllib.request
8182

82-
urllib.request.urlretrieve(f"{tutorial_url}/gromacs.tar.bz2", gromacs_tbz2)
83-
except Exception:
84-
# we cannot download - just give up
83+
with urllib.request.urlopen(
84+
f"{tutorial_url}/gromacs.tar.bz2", timeout=5
85+
) as response:
86+
with open(gromacs_tbz2, "wb") as f:
87+
f.write(response.read())
88+
except (Exception, socket.timeout):
89+
# we cannot download - continue without GROMACS
8590
return None
8691

8792
if not os.path.exists(gromacs_tbz2):
@@ -443,7 +448,7 @@ def load(
443448
gromacs_path = _get_gromacs_dir()
444449

445450
m = {
446-
"GROMACS_PATH": _get_gromacs_dir(),
451+
"GROMACS_PATH": gromacs_path,
447452
"show_warnings": show_warnings,
448453
"parallel": parallel,
449454
"ignore_topology_frame": ignore_topology_frame,

tests/convert/test_openmm_cmap.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import sire as sr
2+
import pytest
3+
4+
5+
@pytest.mark.skipif(
6+
"openmm" not in sr.convert.supported_formats(),
7+
reason="openmm support is not available",
8+
)
9+
def test_openmm_cmap_energy(tmpdir, multichain_cmap, openmm_platform):
10+
"""
11+
Verify that Sire correctly adds CMAPTorsionForce to the OpenMM context by
12+
comparing the total potential energy against a context built directly via
13+
the OpenMM Python API from the same input files.
14+
15+
The multichain_cmap fixture is a periodic solvated system with three protein
16+
chains, each carrying CMAP backbone correction terms. Using it exercises the
17+
multi-molecule CMAP code path in the conversion layer.
18+
"""
19+
import openmm
20+
import openmm.app
21+
import openmm.unit
22+
23+
mols = sr.system.System()
24+
mols.add(multichain_cmap[0])
25+
mols.add(multichain_cmap[1])
26+
mols.add(multichain_cmap[2])
27+
28+
# Sanity-check: at least two molecules must carry CMAP so that the
29+
# multi-chain code path is exercised.
30+
cmap_mol_count = sum(1 for mol in mols.molecules() if mol.has_property("cmap"))
31+
assert (
32+
cmap_mol_count >= 2
33+
), "Expected at least two molecules with CMAP terms in multichain_cmap"
34+
35+
# Save the Sire system to AMBER files so the direct OpenMM path reads the
36+
# same topology and coordinates that Sire uses internally.
37+
dir_path = str(tmpdir.mkdir("cmap_omm"))
38+
prm7 = str(sr.save(mols, f"{dir_path}/system.prm7")[0])
39+
rst7 = str(sr.save(mols, f"{dir_path}/system.rst7")[0])
40+
41+
platform_name = openmm_platform or "CPU"
42+
43+
# Create an OpenMM context via Sire's conversion layer, then get the
44+
# potential energy.
45+
sire_map = {
46+
"constraint": "none",
47+
"cutoff": "none",
48+
"cutoff_type": "none",
49+
"platform": platform_name,
50+
}
51+
omm_sire = sr.convert.to(mols, "openmm", map=sire_map)
52+
sire_energy = (
53+
omm_sire.getState(getEnergy=True)
54+
.getPotentialEnergy()
55+
.value_in_unit(openmm.unit.kilojoules_per_mole)
56+
)
57+
58+
# Create an OpenMM context directly from the AMBER files and get the
59+
# potential energy.
60+
prmtop = openmm.app.AmberPrmtopFile(prm7)
61+
inpcrd = openmm.app.AmberInpcrdFile(rst7)
62+
63+
omm_system = prmtop.createSystem(
64+
nonbondedMethod=openmm.app.NoCutoff,
65+
constraints=None,
66+
)
67+
68+
integrator = openmm.VerletIntegrator(0.001)
69+
platform = openmm.Platform.getPlatformByName(platform_name)
70+
omm_context = openmm.Context(omm_system, integrator, platform)
71+
omm_context.setPositions(inpcrd.positions)
72+
if inpcrd.boxVectors is not None:
73+
omm_context.setPeriodicBoxVectors(*inpcrd.boxVectors)
74+
75+
direct_energy = (
76+
omm_context.getState(getEnergy=True)
77+
.getPotentialEnergy()
78+
.value_in_unit(openmm.unit.kilojoules_per_mole)
79+
)
80+
81+
# Energies should agree to within 1 kJ/mol.
82+
assert sire_energy == pytest.approx(direct_energy, abs=1.0)
83+
84+
85+
@pytest.mark.skipif(
86+
"openmm" not in sr.convert.supported_formats(),
87+
reason="openmm support is not available",
88+
)
89+
def test_openmm_cmap_perturbable(multichain_cmap, openmm_platform):
90+
"""
91+
Verify that CMAPTorsionForce grids are correctly handled for a perturbable
92+
molecule. The pre-merged stream file merged_molecule_cmap.s3 contains a
93+
perturbable molecule whose two end states are identical (an identity
94+
perturbation of a CHARMM protein chain), so both end states carry the same
95+
CMAP backbone correction terms. The test checks that the perturbable code
96+
path correctly applies the same grids at all lambda values and that
97+
set_lambda does not corrupt the force parameters.
98+
"""
99+
import openmm
100+
101+
platform_name = openmm_platform or "CPU"
102+
103+
mol0 = multichain_cmap[0]
104+
105+
mols_pert = sr.load_test_files("merged_molecule_cmap.s3")
106+
mols_pert = sr.morph.link_to_reference(mols_pert)
107+
108+
omm_map = {
109+
"constraint": "none",
110+
"cutoff": "none",
111+
"cutoff_type": "none",
112+
"platform": platform_name,
113+
}
114+
115+
def get_cmap_torsion_grids(context):
116+
"""
117+
Return list of (size, grid) for each CMAP torsion, dereferencing the
118+
map index. Grid values are returned as plain floats (kJ/mol) so that
119+
pytest.approx can compare them. This is map-count-agnostic: the
120+
non-perturbable path deduplicates maps while the perturbable path
121+
allocates one map per torsion, but the per-torsion grid values must
122+
agree.
123+
"""
124+
system = context.getSystem()
125+
for force in system.getForces():
126+
if isinstance(force, openmm.CMAPTorsionForce):
127+
maps = []
128+
for i in range(force.getNumMaps()):
129+
size, grid = force.getMapParameters(i)
130+
grid_floats = [
131+
v.value_in_unit(openmm.unit.kilojoules_per_mole) for v in grid
132+
]
133+
maps.append((size, grid_floats))
134+
result = []
135+
for t in range(force.getNumTorsions()):
136+
map_idx = force.getTorsionParameters(t)[0]
137+
result.append(maps[map_idx])
138+
return result
139+
return []
140+
141+
def unique_grids(torsion_grids, decimals=3):
142+
"""
143+
Return the sorted set of unique (size, rounded-grid) tuples.
144+
145+
Torsion ordering can differ between the perturbable and non-perturbable
146+
code paths, so we compare the sets of unique grid shapes rather than
147+
comparing torsion-by-torsion.
148+
"""
149+
seen = set()
150+
result = []
151+
for size, grid in torsion_grids:
152+
key = (size, tuple(round(v, decimals) for v in grid))
153+
if key not in seen:
154+
seen.add(key)
155+
result.append(key)
156+
return sorted(result)
157+
158+
# Reference: non-perturbable molecule.
159+
mols_ref = sr.system.System()
160+
mols_ref.add(mol0)
161+
omm_ref = sr.convert.to(mols_ref, "openmm", map=omm_map)
162+
ref_torsion_grids = get_cmap_torsion_grids(omm_ref)
163+
164+
assert len(ref_torsion_grids) > 0, "Reference context has no CMAP torsions"
165+
ref_unique = unique_grids(ref_torsion_grids)
166+
167+
# Perturbable context — one context, lambda changed in place.
168+
omm_pert = sr.convert.to(mols_pert, "openmm", map=omm_map)
169+
170+
# At both lambda=0 and lambda=1 the set of unique CMAP grids must match the
171+
# non-perturbable reference (cmap0 == cmap1 for an identity perturbation).
172+
# We compare sets of unique grids because the perturbable and non-perturbable
173+
# code paths may order torsions differently.
174+
for lam in (0.0, 1.0):
175+
omm_pert.set_lambda(lam)
176+
pert_torsion_grids = get_cmap_torsion_grids(omm_pert)
177+
178+
assert len(pert_torsion_grids) == len(ref_torsion_grids), (
179+
f"Wrong number of CMAP torsions at lambda={lam}: "
180+
f"{len(pert_torsion_grids)} != {len(ref_torsion_grids)}"
181+
)
182+
183+
pert_unique = unique_grids(pert_torsion_grids)
184+
assert pert_unique == ref_unique, (
185+
f"Set of unique CMAP grids differs from reference at lambda={lam}: "
186+
f"{len(pert_unique)} unique grids vs {len(ref_unique)} in reference"
187+
)

wrapper/Convert/SireOpenMM/PerturbableOpenMMMolecule.pypp.cpp

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -584,9 +584,64 @@ void register_PerturbableOpenMMMolecule_class(){
584584
, bp::release_gil_policy()
585585
, "Return the torsion phase parameters of the perturbed state" );
586586

587+
}
588+
{ //::SireOpenMM::PerturbableOpenMMMolecule::getCMAPGrids0
589+
590+
typedef ::QVector< double > const & ( ::SireOpenMM::PerturbableOpenMMMolecule::*getCMAPGrids0_function_type)( ) const;
591+
getCMAPGrids0_function_type getCMAPGrids0_function_value( &::SireOpenMM::PerturbableOpenMMMolecule::getCMAPGrids0 );
592+
593+
PerturbableOpenMMMolecule_exposer.def(
594+
"getCMAPGrids0"
595+
, getCMAPGrids0_function_value
596+
, bp::return_value_policy< bp::copy_const_reference >()
597+
, "Return the flat concatenated CMAP grid values (column-major, kJ/mol) "
598+
"for the reference state. Grid k has getCMAPGridSizes()[k]^2 entries." );
599+
600+
}
601+
{ //::SireOpenMM::PerturbableOpenMMMolecule::getCMAPGrids1
602+
603+
typedef ::QVector< double > const & ( ::SireOpenMM::PerturbableOpenMMMolecule::*getCMAPGrids1_function_type)( ) const;
604+
getCMAPGrids1_function_type getCMAPGrids1_function_value( &::SireOpenMM::PerturbableOpenMMMolecule::getCMAPGrids1 );
605+
606+
PerturbableOpenMMMolecule_exposer.def(
607+
"getCMAPGrids1"
608+
, getCMAPGrids1_function_value
609+
, bp::return_value_policy< bp::copy_const_reference >()
610+
, "Return the flat concatenated CMAP grid values (column-major, kJ/mol) "
611+
"for the perturbed state. Grid k has getCMAPGridSizes()[k]^2 entries." );
612+
613+
}
614+
{ //::SireOpenMM::PerturbableOpenMMMolecule::getCMAPGridSizes
615+
616+
typedef ::QVector< int > const & ( ::SireOpenMM::PerturbableOpenMMMolecule::*getCMAPGridSizes_function_type)( ) const;
617+
getCMAPGridSizes_function_type getCMAPGridSizes_function_value( &::SireOpenMM::PerturbableOpenMMMolecule::getCMAPGridSizes );
618+
619+
PerturbableOpenMMMolecule_exposer.def(
620+
"getCMAPGridSizes"
621+
, getCMAPGridSizes_function_value
622+
, bp::return_value_policy< bp::copy_const_reference >()
623+
, "Return the grid dimension N for each CMAP torsion (grid is N x N). "
624+
"Entries correspond to the grids in getCMAPGrids0/1." );
625+
626+
}
627+
{ //::SireOpenMM::PerturbableOpenMMMolecule::getCMAPAtoms
628+
629+
typedef ::QVector< boost::tuples::tuple< int, int, int, int, int,
630+
boost::tuples::null_type, boost::tuples::null_type, boost::tuples::null_type,
631+
boost::tuples::null_type, boost::tuples::null_type > >
632+
( ::SireOpenMM::PerturbableOpenMMMolecule::*getCMAPAtoms_function_type)( ) const;
633+
getCMAPAtoms_function_type getCMAPAtoms_function_value( &::SireOpenMM::PerturbableOpenMMMolecule::getCMAPAtoms );
634+
635+
PerturbableOpenMMMolecule_exposer.def(
636+
"getCMAPAtoms"
637+
, getCMAPAtoms_function_value
638+
, bp::release_gil_policy()
639+
, "Return the molecule-local 5-atom indices for each CMAP torsion, "
640+
"in the same order as getCMAPGridSizes(). Used for REST2 scaling." );
641+
587642
}
588643
{ //::SireOpenMM::PerturbableOpenMMMolecule::isGhostAtom
589-
644+
590645
typedef bool ( ::SireOpenMM::PerturbableOpenMMMolecule::*isGhostAtom_function_type)( int ) const;
591646
isGhostAtom_function_type isGhostAtom_function_value( &::SireOpenMM::PerturbableOpenMMMolecule::isGhostAtom );
592647

wrapper/Convert/SireOpenMM/lambdalever.cpp

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1107,6 +1107,27 @@ PropertyList LambdaLever::getLeverValues(const QVector<double> &lambda_values,
11071107
idx += 1;
11081108
}
11091109

1110+
const auto morphed_cmap_grid = cache.morph(
1111+
schedule,
1112+
"cmap", "cmap_grid",
1113+
mol.getCMAPGrids0(),
1114+
mol.getCMAPGrids1());
1115+
1116+
if (is_first)
1117+
{
1118+
for (int i = 0; i < morphed_cmap_grid.count(); ++i)
1119+
{
1120+
column_names.append(QString("cmap-cmap_grid-%1").arg(i + 1));
1121+
lever_values.append(QVector<double>());
1122+
}
1123+
}
1124+
1125+
for (const auto &val : morphed_cmap_grid)
1126+
{
1127+
lever_values[idx].append(val);
1128+
idx += 1;
1129+
}
1130+
11101131
is_first = false;
11111132
}
11121133

@@ -1156,6 +1177,7 @@ double LambdaLever::setLambda(OpenMM::Context &context,
11561177
auto bondff = this->getForce<OpenMM::HarmonicBondForce>("bond", system);
11571178
auto angff = this->getForce<OpenMM::HarmonicAngleForce>("angle", system);
11581179
auto dihff = this->getForce<OpenMM::PeriodicTorsionForce>("torsion", system);
1180+
auto cmapff = this->getForce<OpenMM::CMAPTorsionForce>("cmap", system);
11591181

11601182
// we know if we have peturbable ghost atoms if we have the ghost forcefields
11611183
const bool have_ghost_atoms = (ghost_ghostff != 0 or ghost_nonghostff != 0);
@@ -1781,6 +1803,59 @@ double LambdaLever::setLambda(OpenMM::Context &context,
17811803
morphed_torsion_k[j] * scale);
17821804
}
17831805
}
1806+
1807+
// update CMAP parameters for this perturbable molecule
1808+
start_index = start_idxs.value("cmap", -1);
1809+
1810+
if (start_index != -1 and cmapff != 0)
1811+
{
1812+
const auto &grid0 = perturbable_mol.getCMAPGrids0();
1813+
const auto &grid1 = perturbable_mol.getCMAPGrids1();
1814+
const auto &sizes = perturbable_mol.getCMAPGridSizes();
1815+
const auto &atoms = perturbable_mol.getCMAPAtoms();
1816+
1817+
// morph all grid values together using the lambda schedule
1818+
const auto morphed_grids = cache.morph(
1819+
schedule,
1820+
"cmap", "cmap_grid",
1821+
grid0,
1822+
grid1);
1823+
1824+
int offset = 0;
1825+
1826+
for (int j = 0; j < sizes.count(); ++j)
1827+
{
1828+
const int N = sizes[j];
1829+
const int map_size = N * N;
1830+
1831+
// CMAP is always a proper backbone dihedral pair, never improper.
1832+
// Apply REST2 scaling if all 5 atoms are within the REST2 region.
1833+
double scale = 1.0;
1834+
1835+
const auto &cmap_atms = atoms[j];
1836+
1837+
if (perturbable_mol.isRest2(boost::get<0>(cmap_atms)) and
1838+
perturbable_mol.isRest2(boost::get<1>(cmap_atms)) and
1839+
perturbable_mol.isRest2(boost::get<2>(cmap_atms)) and
1840+
perturbable_mol.isRest2(boost::get<3>(cmap_atms)) and
1841+
perturbable_mol.isRest2(boost::get<4>(cmap_atms)))
1842+
{
1843+
scale = rest2_scale;
1844+
}
1845+
1846+
std::vector<double> energy(map_size);
1847+
1848+
for (int k = 0; k < map_size; ++k)
1849+
{
1850+
energy[k] = morphed_grids[offset + k] * scale;
1851+
}
1852+
1853+
cmapff->setMapParameters(start_index + j, N, energy);
1854+
offset += map_size;
1855+
}
1856+
1857+
cmapff->updateParametersInContext(context);
1858+
}
17841859
}
17851860

17861861
// update the parameters in the context

0 commit comments

Comments
 (0)