Skip to content

Commit 02eaf49

Browse files
authored
Merge pull request #376 from OpenBioSim/feature_crash_report
Add support for dynamics crash reports
2 parents 293498c + df06d02 commit 02eaf49

File tree

3 files changed

+117
-2
lines changed

3 files changed

+117
-2
lines changed

doc/source/changelog.rst

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

3232
* Add support for ``openmm.MonteCarloMembraneBarostat`` in Sire-to-OpenMM conversion.
3333

34+
* Added support for saving crash reports during Sire dynamics runs.
35+
3436
`2025.2.0 <https://github.com/openbiosim/sire/compare/2025.1.0...2025.2.0>`__ - October 2025
3537
--------------------------------------------------------------------------------------------
3638

src/sire/mol/_dynamics.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -972,6 +972,45 @@ def _rebuild_and_minimise(self):
972972
self._gcmc_sampler._set_water_state(self._omm_mols)
973973
self._gcmc_sampler.pop()
974974

975+
if self._save_crash_report:
976+
import openmm
977+
import numpy as np
978+
from copy import deepcopy
979+
from uuid import uuid4
980+
981+
# Create a unique identifier for this crash report.
982+
crash_id = str(uuid4())[:8]
983+
984+
# Get the current context and system.
985+
context = self._omm_mols
986+
system = deepcopy(context.getSystem())
987+
988+
# Add each force to a unique group.
989+
for i, f in enumerate(system.getForces()):
990+
f.setForceGroup(i)
991+
992+
# Create a new context.
993+
new_context = openmm.Context(system, deepcopy(context.getIntegrator()))
994+
new_context.setPositions(context.getState(getPositions=True).getPositions())
995+
996+
# Write the energies for each force group.
997+
with open(f"crash_{crash_id}.log", "w") as f:
998+
f.write(f"Current lambda: {str(self.get_lambda())}\n")
999+
for i, force in enumerate(system.getForces()):
1000+
state = new_context.getState(getEnergy=True, groups={i})
1001+
f.write(f"{force.getName()}, {state.getPotentialEnergy()}\n")
1002+
1003+
# Save the serialised system.
1004+
with open(f"system_{crash_id}.xml", "w") as f:
1005+
f.write(openmm.XmlSerializer.serialize(system))
1006+
1007+
# Save the positions.
1008+
positions = (
1009+
new_context.getState(getPositions=True).getPositions(asNumpy=True)
1010+
/ openmm.unit.nanometer
1011+
)
1012+
np.savetxt(f"positions_{crash_id}.txt", positions)
1013+
9751014
self.run_minimisation()
9761015

