Skip to content

Commit cf21f12

Browse files
authored
Merge pull request #412 from OpenBioSim/feature_openmm_vsite_water
Add support for 4- and 5-point water models with OpenMM
2 parents 7ed107e + c81b8d4 commit cf21f12

11 files changed

Lines changed: 678 additions & 8 deletions

File tree

corelib/src/libs/SireCAS/lambdaschedule.cpp

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ static RegisterMetaType<LambdaSchedule> r_schedule;
6565

6666
QDataStream &operator<<(QDataStream &ds, const LambdaSchedule &schedule)
6767
{
68-
writeHeader(ds, r_schedule, 3);
68+
writeHeader(ds, r_schedule, 4);
6969

7070
SharedDataStream sds(ds);
7171

@@ -75,6 +75,7 @@ QDataStream &operator<<(QDataStream &ds, const LambdaSchedule &schedule)
7575
<< schedule.default_equations
7676
<< schedule.stage_equations
7777
<< schedule.mol_schedules
78+
<< schedule.coupled_levers
7879
<< static_cast<const Property &>(schedule);
7980

8081
return ds;
@@ -91,21 +92,30 @@ QDataStream &operator>>(QDataStream &ds, LambdaSchedule &schedule)
9192
{
9293
VersionID v = readHeader(ds, r_schedule);
9394

94-
if (v == 1 or v == 2 or v == 3)
95+
if (v == 1 or v == 2 or v == 3 or v == 4)
9596
{
9697
SharedDataStream sds(ds);
9798

9899
sds >> schedule.constant_values;
99100

100-
if (v == 3)
101+
if (v >= 3)
101102
sds >> schedule.force_names;
102103

103104
sds >> schedule.lever_names >> schedule.stage_names >>
104105
schedule.default_equations >> schedule.stage_equations;
105106

106-
if (v == 2 or v == 3)
107+
if (v >= 2)
107108
sds >> schedule.mol_schedules;
108109

110+
if (v >= 4)
111+
sds >> schedule.coupled_levers;
112+
else
113+
{
114+
// Populate the default coupling for streams written before v4
115+
schedule.coupled_levers[_get_lever_name("cmap", "cmap_grid")] =
116+
_get_lever_name("torsion", "torsion_k");
117+
}
118+
109119
if (v < 3)
110120
{
111121
// need to make sure that the lever names are namespaced
@@ -146,13 +156,17 @@ QDataStream &operator>>(QDataStream &ds, LambdaSchedule &schedule)
146156
}
147157
}
148158
else
149-
throw version_error(v, "1, 2, 3", r_schedule, CODELOC);
159+
throw version_error(v, "1, 2, 3, 4", r_schedule, CODELOC);
150160

151161
return ds;
152162
}
153163

154164
LambdaSchedule::LambdaSchedule() : ConcreteProperty<LambdaSchedule, Property>()
155165
{
166+
// By default, cmap_grid falls back to torsion_k so that customising
167+
// torsion scaling automatically keeps the CMAP correction in sync.
168+
coupled_levers[_get_lever_name("cmap", "cmap_grid")] =
169+
_get_lever_name("torsion", "torsion_k");
156170
}
157171

158172
LambdaSchedule::LambdaSchedule(const LambdaSchedule &other)
@@ -162,7 +176,8 @@ LambdaSchedule::LambdaSchedule(const LambdaSchedule &other)
162176
force_names(other.force_names),
163177
lever_names(other.lever_names), stage_names(other.stage_names),
164178
default_equations(other.default_equations),
165-
stage_equations(other.stage_equations)
179+
stage_equations(other.stage_equations),
180+
coupled_levers(other.coupled_levers)
166181
{
167182
}
168183

@@ -1006,6 +1021,32 @@ void LambdaSchedule::removeEquation(const QString &stage,
10061021
this->stage_equations[idx].remove(_get_lever_name(force, lever));
10071022
}
10081023

1024+
/** Couple the lever 'force::lever' to 'fallback_force::fallback_lever'.
1025+
* If no custom equation has been set for 'force::lever' at any stage,
1026+
* the equation for 'fallback_force::fallback_lever' will be used instead
1027+
* of the stage default. This is a single level of indirection — the
1028+
* fallback lever is not itself followed further.
1029+
*
1030+
* By default, 'cmap::cmap_grid' is coupled to 'torsion::torsion_k' so
1031+
* that custom torsion schedules automatically keep the CMAP correction
1032+
* in sync.
1033+
*/
1034+
void LambdaSchedule::coupleLever(const QString &force, const QString &lever,
1035+
const QString &fallback_force,
1036+
const QString &fallback_lever)
1037+
{
1038+
coupled_levers[_get_lever_name(force, lever)] =
1039+
_get_lever_name(fallback_force, fallback_lever);
1040+
}
1041+
1042+
/** Remove any coupling for the lever 'force::lever', reverting it to
1043+
* use the stage default equation when no custom equation is set.
1044+
*/
1045+
void LambdaSchedule::removeCoupledLever(const QString &force, const QString &lever)
1046+
{
1047+
coupled_levers.remove(_get_lever_name(force, lever));
1048+
}
1049+
10091050
/** Return whether or not the specified 'lever' in the specified 'force'
10101051
* at the specified 'stage' has a custom equation set for it
10111052
*/
@@ -1077,6 +1118,32 @@ SireCAS::Expression LambdaSchedule::_getEquation(int stage,
10771118
return it.value();
10781119
}
10791120

1121+
// Check coupled levers: if this lever is coupled to another, try to
1122+
// find a custom equation for the coupled lever before falling back to
1123+
// the stage default. This is a single level of indirection (no recursion)
1124+
// to prevent loops.
1125+
auto coupled_it = this->coupled_levers.find(lever_name);
1126+
1127+
if (coupled_it != this->coupled_levers.end())
1128+
{
1129+
const auto &coupled_name = coupled_it.value();
1130+
const int sep = coupled_name.indexOf("::");
1131+
const QString coupled_force = sep >= 0 ? coupled_name.left(sep) : "*";
1132+
const QString coupled_lever_part = sep >= 0 ? coupled_name.mid(sep + 2) : coupled_name;
1133+
1134+
it = equations.find(coupled_name);
1135+
if (it != equations.end())
1136+
return it.value();
1137+
1138+
it = equations.find(_get_lever_name(coupled_force, "*"));
1139+
if (it != equations.end())
1140+
return it.value();
1141+
1142+
it = equations.find(_get_lever_name("*", coupled_lever_part));
1143+
if (it != equations.end())
1144+
return it.value();
1145+
}
1146+
10801147
// we don't have any match, so return the default equation for this stage
10811148
return this->default_equations[stage];
10821149
}

