Skip to content

Commit c9dd8f6

Browse files
authored
Merge pull request #138 from OpenBioSim/feature_terminal_flip
Add support for Terminal Flip Monte Carlo
2 parents 778300f + 3f281a7 commit c9dd8f6

10 files changed

Lines changed: 1560 additions & 11 deletions

File tree

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,29 @@ require a different `nvcc` to that provided by conda, you can set the
219219
Depending on your setup, you may also need to install the `cuda-nvvm` package
220220
from `conda-forge`.
221221

222+
## Terminal ring flip Monte Carlo
223+
224+
SOMD2 supports terminal ring flip Monte Carlo (MC) moves to improve sampling
225+
of terminal aromatic rings in perturbable ligands, as described in
226+
[this paper](https://chemrxiv.org/doi/full/10.26434/chemrxiv-2025-2zkx5).
227+
Each move attempts a discrete rotation of a terminal ring around the bond
228+
connecting it to the rest of the molecule, accepted or rejected via the
229+
Metropolis criterion. Terminal ring groups are detected automatically from
230+
the molecular connectivity of perturbable molecules.
231+
232+
To enable terminal flip MC, set the frequency at which moves are attempted:
233+
234+
```
235+
somd2 perturbable_system.bss --terminal-flip-frequency "1 ps"
236+
```
237+
238+
The flip angle for each group is determined automatically from the ring
239+
geometry. To override this for all groups:
240+
241+
```
242+
somd2 perturbable_system.bss --terminal-flip-frequency "1 ps" --terminal-flip-angle "180 degrees"
243+
```
244+
222245
## Analysis
223246

224247
Simulation output will be written to the directory specified using the

src/somd2/_utils/__init__.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,19 @@
1919
# along with SOMD2. If not, see <http://www.gnu.org/licenses/>.
2020
#####################################################################
2121

22-
import platform as _platform
22+
import sys as _sys
2323

24-
if _platform.system() == "Windows":
25-
_lam_sym = "lambda"
26-
else:
24+
try:
25+
"λΔ°".encode(_sys.stdout.encoding or "utf-8")
2726
_lam_sym = "λ"
27+
_delta_sym = "ΔE"
28+
_degree_sym = "°"
29+
except (UnicodeEncodeError, LookupError):
30+
_lam_sym = "lambda"
31+
_delta_sym = "delta"
32+
_degree_sym = "deg"
2833

29-
del _platform
34+
del _sys
3035

3136

3237
def _has_ghost(mol, idxs, is_lambda1=False):

src/somd2/config/_config.py

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ def __init__(
139139
replica_exchange=False,
140140
randomise_velocities=False,
141141
perturbed_system=None,
142+
terminal_flip_frequency=None,
143+
terminal_flip_angle=None,
144+
terminal_flip_max_mobile_atoms=None,
142145
gcmc=False,
143146
gcmc_frequency=None,
144147
gcmc_selection=None,
@@ -370,13 +373,30 @@ def __init__(
370373
GPU resources are available.
371374
372375
randomise_velocities: bool
373-
Whether to randomise velocities at the start of each replica exchange cycle.
376+
Whether to randomise velocities at the start of each replica exchange cycle
377+
or following a terminal flip Monte Carlo move.
374378
375379
perturbed_system: str
376380
The path to a stream file containing a Sire system for the equilibrated perturbed
377381
end state (lambda = 1). This will be used as the starting conformation all lambda
378382
windows > 0.5 when performing a replica exchange simulation.
379383
384+
terminal_flip_frequency: str
385+
Frequency at which to attempt terminal ring flip Monte Carlo moves. If None
386+
(the default), no terminal flip moves will be performed. When set, terminal
387+
ring groups in perturbable molecules are detected automatically using Sire's
388+
native connectivity. This must be a multiple of 'energy_frequency'.
389+
390+
terminal_flip_angle: str
391+
Override the flip angle used for all terminal ring groups, e.g.
392+
``"180 degrees"``. If None (the default), the angle is determined
393+
automatically for each group from its geometry.
394+
395+
terminal_flip_max_mobile_atoms: int or None
396+
Maximum number of mobile atoms allowed in a terminal ring group.
397+
Groups with more mobile atoms than this threshold are skipped during
398+
detection. Defaults to None (no limit).
399+
380400
gcmc: bool
381401
Whether to perform Grand Canonical Monte Carlo (GCMC) water insertions/deletions.
382402
@@ -559,6 +579,9 @@ def __init__(
559579
self.replica_exchange = replica_exchange
560580
self.randomise_velocities = randomise_velocities
561581
self.perturbed_system = perturbed_system
582+
self.terminal_flip_frequency = terminal_flip_frequency
583+
self.terminal_flip_angle = terminal_flip_angle
584+
self.terminal_flip_max_mobile_atoms = terminal_flip_max_mobile_atoms
562585
self.gcmc = gcmc
563586
self.gcmc_frequency = gcmc_frequency
564587
self.gcmc_selection = gcmc_selection
@@ -1994,6 +2017,80 @@ def perturbed_system(self, perturbed_system):
19942017
self._perturbed_system = None
19952018
self._perturbed_system_file = None
19962019

2020+
@property
2021+
def terminal_flip_frequency(self):
2022+
return self._terminal_flip_frequency
2023+
2024+
@terminal_flip_frequency.setter
2025+
def terminal_flip_frequency(self, terminal_flip_frequency):
2026+
if terminal_flip_frequency is not None:
2027+
if not isinstance(terminal_flip_frequency, str):
2028+
raise TypeError("'terminal_flip_frequency' must be of type 'str'")
2029+
2030+
from sire.units import picosecond
2031+
2032+
try:
2033+
t = _sr.u(terminal_flip_frequency)
2034+
except Exception:
2035+
raise ValueError(
2036+
f"Unable to parse 'terminal_flip_frequency' as a Sire GeneralUnit: "
2037+
f"{terminal_flip_frequency}"
2038+
)
2039+
2040+
if t.value() != 0 and not t.has_same_units(picosecond):
2041+
raise ValueError("'terminal_flip_frequency' units are invalid.")
2042+
2043+
self._terminal_flip_frequency = t
2044+
else:
2045+
self._terminal_flip_frequency = None
2046+
2047+
@property
2048+
def terminal_flip_angle(self):
2049+
return self._terminal_flip_angle
2050+
2051+
@terminal_flip_angle.setter
2052+
def terminal_flip_angle(self, terminal_flip_angle):
2053+
if terminal_flip_angle is not None:
2054+
if not isinstance(terminal_flip_angle, str):
2055+
raise TypeError("'terminal_flip_angle' must be of type 'str'")
2056+
2057+
from sire.units import degrees
2058+
2059+
try:
2060+
a = _sr.u(terminal_flip_angle)
2061+
except Exception:
2062+
raise ValueError(
2063+
f"Unable to parse 'terminal_flip_angle' as a Sire GeneralUnit: "
2064+
f"{terminal_flip_angle}"
2065+
)
2066+
2067+
if not a.has_same_units(degrees):
2068+
raise ValueError("'terminal_flip_angle' units are invalid.")
2069+
2070+
self._terminal_flip_angle = a
2071+
else:
2072+
self._terminal_flip_angle = None
2073+
2074+
@property
2075+
def terminal_flip_max_mobile_atoms(self):
2076+
return self._terminal_flip_max_mobile_atoms
2077+
2078+
@terminal_flip_max_mobile_atoms.setter
2079+
def terminal_flip_max_mobile_atoms(self, terminal_flip_max_mobile_atoms):
2080+
if terminal_flip_max_mobile_atoms is not None:
2081+
if not isinstance(terminal_flip_max_mobile_atoms, int):
2082+
try:
2083+
terminal_flip_max_mobile_atoms = int(terminal_flip_max_mobile_atoms)
2084+
except:
2085+
raise ValueError(
2086+
"'terminal_flip_max_mobile_atoms' must be of type 'int'"
2087+
)
2088+
if terminal_flip_max_mobile_atoms < 1:
2089+
raise ValueError(
2090+
"'terminal_flip_max_mobile_atoms' must be greater than 0"
2091+
)
2092+
self._terminal_flip_max_mobile_atoms = terminal_flip_max_mobile_atoms
2093+
19972094
@property
19982095
def gcmc(self):
19992096
return self._gcmc

src/somd2/runner/_base.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,59 @@ def __init__(self, system, config):
664664
# Store the excess chemcical potential value.
665665
self._mu_ex = self._config.gcmc_excess_chemical_potential.value()
666666

667+
# Terminal flip specific validation and setup.
668+
if self._config.terminal_flip_frequency is not None:
669+
from math import isclose
670+
671+
# Make sure the terminal flip frequency is a multiple of the
672+
# energy frequency.
673+
ratio = (
674+
self._config.terminal_flip_frequency / self._config.energy_frequency
675+
).value()
676+
677+
if not isclose(ratio, round(ratio), abs_tol=1e-4):
678+
msg = "'terminal_flip_frequency' must be a multiple of 'energy_frequency'."
679+
_logger.error(msg)
680+
raise ValueError(msg)
681+
682+
# Auto-detect terminal ring groups using Sire connectivity.
683+
from ._samplers import detect_terminal_groups
684+
685+
if isinstance(self._system, list):
686+
mols = self._system[0]
687+
else:
688+
mols = self._system
689+
690+
flip_angle = (
691+
self._config.terminal_flip_angle.to("degrees").value()
692+
if self._config.terminal_flip_angle is not None
693+
else None
694+
)
695+
self._terminal_groups = detect_terminal_groups(
696+
mols,
697+
flip_angle=flip_angle,
698+
max_mobile_atoms=self._config.terminal_flip_max_mobile_atoms,
699+
)
700+
701+
if not self._terminal_groups:
702+
_logger.warning(
703+
"No terminal ring groups detected. Terminal flip moves will not "
704+
"be performed."
705+
)
706+
else:
707+
_logger.info(
708+
f"Detected {len(self._terminal_groups)} terminal ring group(s) "
709+
f"for terminal flip MC."
710+
)
711+
for i, (angle, indices) in enumerate(self._terminal_groups):
712+
_logger.info(
713+
f" Group {i}: flip angle = {angle}°, "
714+
f"anchor = {indices[0]}, pivot = {indices[1]}, "
715+
f"{len(indices) - 2} mobile atom(s)"
716+
)
717+
else:
718+
self._terminal_groups = []
719+
667720
# Store the initial system time.
668721
if isinstance(self._system, list):
669722
self._initial_time = []
@@ -1148,6 +1201,7 @@ def increment_filename(base_filename, suffix):
11481201
output_directory / f"energy_components_{lam}.txt"
11491202
)
11501203
filenames["gcmc_ghosts"] = str(output_directory / f"gcmc_ghosts_{lam}.txt")
1204+
filenames["sampler_stats"] = str(output_directory / f"sampler_stats_{lam}.pkl")
11511205
if restart:
11521206
filenames["config"] = str(
11531207
output_directory / increment_filename("config", "yaml")

0 commit comments

Comments
 (0)