9771016
def run(
@@ -990,6 +1029,7 @@ def run(
9901029
null_energy: str = None,
9911030
excess_chemical_potential: float = None,
9921031
num_waters: int = None,
1032+
save_crash_report: bool = False,
9931033
):
9941034
if self.is_null():
9951035
return
@@ -1009,6 +1049,7 @@ def run(
10091049
"null_energy": null_energy,
10101050
"excess_chemical_potential": excess_chemical_potential,
10111051
"num_waters": num_waters,
1052+
"save_crash_report": save_crash_report,
10121053
}
10131054

10141055
from concurrent.futures import ThreadPoolExecutor
@@ -1048,6 +1089,13 @@ def run(
10481089
except:
10491090
raise ValueError("'num_waters' must be an integer")
10501091

1092+
if save_crash_report is not None:
1093+
if not isinstance(save_crash_report, bool):
1094+
raise ValueError("'save_crash_report' must be True or False")
1095+
self._save_crash_report = save_crash_report
1096+
else:
1097+
self._save_crash_report = False
1098+
10511099
try:
10521100
steps_to_run = int(time.to(picosecond) / self.timestep().to(picosecond))
10531101
except Exception:
@@ -1649,6 +1697,7 @@ def run(
16491697
null_energy: str = None,
16501698
excess_chemical_potential: str = None,
16511699
num_waters: int = None,
1700+
save_crash_report: bool = False,
16521701
):
16531702
"""
16541703
Perform dynamics on the molecules.
@@ -1722,6 +1771,13 @@ def run(
17221771
Whether to save the energy on exit, regardless of whether
17231772
the energy frequency has been reached.
17241773
1774+
save_crash_report: bool
1775+
Whether to save a crash report if the dynamics fails due to an
1776+
instability. This will save a named log file containing the energy
1777+
for each force, an XML file containing the OpenMM system at the
1778+
start of the dynamics block, and a NumPy text file containing
1779+
the atomic positions at the start of the dynamics block.
1780+
17251781
auto_fix_minimise: bool
17261782
Whether or not to automatically run minimisation if the
17271783
trajectory exits with an error in the first few steps.
@@ -1768,6 +1824,14 @@ def run(
17681824
else:
17691825
save_velocities = False
17701826

1827+
if save_crash_report is None:
1828+
if self._d._map.specified("save_crash_report"):
1829+
save_crash_report = (
1830+
self._d._map["save_crash_report"].value().as_bool()
1831+
)
1832+
else:
1833+
save_crash_report = False
1834+
17711835
self._d.run(
17721836
time=time,
17731837
save_frequency=save_frequency,
@@ -1778,6 +1842,7 @@ def run(
17781842
save_velocities=save_velocities,
17791843
save_frame_on_exit=save_frame_on_exit,
17801844
save_energy_on_exit=save_energy_on_exit,
1845+
save_crash_report=save_crash_report,
17811846
auto_fix_minimise=auto_fix_minimise,
17821847
num_energy_neighbours=num_energy_neighbours,
17831848
null_energy=null_energy,

tests/mol/test_dynamics.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ def test_cutoff_options(ala_mols):
8181
"openmm" not in sr.convert.supported_formats(),
8282
reason="openmm support is not available",
8383
)
84-
def test_sample_frequency(ala_mols):
84+
def test_sample_frequency(ala_mols, openmm_platform):
8585
"""
8686
Test that energies and frames are saved at the correct frequency.
8787
"""
@@ -92,7 +92,7 @@ def test_sample_frequency(ala_mols):
9292

9393
mols = ala_mols
9494

95-
d = mols.dynamics(platform="Reference", timestep="1 fs")
95+
d = mols.dynamics(platform=openmm_platform, timestep="1 fs")
9696

9797
# Create a list of lambda windows.
9898
lambdas = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
@@ -145,3 +145,51 @@ def test_sample_frequency(ala_mols):
145145

146146
# Check that the trajectory has 10 frames.
147147
assert new_mols.num_frames() == 10
148+
149+
150+
@pytest.mark.skipif(
151+
"openmm" not in sr.convert.supported_formats(),
152+
reason="openmm support is not available",
153+
)
154+
def test_crash_report(merged_ethane_methanol, openmm_platform):
155+
"""
156+
Test that energies and frames are saved at the correct frequency.
157+
"""
158+
159+
import os
160+
import glob
161+
import tempfile
162+
from sire.base import ProgressBar
163+
164+
ProgressBar.set_silent()
165+
166+
mols = merged_ethane_methanol.clone()
167+
mols = sr.morph.link_to_reference(mols)
168+
169+
d = mols.dynamics(platform=openmm_platform)
170+
171+
# Run a short simulation within a temporary directory.
172+
tmpdir = tempfile.TemporaryDirectory()
173+
174+
# Save the current directory.
175+
old_dir = os.getcwd()
176+
177+
try:
178+
# Change to the temporary directory.
179+
os.chdir(tmpdir.name)
180+
181+
# Run a short simulation, forcing a crash.
182+
d.run("1ps", save_crash_report=True)
183+
184+
# Glob for the crash report files.
185+
crash_log = glob.glob("crash_*.log")
186+
crash_system = glob.glob("system_*.xml")
187+
crash_positions = glob.glob("positions_*.txt")
188+
189+
# Make sure we have one of each file.
190+
assert len(crash_log) == 1
191+
assert len(crash_system) == 1
192+
assert len(crash_positions) == 1
193+
finally:
194+
# Change back to the old directory.
195+
os.chdir(old_dir)

0 commit comments

Comments
 (0)