corelib/src/libs/SireCAS/lambdaschedule.h

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,12 @@ namespace SireCAS
164164
const QString &force = "*",
165165
const QString &lever = "*") const;
166166

167+
void coupleLever(const QString &force, const QString &lever,
168+
const QString &fallback_force,
169+
const QString &fallback_lever);
170+
171+
void removeCoupledLever(const QString &force, const QString &lever);
172+
167173
SireCAS::Expression getEquation(const QString &stage = "*",
168174
const QString &force = "*",
169175
const QString &lever = "*") const;
@@ -248,6 +254,12 @@ namespace SireCAS
248254
particular stage */
249255
QVector<QHash<QString, SireCAS::Expression>> stage_equations;
250256

257+
/** Coupled lever fallbacks: if a lever (force::lever) has no custom
258+
equation set, use the equation for the paired lever instead of
259+
falling straight back to the stage default. Stored as
260+
_get_lever_name(force, lever) → _get_lever_name(fb_force, fb_lever). */
261+
QHash<QString, QString> coupled_levers;
262+
251263
/** The symbol used to represent the lambda value */
252264
static SireCAS::Symbol lambda_symbol;
253265

doc/source/changelog.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ 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+
34+
* Add functionality for coupling one lambda lever to another.
35+
3236
`2025.4.0 <https://github.com/openbiosim/sire/compare/2025.3.0...2025.4.0>`__ - February 2026
3337
---------------------------------------------------------------------------------------------
3438

doc/source/tutorial/part07/02_levers.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,31 @@ In this case, as we have a perturbable system, the Force objects used are;
159159
It uses parameters that are controlled by the ``charge``, ``sigma``, ``epsilon``,
160160
``alpha``, ``kappa``, ``charge_scale`` and ``lj_scale`` levers.
161161

162+
For systems that use a force field with CMAP backbone torsion corrections (such
163+
as AMBER ff19SB), an additional Force object is present:
164+
165+
* ``cmap``: `OpenMM::CMAPTorsionForce <http://docs.openmm.org/latest/api-c++/generated/CMAPTorsionForce.html>`__.
166+
This models the cross-term energy correction (CMAP) for backbone φ/ψ dihedral
167+
pairs. It uses a parameter controlled by the ``cmap_grid`` lever, which
168+
interpolates the two-dimensional energy grid from its λ=0 value to its λ=1 value.
169+
170+
.. note::
171+
172+
By default, ``cmap::cmap_grid`` is coupled to ``torsion::torsion_k``:
173+
if you set a custom equation for ``torsion_k``, the same equation will
174+
automatically be used for ``cmap_grid``, keeping the CMAP correction
175+
in sync with the torsion perturbation. You can override this by
176+
setting an explicit equation for ``cmap::cmap_grid``::
177+
178+
# freeze CMAP at its λ=0 grid while still perturbing torsions
179+
s.set_equation(stage="morph", force="cmap", lever="cmap_grid",
180+
equation=s.initial())
181+
182+
To remove the coupling entirely (so that ``cmap_grid`` falls back to
183+
the stage default independently of ``torsion_k``)::
184+
185+
s.remove_coupled_lever(force="cmap", lever="cmap_grid")
186+
162187
Some levers, like ``bond_length``, are used only by a single Force object.
163188
However, others, like ``charge``, are used by multiple Force objects.
164189

