Skip to content

Commit 8634693

Browse files
authored
Merge pull request #173 from OpenBioSim/backport_172
2 parents 40fa7f3 + 9eabe66 commit 8634693

2 files changed

Lines changed: 189 additions & 11 deletions

File tree

src/somd2/runner/_base.py

Lines changed: 96 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -362,14 +362,27 @@ def __init__(self, system, config):
362362
# Create alchemical ions.
363363
ion_indices = []
364364
if charge_diff != 0:
365-
self._system, coalchemical_restraints, ion_indices = (
365+
# On restart, reuse the exact molecule(s) chosen as alchemical ions
366+
# in the original run, rather than re-running the "furthest waters"
367+
# search. This makes ion selection independent of GCMC state (or
368+
# anything else that might change between runs), since the search
369+
# is otherwise only reproducible by assumption, not by construction.
370+
mol_indices = None
371+
if self._config.restart:
372+
mol_indices = self._load_alchemical_ion_indices()
373+
374+
self._system, coalchemical_restraints, ion_indices, ion_mol_indices = (
366375
self._create_alchemical_ions(
367376
self._system,
368377
charge_diff,
369378
restraint_distance=self._config.coalchemical_restraint_dist,
379+
mol_indices=mol_indices,
370380
)
371381
)
372382

383+
# Keep the stored indices in sync for any future restart.
384+
self._save_alchemical_ion_indices(ion_mol_indices)
385+
373386
# Add the coalchemical restraints to the extra args.
374387
if coalchemical_restraints is not None:
375388
self._config._extra_args["coalchemical_restraints"] = (
@@ -1045,8 +1058,49 @@ def _get_charge_difference(system):
10451058

10461059
return perturbed - reference
10471060

1061+
def _save_alchemical_ion_indices(self, mol_indices):
1062+
"""
1063+
Persist the absolute molecule index of each alchemical ion to a small,
1064+
dedicated file in the output directory, independent of the per-window
1065+
(regular runner) or shared (repex) checkpoint formats. This allows a
1066+
restart to reuse the exact same ion(s) chosen in the original run.
1067+
1068+
Parameters
1069+
----------
1070+
1071+
mol_indices: [int]
1072+
The absolute molecule index of each alchemical ion.
1073+
"""
1074+
import numpy as _np
1075+
1076+
path = self._config.output_directory / "alchemical_ions.npz"
1077+
_np.savez(path, mol_indices=_np.array(mol_indices, dtype=int))
1078+
1079+
def _load_alchemical_ion_indices(self):
1080+
"""
1081+
Load the absolute molecule index of each alchemical ion previously
1082+
stored by `_save_alchemical_ion_indices`, if present.
1083+
1084+
Returns
1085+
-------
1086+
1087+
mol_indices: [int], None
1088+
The absolute molecule index of each alchemical ion, or None if no
1089+
stored indices are available (e.g. a fresh run, or a restart from
1090+
an output directory that predates this feature).
1091+
"""
1092+
import numpy as _np
1093+
1094+
path = self._config.output_directory / "alchemical_ions.npz"
1095+
try:
1096+
return _np.load(path)["mol_indices"].tolist()
1097+
except Exception:
1098+
return None
1099+
10481100
@staticmethod
1049-
def _create_alchemical_ions(system, charge_diff, restraint_distance=None):
1101+
def _create_alchemical_ions(
1102+
system, charge_diff, restraint_distance=None, mol_indices=None
1103+
):
10501104
"""
10511105
Internal function to create alchemical ions to maintain a constant charge.
10521106
@@ -1059,6 +1113,14 @@ def _create_alchemical_ions(system, charge_diff, restraint_distance=None):
10591113
charge_diff: int
10601114
The charge difference between perturbed and reference states.
10611115
1116+
mol_indices: [int]
1117+
The absolute molecule index (position in `system.molecules()`) of
1118+
each water to convert into an alchemical ion. If provided, these
1119+
molecules are converted directly, bypassing the "furthest waters"
1120+
search. Used on restart to reproduce the exact same ion(s) chosen
1121+
in the original run, independent of any GCMC state or changes to
1122+
the search heuristic. Must have the same length as `abs(charge_diff)`.
1123+
10621124
Returns
10631125
-------
10641126
@@ -1073,6 +1135,11 @@ def _create_alchemical_ions(system, charge_diff, restraint_distance=None):
10731135
The perturbable-molecule index of each alchemical ion that was
10741136
added, suitable for use with
10751137
`LambdaSchedule.set_molecule_schedule <sire.cas.LambdaSchedule>`.
1138+
1139+
ion_mol_indices: [int]
1140+
The absolute molecule index (position in `system.molecules()`,
1141+
prior to any conversion) of each alchemical ion that was added.
1142+
Suitable for passing back in as `mol_indices` on a restart.
10761143
"""
10771144

10781145
from sire.legacy.IO import createChlorineIon as _createChlorineIon
@@ -1116,12 +1183,28 @@ def _create_alchemical_ions(system, charge_diff, restraint_distance=None):
11161183
f"{len(system['water'].molecules())} available."
11171184
)
11181185

1119-
# Reference coordinates.
1120-
coords = system.molecules("property is_perturbable").coordinates()
1121-
coord_string = f"{coords[0].value()}, {coords[1].value()}, {coords[2].value()}"
1186+
if mol_indices is not None:
1187+
if len(mol_indices) != num_waters:
1188+
raise ValueError(
1189+
f"Number of stored alchemical-ion molecule indices "
1190+
f"({len(mol_indices)}) does not match the current charge "
1191+
f"difference ({num_waters} waters required)."
1192+
)
1193+
1194+
# Reuse the exact molecules chosen in the original run.
1195+
all_mols = system.molecules()
1196+
waters = [all_mols[idx] for idx in mol_indices]
1197+
else:
1198+
# Reference coordinates.
1199+
coords = system.molecules("property is_perturbable").coordinates()
1200+
coord_string = (
1201+
f"{coords[0].value()}, {coords[1].value()}, {coords[2].value()}"
1202+
)
11221203

1123-
# Find the furthest N waters from the perturbable molecule.
1124-
waters = system[f"furthest {num_waters} waters from {coord_string}"].molecules()
1204+
# Find the furthest N waters from the perturbable molecule.
1205+
waters = system[
1206+
f"furthest {num_waters} waters from {coord_string}"
1207+
].molecules()
11251208

11261209
# Determine the water model.
11271210
if waters[0].num_atoms() == 3:
@@ -1141,6 +1224,10 @@ def _create_alchemical_ions(system, charge_diff, restraint_distance=None):
11411224
# Store the molecule numbers of the alchemical ions.
11421225
ion_numbers = []
11431226

1227+
# Store the absolute molecule index of each alchemical ion (prior to
1228+
# conversion), for persisting across restarts.
1229+
ion_mol_indices = []
1230+
11441231
# Create the ions.
11451232
for water in waters:
11461233
# Flag to indicate whether we need to reverse the alchemical ion
@@ -1261,6 +1348,7 @@ def _create_alchemical_ions(system, charge_diff, restraint_distance=None):
12611348

12621349
# Get the index of the perturbed water.
12631350
index = numbers.index(water.number())
1351+
ion_mol_indices.append(index)
12641352

12651353
# Log that we are adding an alchemical ion.
12661354
if is_reverse:
@@ -1283,7 +1371,7 @@ def _create_alchemical_ions(system, charge_diff, restraint_distance=None):
12831371
perturbable_mols = system.molecules()["perturbable"].molecules()
12841372
ion_indices = [perturbable_mols.find(system[number]) for number in ion_numbers]
12851373

1286-
return system, restraints, ion_indices
1374+
return system, restraints, ion_indices, ion_mol_indices
12871375

12881376
@staticmethod
12891377
def _create_filenames(lambda_array, lambda_value, output_directory, restart=False):

tests/runner/test_alchemical_ions.py

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import math
2+
import pytest
23
import tempfile
34

4-
import pytest
5+
from pathlib import Path
56

67
from somd2.config import Config
78
from somd2.runner import Runner
@@ -15,20 +16,109 @@ def test_alchemical_ions(mols, request):
1516
mols = request.getfixturevalue(mols).clone()
1617

1718
# Add 10 Cl- ions.
18-
new_mols, _, ion_indices = Runner._create_alchemical_ions(mols, 10)
19+
new_mols, _, ion_indices, ion_mol_indices = Runner._create_alchemical_ions(mols, 10)
1920

2021
# Make sure the charge difference is correct.
2122
assert math.isclose(Runner._get_charge_difference(new_mols), -10.0, rel_tol=1e-6)
2223

2324
# Make sure there is one perturbable-molecule index per ion.
2425
assert len(ion_indices) == 10
26+
assert len(ion_mol_indices) == 10
2527

2628
# Add 10 Na+ ions.
27-
new_mols, _, ion_indices = Runner._create_alchemical_ions(mols, -10)
29+
new_mols, _, ion_indices, ion_mol_indices = Runner._create_alchemical_ions(
30+
mols, -10
31+
)
2832

2933
# Make sure the charge difference is correct.
3034
assert math.isclose(Runner._get_charge_difference(new_mols), 10.0, rel_tol=1e-6)
3135
assert len(ion_indices) == 10
36+
assert len(ion_mol_indices) == 10
37+
38+
39+
@pytest.mark.parametrize("mols", ["ethane_methanol", "ethane_methanol_ions"])
40+
def test_alchemical_ion_mol_indices_reproducible(mols, request):
41+
"""
42+
Ensure that passing the molecule indices returned by a previous call to
43+
`_create_alchemical_ions` reproduces the exact same ion(s), bypassing the
44+
"furthest waters" search entirely. This is what a restart relies on.
45+
"""
46+
mols = request.getfixturevalue(mols).clone()
47+
48+
# Pick ions via the heuristic search, recording which molecule(s) were
49+
# converted.
50+
heuristic_mols, _, _, ion_mol_indices = Runner._create_alchemical_ions(mols, 3)
51+
heuristic_ion_numbers = {
52+
mol.number()
53+
for mol in heuristic_mols.molecules()["perturbable"].molecules()
54+
if mol.has_property("is_alchemical_ion")
55+
}
56+
57+
# Reuse the stored indices directly - should convert the exact same
58+
# molecules, without running the search.
59+
replayed_mols, _, _, replayed_mol_indices = Runner._create_alchemical_ions(
60+
mols, 3, mol_indices=ion_mol_indices
61+
)
62+
replayed_ion_numbers = {
63+
mol.number()
64+
for mol in replayed_mols.molecules()["perturbable"].molecules()
65+
if mol.has_property("is_alchemical_ion")
66+
}
67+
68+
assert replayed_ion_numbers == heuristic_ion_numbers
69+
assert replayed_mol_indices == ion_mol_indices
70+
assert math.isclose(
71+
Runner._get_charge_difference(replayed_mols), -3.0, rel_tol=1e-6
72+
)
73+
74+
75+
def test_alchemical_ion_mol_indices_mismatch_raises(ethane_methanol):
76+
"""A stored index count that doesn't match the charge difference should
77+
raise a clear error, rather than silently converting the wrong number of
78+
waters."""
79+
mols = ethane_methanol.clone()
80+
81+
with pytest.raises(ValueError, match="does not match the current charge"):
82+
Runner._create_alchemical_ions(mols, 3, mol_indices=[0, 1])
83+
84+
85+
def test_alchemical_ion_restart_reuses_same_ion(ethane_methanol_ions):
86+
"""
87+
Ensure that restarting a run picks the exact same alchemical ion as the
88+
original run, via the persisted `alchemical_ions.npz` file, rather than
89+
re-running the "furthest waters" search from scratch.
90+
"""
91+
mols = ethane_methanol_ions.clone()
92+
93+
with tempfile.TemporaryDirectory() as tmpdir:
94+
base_config = dict(
95+
output_directory=tmpdir,
96+
platform="cpu",
97+
charge_difference=1,
98+
)
99+
100+
# Fresh run: picks an ion via the heuristic search and persists its
101+
# molecule index to alchemical_ions.npz.
102+
runner1 = Runner(mols.clone(), Config(restart=False, **base_config))
103+
ion_number_1 = next(
104+
mol.number()
105+
for mol in runner1._system.molecules()["perturbable"].molecules()
106+
if mol.has_property("is_alchemical_ion")
107+
)
108+
109+
assert (Path(tmpdir) / "alchemical_ions.npz").exists()
110+
111+
# "Restart": construct a new Runner against the same input and output
112+
# directory. It should reuse the stored ion index rather than
113+
# re-running the search.
114+
runner2 = Runner(mols.clone(), Config(restart=True, **base_config))
115+
ion_number_2 = next(
116+
mol.number()
117+
for mol in runner2._system.molecules()["perturbable"].molecules()
118+
if mol.has_property("is_alchemical_ion")
119+
)
120+
121+
assert ion_number_1 == ion_number_2
32122

33123

34124
@pytest.mark.parametrize("schedule_name", ["decouple", "annihilate"])

0 commit comments

Comments
 (0)