diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index 86aade73b..ed5b2b402 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -88,6 +88,10 @@ organisation on `GitHub `__. ``NonbondedForce`` alone, i.e. without softening, and without the subtraction of the coulomb energy that the softcore replaces. +* Fixed ``Dynamics.current_potential_energy()`` returning a stale value after a + dynamics block that didn't save energies, since the context's energy cache was + only invalidated when an energy was recorded. + `2026.1.0 `__ - June 2026 ----------------------------------------------------------------------------------------- diff --git a/src/sire/mol/_dynamics.py b/src/sire/mol/_dynamics.py index c34ae4492..383300078 100644 --- a/src/sire/mol/_dynamics.py +++ b/src/sire/mol/_dynamics.py @@ -445,6 +445,10 @@ def _exit_dynamics_block( self._omm_state = self._omm_mols.getState(getEnergy=True) self._omm_state_has_cv = (False, False) + # dynamics has advanced the positions without going through + # setPositions(), so the context's energy cache is stale + self._omm_mols.clear_energy_cache() + current_time = ( self._omm_state.getTime().value_in_unit(openmm.unit.nanosecond) * nanosecond ) @@ -511,10 +515,6 @@ def _exit_dynamics_block( nrg_sim_lambda_value = nrg if lambda_windows is not None: - # Positions have just changed (dynamics completed), so - # invalidate all cached per-group energies before the scan. - self._omm_mols.clear_energy_cache() - # get the index of the simulation lambda value in the # lambda windows list try: @@ -574,10 +574,6 @@ def _exit_dynamics_block( self._nrgs = nrgs self._nrgs_array = nrgs_array - # Repex synchronisation point: a peer replica may push new - # positions into this context, so the cache must be invalidated. - self._omm_mols.clear_energy_cache() - # update the interpolation lambda value if self._is_interpolate: if delta_lambda: diff --git a/tests/mol/test_dynamics.py b/tests/mol/test_dynamics.py index acda5d55d..7a65e1276 100644 --- a/tests/mol/test_dynamics.py +++ b/tests/mol/test_dynamics.py @@ -301,3 +301,42 @@ def potentials(traj): for r in range(num_replicas): assert len(cache_nrgs[r]) == num_cycles assert cache_nrgs[r] == ref_nrgs[r] + + +@pytest.mark.skipif( + "openmm" not in sr.convert.supported_formats(), + reason="openmm support is not available", +) +def test_energy_cache_cleared_after_dynamics(ala_mols): + """ + The context's energy cache must be invalidated after every dynamics block, + not just one that saved energies. The integrator advances the positions + without going through setPositions(), so nothing else clears it. + """ + import openmm + + mols = ala_mols + + d = mols.dynamics(timestep="1fs", temperature="300K", platform="Reference") + + def direct(): + return ( + d.context() + .getState(getEnergy=True) + .getPotentialEnergy() + .value_in_unit(openmm.unit.kilocalorie_per_mole) + ) + + assert d.current_potential_energy().value() == pytest.approx(direct()) + + # A block that doesn't record an energy. + d.run("50fs") + assert d.current_potential_energy().value() == pytest.approx(direct()) + + # A block that does. + d.run("50fs", energy_frequency="10fs") + assert d.current_potential_energy().value() == pytest.approx(direct()) + + # A block that doesn't, again, now that a trajectory exists. + d.run("50fs") + assert d.current_potential_energy().value() == pytest.approx(direct())