src/sire/mol/_dynamics.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,11 +492,11 @@ def _exit_dynamics_block(
492492
zip(lambda_windows, rest2_scale_factors)
493493
):
494494
if lambda_value != sim_lambda_value:
495+
key = f"{lambda_value:.5f}"
495496
if (
496497
not has_lambda_index
497498
or abs(lambda_index - i) <= num_energy_neighbours
498499
):
499-
key = f"{lambda_value:.5f}"
500500
self._omm_mols.set_lambda(
501501
lambda_value,
502502
rest2_scale=rest2_scale,

tests/cas/test_lambdaschedule.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,105 @@ def test_lambdaschedule():
154154
def test_has_force_specific_equation(force, lever, contained):
155155
l = sr.cas.LambdaSchedule.standard_decouple()
156156
assert l.has_force_specific_equation("decouple", force, lever) == contained
157+
158+
159+
def test_coupled_lever_default():
160+
"""cmap::cmap_grid should follow torsion::torsion_k by default."""
161+
l = sr.cas.LambdaSchedule.standard_morph()
162+
morph_equation = (1 - l.lam()) * l.initial() + l.lam() * l.final()
163+
164+
# With no custom equations, both should return the stage default.
165+
_assert_same_equation(
166+
l.lam(),
167+
l.get_equation(stage="morph", force="torsion", lever="torsion_k"),
168+
morph_equation,
169+
)
170+
_assert_same_equation(
171+
l.lam(),
172+
l.get_equation(stage="morph", force="cmap", lever="cmap_grid"),
173+
morph_equation,
174+
)
175+
176+
177+
def test_coupled_lever_follows_torsion_k():
178+
"""Setting a custom torsion_k equation should automatically apply to cmap_grid."""
179+
l = sr.cas.LambdaSchedule.standard_morph()
180+
custom_eq = l.lam() ** 2 * l.final() + (1 - l.lam() ** 2) * l.initial()
181+
182+
l.set_equation(
183+
stage="morph", force="torsion", lever="torsion_k", equation=custom_eq
184+
)
185+
186+
# cmap_grid should now follow the custom torsion_k equation via coupling.
187+
_assert_same_equation(
188+
l.lam(),
189+
l.get_equation(stage="morph", force="cmap", lever="cmap_grid"),
190+
custom_eq,
191+
)
192+
193+
194+
def test_coupled_lever_explicit_override():
195+
"""An explicit cmap_grid equation should take precedence over the coupling."""
196+
l = sr.cas.LambdaSchedule.standard_morph()
197+
torsion_eq = l.lam() ** 2 * l.final() + (1 - l.lam() ** 2) * l.initial()
198+
cmap_eq = l.initial() # freeze CMAP at λ=0
199+
200+
l.set_equation(
201+
stage="morph", force="torsion", lever="torsion_k", equation=torsion_eq
202+
)
203+
l.set_equation(stage="morph", force="cmap", lever="cmap_grid", equation=cmap_eq)
204+
205+
# cmap_grid should use its own explicit equation, not torsion_k's.
206+
_assert_same_equation(
207+
l.lam(),
208+
l.get_equation(stage="morph", force="cmap", lever="cmap_grid"),
209+
cmap_eq,
210+
)
211+
# torsion_k should be unaffected.
212+
_assert_same_equation(
213+
l.lam(),
214+
l.get_equation(stage="morph", force="torsion", lever="torsion_k"),
215+
torsion_eq,
216+
)
217+
218+
219+
def test_remove_coupled_lever():
220+
"""Removing the coupling makes cmap_grid fall back to the stage default."""
221+
l = sr.cas.LambdaSchedule.standard_morph()
222+
morph_equation = (1 - l.lam()) * l.initial() + l.lam() * l.final()
223+
custom_eq = l.lam() ** 2 * l.final() + (1 - l.lam() ** 2) * l.initial()
224+
225+
l.set_equation(
226+
stage="morph", force="torsion", lever="torsion_k", equation=custom_eq
227+
)
228+
l.remove_coupled_lever(force="cmap", lever="cmap_grid")
229+
230+
# cmap_grid should now use the stage default, not follow torsion_k.
231+
_assert_same_equation(
232+
l.lam(),
233+
l.get_equation(stage="morph", force="cmap", lever="cmap_grid"),
234+
morph_equation,
235+
)
236+
237+
238+
def test_couple_lever_custom():
239+
"""coupleLever can set an arbitrary coupling between levers."""
240+
l = sr.cas.LambdaSchedule.standard_morph()
241+
custom_eq = l.lam() ** 2 * l.final() + (1 - l.lam() ** 2) * l.initial()
242+
243+
# Couple bond_k to torsion_k (unusual, but should work).
244+
l.couple_lever(
245+
force="bond",
246+
lever="bond_k",
247+
fallback_force="torsion",
248+
fallback_lever="torsion_k",
249+
)
250+
l.set_equation(
251+
stage="morph", force="torsion", lever="torsion_k", equation=custom_eq
252+
)
253+
254+
_assert_same_equation(
255+
l.lam(),
256+
l.get_equation(stage="morph", force="bond", lever="bond_k"),
257+
custom_eq,
258+
)

0 commit comments

Comments
 (0)